diff --git a/src/KenjinxAndroid/app/src/main/AndroidManifest.xml b/src/KenjinxAndroid/app/src/main/AndroidManifest.xml index a19981735..b73f18e16 100644 --- a/src/KenjinxAndroid/app/src/main/AndroidManifest.xml +++ b/src/KenjinxAndroid/app/src/main/AndroidManifest.xml @@ -2,6 +2,18 @@ + + + + + + + + + + + + @@ -15,6 +27,12 @@ tools:ignore="ScopedStorage" /> + + + + + + --> + + + + + + diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController.kt index 2ababa511..21bcbb24f 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController.kt @@ -37,15 +37,38 @@ typealias GamePadConfig = RadialGamePadConfig private const val DUMMY_LEFT_STICK_PRESS_ID = 10001 private const val DUMMY_RIGHT_STICK_PRESS_ID = 10002 -class GameController(var activity: Activity) { +class GameController(var activity: Activity) : IGameController { companion object { private fun init(context: Context, controller: GameController): View { val inflater = LayoutInflater.from(context) val parent = FrameLayout(context) val view = inflater.inflate(R.layout.game_layout, parent, false) - view.findViewById(R.id.leftcontainer)!!.addView(controller.leftGamePad) - view.findViewById(R.id.rightcontainer)!!.addView(controller.rightGamePad) + + val leftContainer = view.findViewById(R.id.leftcontainer)!! + val rightContainer = view.findViewById(R.id.rightcontainer)!! + + leftContainer.addView(controller.leftGamePad) + rightContainer.addView(controller.rightGamePad) + + leftContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f) + v.pivotX = 0f + v.pivotY = v.height.toFloat() + v.scaleX = scale + v.scaleY = scale + } + rightContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f) + v.pivotX = v.width.toFloat() + v.pivotY = v.height.toFloat() + v.scaleX = scale + v.scaleY = scale + } + + leftContainer.post { leftContainer.requestLayout() } + rightContainer.post { rightContainer.requestLayout() } + return view } @@ -76,16 +99,18 @@ class GameController(var activity: Activity) { } } + private var controllerView: View? = null var leftGamePad: GamePad var rightGamePad: GamePad var controllerId: Int = -1 - val isVisible: Boolean + override val isVisible: Boolean get() = controllerView?.isVisible ?: false init { - leftGamePad = GamePad(generateConfig(true), 16f, activity) - rightGamePad = GamePad(generateConfig(false), 16f, activity) + val useSwitchLayout = QuickSettings(activity).useSwitchLayout + leftGamePad = GamePad(generateConfig(true, useSwitchLayout), 16f, activity) + rightGamePad = GamePad(generateConfig(false, useSwitchLayout), 16f, activity) leftGamePad.primaryDialMaxSizeDp = 200f rightGamePad.primaryDialMaxSizeDp = 200f @@ -96,14 +121,14 @@ class GameController(var activity: Activity) { rightGamePad.gravityY = 1f } - fun setVisible(isVisible: Boolean) { + override fun setVisible(isVisible: Boolean) { controllerView?.apply { this.isVisible = isVisible if (isVisible) connect() } } - fun connect() { + override fun connect() { if (controllerId == -1) controllerId = KenjinxNative.inputConnectGamepad(0) } @@ -177,7 +202,7 @@ suspend fun Flow.safeCollect(block: suspend (T) -> Unit) { .collect { block(it) } } -private fun generateConfig(isLeft: Boolean): GamePadConfig { +private fun generateConfig(isLeft: Boolean, useSwitchLayout: Boolean): GamePadConfig { val distance = 0.3f val buttonScale = 1f @@ -293,20 +318,23 @@ private fun generateConfig(isLeft: Boolean): GamePadConfig { /* ringSegments = */ 12, /* Primary (ABXY) */ PrimaryDialConfig.PrimaryButtons( - listOf( - ButtonConfig( - GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null - ), - ButtonConfig( - GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null - ), - ButtonConfig( - GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null - ), - ButtonConfig( - GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null + if (useSwitchLayout) { + // Switch/Nintendo: A=Right, X=Top, Y=Left, B=Bottom + listOf( + ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null), // Right + ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Top + ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Left + ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null) // Bottom ) - ), + } else { + // Xbox-Stil: B=Right, Y=Top, X=Left, A=Bottom + listOf( + ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null), // Right + ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Top + ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Left + ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null) // Bottom + ) + }, null, 0f, true, diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController2.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController2.kt new file mode 100644 index 000000000..50e85b42c --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController2.kt @@ -0,0 +1,421 @@ +package org.kenjinx.android + +import android.app.Activity +import android.content.Context +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.math.MathUtils +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.swordfish.radialgamepad.library.RadialGamePad +import com.swordfish.radialgamepad.library.config.ButtonConfig +import com.swordfish.radialgamepad.library.config.CrossConfig +import com.swordfish.radialgamepad.library.config.CrossContentDescription +import com.swordfish.radialgamepad.library.config.PrimaryDialConfig +import com.swordfish.radialgamepad.library.config.RadialGamePadConfig +import com.swordfish.radialgamepad.library.config.SecondaryDialConfig +import com.swordfish.radialgamepad.library.event.Event +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import org.kenjinx.android.viewmodels.MainViewModel +import org.kenjinx.android.viewmodels.QuickSettings + +// --- Dummy IDs to disconnect legacy L3/R3 (stick double tap/tap) --- +private const val DUMMY_LEFT_STICK_PRESS_ID = 10001 +private const val DUMMY_RIGHT_STICK_PRESS_ID = 10002 + +class GameController2(var activity: Activity) : IGameController { + + companion object { + private fun init(context: Context, controller: GameController2): View { + val inflater = LayoutInflater.from(context) + val parent = FrameLayout(context) + val view = inflater.inflate(R.layout.game_layout, parent, false) + + val leftContainer = view.findViewById(R.id.leftcontainer)!! + val rightContainer = view.findViewById(R.id.rightcontainer)!! + + leftContainer.addView(controller.leftGamePad) + rightContainer.addView(controller.rightGamePad) + + leftContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f) + v.pivotX = 0f + v.pivotY = v.height.toFloat() + v.scaleX = scale + v.scaleY = scale + } + rightContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f) + v.pivotX = v.width.toFloat() + v.pivotY = v.height.toFloat() + v.scaleX = scale + v.scaleY = scale + } + + leftContainer.post { leftContainer.requestLayout() } + rightContainer.post { rightContainer.requestLayout() } + + return view + } + + @Composable + fun Compose(viewModel: MainViewModel) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + val controller = GameController2(viewModel.activity) + val c = init(context, controller) + + viewModel.activity.lifecycleScope.launch { + val events = merge( + controller.leftGamePad.events(), + controller.rightGamePad.events() + ) + events.safeCollect { controller.handleEvent(it) } + } + + controller.controllerView = c + viewModel.setGameController(controller) + controller.setVisible(QuickSettings(viewModel.activity).useVirtualController) + c + } + ) + } + } + + + private var controllerView: View? = null + var leftGamePad: RadialGamePad + var rightGamePad: RadialGamePad + var controllerId: Int = -1 + override val isVisible: Boolean + get() = controllerView?.isVisible ?: false + + init { + val useSwitchLayout = QuickSettings(activity).useSwitchLayout + leftGamePad = RadialGamePad(generateConfig2(true, useSwitchLayout), 16f, activity) + rightGamePad = RadialGamePad(generateConfig2(false, useSwitchLayout), 16f, activity) + + leftGamePad.primaryDialMaxSizeDp = 200f + rightGamePad.primaryDialMaxSizeDp = 200f + + leftGamePad.gravityX = -1f + leftGamePad.gravityY = 1f + rightGamePad.gravityX = 1f + rightGamePad.gravityY = 1f + } + + override fun setVisible(isVisible: Boolean) { + controllerView?.apply { + this.isVisible = isVisible + if (isVisible) connect() + } + } + + override fun connect() { + if (controllerId == -1) + controllerId = KenjinxNative.inputConnectGamepad(0) + } + + private fun handleEvent(ev: Event) { + if (controllerId == -1) + controllerId = KenjinxNative.inputConnectGamepad(0) + + controllerId.apply { + when (ev) { + is Event.Button -> { + // Ignore legacy L3/R3 via stick press (double tap) + if (ev.id == DUMMY_LEFT_STICK_PRESS_ID || ev.id == DUMMY_RIGHT_STICK_PRESS_ID) { + return + } + when (ev.action) { + KeyEvent.ACTION_UP -> KenjinxNative.inputSetButtonReleased(ev.id, this) + KeyEvent.ACTION_DOWN -> KenjinxNative.inputSetButtonPressed(ev.id, this) + } + } + + is Event.Direction -> { + when (ev.id) { + GamePadButtonInputId.DpadUp.ordinal -> { + // Horizontal + if (ev.xAxis > 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this) + } else if (ev.xAxis < 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this) + } else { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this) + } + // Vertical + if (ev.yAxis < 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this) + } else if (ev.yAxis > 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this) + } else { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this) + } + } + + GamePadButtonInputId.LeftStick.ordinal -> { + val setting = QuickSettings(activity) + val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f) + val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f) + KenjinxNative.inputSetStickAxis(1, x, -y, this) + } + + GamePadButtonInputId.RightStick.ordinal -> { + val setting = QuickSettings(activity) + val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f) + val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f) + KenjinxNative.inputSetStickAxis(2, x, -y, this) + } + } + } + } + } + } +} + +// --- Lokale Kopie der Config-Erzeugung (Name = generateConfig2, um Konflikte zu vermeiden) --- +private fun generateConfig2(isLeft: Boolean, useSwitchLayout: Boolean): RadialGamePadConfig { + val distance = 0.3f + val buttonScale = 1f + + if (isLeft) { + return RadialGamePadConfig( + /* ringSegments = */ 12, + /* Primary (Stick) */ + // IMPORTANT: pressButtonId -> DUMMY_LEFT_STICK_PRESS_ID, so that double tap does not trigger L3 + PrimaryDialConfig.Stick( + GamePadButtonInputId.LeftStick.ordinal, + DUMMY_LEFT_STICK_PRESS_ID, + setOf(), + "LeftStick", + null + ), + listOf( + // D-Pad + SecondaryDialConfig.Cross( + /* sector */ 10, + /* size */ 3, + /* gap */ 2.1f, + distance, + CrossConfig( + GamePadButtonInputId.DpadUp.ordinal, + CrossConfig.Shape.STANDARD, + null, + setOf(), + CrossContentDescription(), + true, + null + ), + SecondaryDialConfig.RotationProcessor() + ), + + // Minus + SecondaryDialConfig.SingleButton( + /* sector */ 9, + buttonScale, + 0.3f, + ButtonConfig( + GamePadButtonInputId.Minus.ordinal, + "-", + true, + null, + "Minus", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // L-Bumper + SecondaryDialConfig.DoubleButton( + /* sector */ 2, + 0.2f, + ButtonConfig( + GamePadButtonInputId.LeftShoulder.ordinal, + "L", + true, + null, + "LeftBumper", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // ZL-Trigger + SecondaryDialConfig.DoubleButton( + /* sector */ 2, + 1f, + ButtonConfig( + GamePadButtonInputId.LeftTrigger.ordinal, + "ZL", + true, + null, + "LeftTrigger", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // L3 separat + SecondaryDialConfig.SingleButton( + /* sector */ 1, + buttonScale, + 1.0f, + ButtonConfig( + GamePadButtonInputId.LeftStickButton.ordinal, + "L3", + true, + null, + "LeftStickButton", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + ) + ) + } else { + return RadialGamePadConfig( + /* ringSegments = */ 12, + /* Primary (ABXY) */ + PrimaryDialConfig.PrimaryButtons( + if (useSwitchLayout) { + // Switch/Nintendo: A=Right, X=Top, Y=Left, B=Bottom + listOf( + ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null), // Right + ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Top + ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Left + ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null) // Bottom + ) + } else { + // Xbox-Stil: B=Right, Y=Top, X=Left, A=Bottom + listOf( + ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null), // Right + ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Top + ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Left + ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null) // Bottom + ) + }, + null, + 0f, + true, + null + ), + listOf( + // Right stick + // IMPORTANT: pressButtonId -> DUMMY_RIGHT_STICK_PRESS_ID, so that double tap does not trigger R3 + SecondaryDialConfig.Stick( + /* sector */ 6, + /* size */ 3, + /* gap */ 2.7f, + distance, + GamePadButtonInputId.RightStick.ordinal, + DUMMY_RIGHT_STICK_PRESS_ID, + null, + setOf(), + "RightStick", + SecondaryDialConfig.RotationProcessor() + ), + + // Plus + SecondaryDialConfig.SingleButton( + /* sector */ 9, + buttonScale, + 0.3f, + ButtonConfig( + GamePadButtonInputId.Plus.ordinal, + "+", + true, + null, + "Plus", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // R-Bumper + SecondaryDialConfig.DoubleButton( + /* sector */ 3, + 0.2f, + ButtonConfig( + GamePadButtonInputId.RightShoulder.ordinal, + "R", + true, + null, + "RightBumper", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // ZR-Trigger + SecondaryDialConfig.DoubleButton( + /* sector */ 3, + 1f, + ButtonConfig( + GamePadButtonInputId.RightTrigger.ordinal, + "ZR", + true, + null, + "RightTrigger", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // R3 separat + SecondaryDialConfig.SingleButton( + /* sector */ 5, + buttonScale, + 1.0f, + ButtonConfig( + GamePadButtonInputId.RightStickButton.ordinal, + "R3", + true, + null, + "RightStickButton", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + ) + ) + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController3.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController3.kt new file mode 100644 index 000000000..44237e107 --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController3.kt @@ -0,0 +1,414 @@ +package org.kenjinx.android + +import android.app.Activity +import android.content.Context +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.math.MathUtils +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.swordfish.radialgamepad.library.RadialGamePad +import com.swordfish.radialgamepad.library.config.ButtonConfig +import com.swordfish.radialgamepad.library.config.CrossConfig +import com.swordfish.radialgamepad.library.config.CrossContentDescription +import com.swordfish.radialgamepad.library.config.PrimaryDialConfig +import com.swordfish.radialgamepad.library.config.RadialGamePadConfig +import com.swordfish.radialgamepad.library.config.SecondaryDialConfig +import com.swordfish.radialgamepad.library.event.Event +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import org.kenjinx.android.viewmodels.MainViewModel +import org.kenjinx.android.viewmodels.QuickSettings + +// --- Dummy IDs to disconnect legacy L3/R3 (stick double tap/tap) --- +private const val DUMMY_LEFT_STICK_PRESS_ID = 10001 +private const val DUMMY_RIGHT_STICK_PRESS_ID = 10002 + +class GameController3(var activity: Activity) : IGameController { + + companion object { + private fun init(context: Context, controller: GameController3): View { + val inflater = LayoutInflater.from(context) + val parent = FrameLayout(context) + val view = inflater.inflate(R.layout.game_layout, parent, false) + + val leftContainer = view.findViewById(R.id.leftcontainer)!! + val rightContainer = view.findViewById(R.id.rightcontainer)!! + + leftContainer.addView(controller.leftGamePad) + rightContainer.addView(controller.rightGamePad) + + leftContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f) + v.pivotX = 0f + v.pivotY = v.height.toFloat() + v.scaleX = scale + v.scaleY = scale + } + rightContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f) + v.pivotX = v.width.toFloat() + v.pivotY = v.height.toFloat() + v.scaleX = scale + v.scaleY = scale + } + + leftContainer.post { leftContainer.requestLayout() } + rightContainer.post { rightContainer.requestLayout() } + + return view + } + + @Composable + fun Compose(viewModel: MainViewModel) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + val controller = GameController3(viewModel.activity) + val c = init(context, controller) + + viewModel.activity.lifecycleScope.launch { + val events = merge( + controller.leftGamePad.events(), + controller.rightGamePad.events() + ) + events.safeCollect { controller.handleEvent(it) } + } + + controller.controllerView = c + viewModel.setGameController(controller) + controller.setVisible(QuickSettings(viewModel.activity).useVirtualController) + c + } + ) + } + } + + private var controllerView: View? = null + var leftGamePad: RadialGamePad + var rightGamePad: RadialGamePad + var controllerId: Int = -1 + override val isVisible: Boolean + get() = controllerView?.isVisible ?: false + + init { + val useSwitchLayout = QuickSettings(activity).useSwitchLayout + leftGamePad = RadialGamePad(generateConfig3(true, useSwitchLayout), 16f, activity) + rightGamePad = RadialGamePad(generateConfig3(false, useSwitchLayout), 16f, activity) + + leftGamePad.primaryDialMaxSizeDp = 200f + rightGamePad.primaryDialMaxSizeDp = 200f + + leftGamePad.gravityX = -1f + leftGamePad.gravityY = 1f + rightGamePad.gravityX = 1f + rightGamePad.gravityY = 1f + } + + override fun setVisible(isVisible: Boolean) { + controllerView?.apply { + this.isVisible = isVisible + if (isVisible) connect() + } + } + + override fun connect() { + if (controllerId == -1) + controllerId = KenjinxNative.inputConnectGamepad(0) + } + + private fun handleEvent(ev: Event) { + if (controllerId == -1) + controllerId = KenjinxNative.inputConnectGamepad(0) + + controllerId.apply { + when (ev) { + is Event.Button -> { + // Ignore legacy L3/R3 via stick press (double tap) + if (ev.id == DUMMY_LEFT_STICK_PRESS_ID || ev.id == DUMMY_RIGHT_STICK_PRESS_ID) return + when (ev.action) { + KeyEvent.ACTION_UP -> KenjinxNative.inputSetButtonReleased(ev.id, this) + KeyEvent.ACTION_DOWN -> KenjinxNative.inputSetButtonPressed(ev.id, this) + } + } + is Event.Direction -> { + when (ev.id) { + GamePadButtonInputId.DpadUp.ordinal -> { + // Horizontal + if (ev.xAxis > 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this) + } else if (ev.xAxis < 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this) + } else { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this) + } + // Vertical + if (ev.yAxis < 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this) + } else if (ev.yAxis > 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this) + } else { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this) + } + } + GamePadButtonInputId.LeftStick.ordinal -> { + val setting = QuickSettings(activity) + val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f) + val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f) + KenjinxNative.inputSetStickAxis(1, x, -y, this) + } + GamePadButtonInputId.RightStick.ordinal -> { + val setting = QuickSettings(activity) + val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f) + val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f) + KenjinxNative.inputSetStickAxis(2, x, -y, this) + } + } + } + } + } + } +} + +private fun generateConfig3(isLeft: Boolean, useSwitchLayout: Boolean): RadialGamePadConfig { + val distance = 0.3f + val buttonScale = 1f + + if (isLeft) { + return RadialGamePadConfig( + /* ringSegments = */ 12, + /* Primary (D-Pad) */ + PrimaryDialConfig.Cross( + CrossConfig( + GamePadButtonInputId.DpadUp.ordinal, + CrossConfig.Shape.STANDARD, + null, + setOf(), + CrossContentDescription(), + true, + null + ), + ), + listOf( + // Left stick + // IMPORTANT: pressButtonId -> DUMMY_LEFT_STICK_PRESS_ID, so that double tap does not trigger L3 + SecondaryDialConfig.Stick( + /* sector */ 10, + /* size */ 3, + /* gap */ 2.7f, + distance, + GamePadButtonInputId.LeftStick.ordinal, + DUMMY_LEFT_STICK_PRESS_ID, + null, + setOf(), + "LeftStick", + SecondaryDialConfig.RotationProcessor() + ), + + // Minus + SecondaryDialConfig.SingleButton( + /* sector */ 9, + buttonScale, + 0.3f, + ButtonConfig( + GamePadButtonInputId.Minus.ordinal, + "-", + true, + null, + "Minus", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // L-Bumper + SecondaryDialConfig.DoubleButton( + /* sector */ 2, + 0.2f, + ButtonConfig( + GamePadButtonInputId.LeftShoulder.ordinal, + "L", + true, + null, + "LeftBumper", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // ZL-Trigger + SecondaryDialConfig.DoubleButton( + /* sector */ 2, + 1f, + ButtonConfig( + GamePadButtonInputId.LeftTrigger.ordinal, + "ZL", + true, + null, + "LeftTrigger", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // L3 separat + SecondaryDialConfig.SingleButton( + /* sector */ 1, + buttonScale, + 1.0f, + ButtonConfig( + GamePadButtonInputId.LeftStickButton.ordinal, + "L3", + true, + null, + "LeftStickButton", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + ) + ) + } else { + return RadialGamePadConfig( + /* ringSegments = */ 12, + /* Primary (ABXY) */ + PrimaryDialConfig.PrimaryButtons( + if (useSwitchLayout) { + // Switch/Nintendo: A=Right, X=Top, Y=Left, B=Bottom + listOf( + ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null), // Right + ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Top + ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Left + ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null) // Bottom + ) + } else { + // Xbox-Stil: B=Right, Y=Top, X=Left, A=Bottom + listOf( + ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null), // Right + ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Top + ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Left + ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null) // Bottom + ) + }, + null, + 0f, + true, + null + ), + listOf( + // Right stick + // IMPORTANT: pressButtonId -> DUMMY_RIGHT_STICK_PRESS_ID, so that double tap does not trigger R3 + SecondaryDialConfig.Stick( + /* sector */ 6, + /* size */ 3, + /* gap */ 2.7f, + distance, + GamePadButtonInputId.RightStick.ordinal, + DUMMY_RIGHT_STICK_PRESS_ID, + null, + setOf(), + "RightStick", + SecondaryDialConfig.RotationProcessor() + ), + + // Plus + SecondaryDialConfig.SingleButton( + /* sector */ 9, + buttonScale, + 0.3f, + ButtonConfig( + GamePadButtonInputId.Plus.ordinal, + "+", + true, + null, + "Plus", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // R-Bumper + SecondaryDialConfig.DoubleButton( + /* sector */ 3, + 0.2f, + ButtonConfig( + GamePadButtonInputId.RightShoulder.ordinal, + "R", + true, + null, + "RightBumper", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // ZR-Trigger + SecondaryDialConfig.DoubleButton( + /* sector */ 3, + 1f, + ButtonConfig( + GamePadButtonInputId.RightTrigger.ordinal, + "ZR", + true, + null, + "RightTrigger", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // R3 separat + SecondaryDialConfig.SingleButton( + /* sector */ 5, + buttonScale, + 1.0f, + ButtonConfig( + GamePadButtonInputId.RightStickButton.ordinal, + "R3", + true, + null, + "RightStickButton", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + ) + ) + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController4.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController4.kt new file mode 100644 index 000000000..5367214c3 --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController4.kt @@ -0,0 +1,411 @@ +package org.kenjinx.android + +import android.app.Activity +import android.content.Context +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.math.MathUtils +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.swordfish.radialgamepad.library.RadialGamePad +import com.swordfish.radialgamepad.library.config.ButtonConfig +import com.swordfish.radialgamepad.library.config.CrossConfig +import com.swordfish.radialgamepad.library.config.CrossContentDescription +import com.swordfish.radialgamepad.library.config.PrimaryDialConfig +import com.swordfish.radialgamepad.library.config.RadialGamePadConfig +import com.swordfish.radialgamepad.library.config.SecondaryDialConfig +import com.swordfish.radialgamepad.library.event.Event +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import org.kenjinx.android.viewmodels.MainViewModel +import org.kenjinx.android.viewmodels.QuickSettings + +private const val DUMMY_LEFT_STICK_PRESS_ID = 10001 +private const val DUMMY_RIGHT_STICK_PRESS_ID = 10002 + +class GameController4(var activity: Activity) : IGameController { + + companion object { + private fun init(context: Context, controller: GameController4): View { + val inflater = LayoutInflater.from(context) + val parent = FrameLayout(context) + val view = inflater.inflate(R.layout.game_layout, parent, false) + + val leftContainer = view.findViewById(R.id.leftcontainer)!! + val rightContainer = view.findViewById(R.id.rightcontainer)!! + + leftContainer.addView(controller.leftGamePad) + rightContainer.addView(controller.rightGamePad) + + leftContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f) + v.pivotX = 0f + v.pivotY = v.height.toFloat() + v.scaleX = scale + v.scaleY = scale + } + rightContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f) + v.pivotX = v.width.toFloat() + v.pivotY = v.height.toFloat() + v.scaleX = scale + v.scaleY = scale + } + + leftContainer.post { leftContainer.requestLayout() } + rightContainer.post { rightContainer.requestLayout() } + + return view + } + + @Composable + fun Compose(viewModel: MainViewModel) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + val controller = GameController4(viewModel.activity) + val c = init(context, controller) + + viewModel.activity.lifecycleScope.launch { + val events = merge( + controller.leftGamePad.events(), + controller.rightGamePad.events() + ) + events.safeCollect { controller.handleEvent(it) } + } + + controller.controllerView = c + viewModel.setGameController(controller) + controller.setVisible(QuickSettings(viewModel.activity).useVirtualController) + c + } + ) + } + } + + private var controllerView: View? = null + var leftGamePad: RadialGamePad + var rightGamePad: RadialGamePad + var controllerId: Int = -1 + override val isVisible: Boolean + get() = controllerView?.isVisible ?: false + + init { + val useSwitchLayout = QuickSettings(activity).useSwitchLayout + leftGamePad = RadialGamePad(generateConfig4(true, useSwitchLayout), 16f, activity) + rightGamePad = RadialGamePad(generateConfig4(false, useSwitchLayout), 16f, activity) + + leftGamePad.primaryDialMaxSizeDp = 200f + rightGamePad.primaryDialMaxSizeDp = 200f + + leftGamePad.gravityX = -1f + leftGamePad.gravityY = 1f + rightGamePad.gravityX = 1f + rightGamePad.gravityY = 1f + } + + override fun setVisible(isVisible: Boolean) { + controllerView?.apply { + this.isVisible = isVisible + if (isVisible) connect() + } + } + + override fun connect() { + if (controllerId == -1) + controllerId = KenjinxNative.inputConnectGamepad(0) + } + + private fun handleEvent(ev: Event) { + if (controllerId == -1) + controllerId = KenjinxNative.inputConnectGamepad(0) + + controllerId.apply { + when (ev) { + is Event.Button -> { + if (ev.id == DUMMY_LEFT_STICK_PRESS_ID || ev.id == DUMMY_RIGHT_STICK_PRESS_ID) return + when (ev.action) { + KeyEvent.ACTION_UP -> KenjinxNative.inputSetButtonReleased(ev.id, this) + KeyEvent.ACTION_DOWN -> KenjinxNative.inputSetButtonPressed(ev.id, this) + } + } + is Event.Direction -> { + when (ev.id) { + GamePadButtonInputId.DpadUp.ordinal -> { + if (ev.xAxis > 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this) + } else if (ev.xAxis < 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this) + } else { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this) + } + if (ev.yAxis < 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this) + } else if (ev.yAxis > 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this) + } else { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this) + } + } + GamePadButtonInputId.LeftStick.ordinal -> { + val setting = QuickSettings(activity) + val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f) + val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f) + KenjinxNative.inputSetStickAxis(1, x, -y, this) + } + GamePadButtonInputId.RightStick.ordinal -> { + val setting = QuickSettings(activity) + val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f) + val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f) + KenjinxNative.inputSetStickAxis(2, x, -y, this) + } + } + } + } + } + } +} + +private fun generateConfig4(isLeft: Boolean, useSwitchLayout: Boolean): RadialGamePadConfig { + val distance = 0.3f + val buttonScale = 1f + + if (isLeft) { + return RadialGamePadConfig( + /* ringSegments = */ 12, + /* Primary (Stick) */ + // IMPORTANT: pressButtonId -> DUMMY_LEFT_STICK_PRESS_ID, so that double tap does not trigger L3 + PrimaryDialConfig.Stick( + GamePadButtonInputId.LeftStick.ordinal, + DUMMY_LEFT_STICK_PRESS_ID, + setOf(), + "LeftStick", + null + ), + listOf( + // D-Pad + SecondaryDialConfig.Cross( + /* sector */ 10, + /* size */ 3, + /* gap */ 2.1f, + distance, + CrossConfig( + GamePadButtonInputId.DpadUp.ordinal, + CrossConfig.Shape.STANDARD, + null, + setOf(), + CrossContentDescription(), + true, + null + ), + SecondaryDialConfig.RotationProcessor() + ), + + // Minus + SecondaryDialConfig.SingleButton( + /* sector */ 9, + buttonScale, + 0.3f, + ButtonConfig( + GamePadButtonInputId.Minus.ordinal, + "-", + true, + null, + "Minus", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // L-Bumper + SecondaryDialConfig.DoubleButton( + /* sector */ 2, + 1.2f, + ButtonConfig( + GamePadButtonInputId.LeftShoulder.ordinal, + "L", + true, + null, + "LeftBumper", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // ZL-Trigger + SecondaryDialConfig.DoubleButton( + /* sector */ 2, + 2f, + ButtonConfig( + GamePadButtonInputId.LeftTrigger.ordinal, + "ZL", + true, + null, + "LeftTrigger", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + ) + ) + } else { + return RadialGamePadConfig( + /* ringSegments = */ 12, + /* Primary (ABXY) */ + PrimaryDialConfig.PrimaryButtons( + if (useSwitchLayout) { + // Switch/Nintendo: A=Right, X=Top, Y=Left, B=Bottom + listOf( + ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null), // Right + ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Top + ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Left + ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null) // Bottom + ) + } else { + // Xbox-Stil: B=Right, Y=Top, X=Left, A=Bottom + listOf( + ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null), // Right + ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Top + ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Left + ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null) // Bottom + ) + }, + null, + 0f, + true, + null + ), + listOf( + // Right stick + // IMPORTANT: pressButtonId -> DUMMY_RIGHT_STICK_PRESS_ID, so that double tap does not trigger R3 + SecondaryDialConfig.Stick( + /* sector */ 6, + /* size */ 3, + /* gap */ 2.7f, + distance, + GamePadButtonInputId.RightStick.ordinal, + DUMMY_RIGHT_STICK_PRESS_ID, + null, + setOf(), + "RightStick", + SecondaryDialConfig.RotationProcessor() + ), + + // Plus + SecondaryDialConfig.SingleButton( + /* sector */ 9, + buttonScale, + 0.3f, + ButtonConfig( + GamePadButtonInputId.Plus.ordinal, + "+", + true, + null, + "Plus", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // R-Bumper + SecondaryDialConfig.DoubleButton( + /* sector */ 3, + 1.2f, + ButtonConfig( + GamePadButtonInputId.RightShoulder.ordinal, + "R", + true, + null, + "RightBumper", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // ZR-Trigger + SecondaryDialConfig.DoubleButton( + /* sector */ 3, + 2f, + ButtonConfig( + GamePadButtonInputId.RightTrigger.ordinal, + "ZR", + true, + null, + "RightTrigger", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // R3 separat + SecondaryDialConfig.SingleButton( + /* sector */ 3, + buttonScale, + 0f, + ButtonConfig( + GamePadButtonInputId.RightStickButton.ordinal, + "R3", + true, + null, + "RightStickButton", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // L3 separat + SecondaryDialConfig.SingleButton( + /* sector */ 4, + buttonScale, + 0f, + ButtonConfig( + GamePadButtonInputId.LeftStickButton.ordinal, + "L3", + true, + null, + "LeftStickButton", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + ) + ) + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController5.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController5.kt new file mode 100644 index 000000000..75d6c7616 --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController5.kt @@ -0,0 +1,415 @@ +package org.kenjinx.android + +import android.app.Activity +import android.content.Context +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.math.MathUtils +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.swordfish.radialgamepad.library.RadialGamePad +import com.swordfish.radialgamepad.library.config.ButtonConfig +import com.swordfish.radialgamepad.library.config.CrossConfig +import com.swordfish.radialgamepad.library.config.CrossContentDescription +import com.swordfish.radialgamepad.library.config.PrimaryDialConfig +import com.swordfish.radialgamepad.library.config.RadialGamePadConfig +import com.swordfish.radialgamepad.library.config.SecondaryDialConfig +import com.swordfish.radialgamepad.library.event.Event +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import org.kenjinx.android.viewmodels.MainViewModel +import org.kenjinx.android.viewmodels.QuickSettings + +private const val DUMMY_LEFT_STICK_PRESS_ID = 10001 +private const val DUMMY_RIGHT_STICK_PRESS_ID = 10002 + +class GameController5(var activity: Activity) : IGameController { + + companion object { + private fun init(context: Context, controller: GameController5): View { + val inflater = LayoutInflater.from(context) + val parent = FrameLayout(context) + val view = inflater.inflate(R.layout.game_layout, parent, false) + + val leftContainer = view.findViewById(R.id.leftcontainer)!! + val rightContainer = view.findViewById(R.id.rightcontainer)!! + + leftContainer.addView(controller.leftGamePad) + rightContainer.addView(controller.rightGamePad) + + leftContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f) + v.pivotX = 0f + v.pivotY = v.height.toFloat() + v.scaleX = scale + v.scaleY = scale + } + rightContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f) + v.pivotX = v.width.toFloat() + v.pivotY = v.height.toFloat() + v.scaleX = scale + v.scaleY = scale + } + + leftContainer.post { leftContainer.requestLayout() } + rightContainer.post { rightContainer.requestLayout() } + + return view + } + + @Composable + fun Compose(viewModel: MainViewModel) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + val controller = GameController5(viewModel.activity) + val c = init(context, controller) + + viewModel.activity.lifecycleScope.launch { + val events = merge( + controller.leftGamePad.events(), + controller.rightGamePad.events() + ) + events.safeCollect { controller.handleEvent(it) } + } + + controller.controllerView = c + viewModel.setGameController(controller) + controller.setVisible(QuickSettings(viewModel.activity).useVirtualController) + c + } + ) + } + } + + private var controllerView: View? = null + var leftGamePad: RadialGamePad + var rightGamePad: RadialGamePad + var controllerId: Int = -1 + override val isVisible: Boolean + get() = controllerView?.isVisible ?: false + + init { + val useSwitchLayout = QuickSettings(activity).useSwitchLayout + leftGamePad = RadialGamePad(generateConfig5(true, useSwitchLayout), 16f, activity) + rightGamePad = RadialGamePad(generateConfig5(false, useSwitchLayout), 16f, activity) + + leftGamePad.primaryDialMaxSizeDp = 200f + rightGamePad.primaryDialMaxSizeDp = 200f + + leftGamePad.gravityX = -1f + leftGamePad.gravityY = 1f + rightGamePad.gravityX = 1f + rightGamePad.gravityY = 1f + } + + override fun setVisible(isVisible: Boolean) { + controllerView?.apply { + this.isVisible = isVisible + if (isVisible) connect() + } + } + + override fun connect() { + if (controllerId == -1) + controllerId = KenjinxNative.inputConnectGamepad(0) + } + + private fun handleEvent(ev: Event) { + if (controllerId == -1) + controllerId = KenjinxNative.inputConnectGamepad(0) + + controllerId.apply { + when (ev) { + is Event.Button -> { + if (ev.id == DUMMY_LEFT_STICK_PRESS_ID || ev.id == DUMMY_RIGHT_STICK_PRESS_ID) return + when (ev.action) { + KeyEvent.ACTION_UP -> KenjinxNative.inputSetButtonReleased(ev.id, this) + KeyEvent.ACTION_DOWN -> KenjinxNative.inputSetButtonPressed(ev.id, this) + } + } + is Event.Direction -> { + when (ev.id) { + GamePadButtonInputId.DpadUp.ordinal -> { + if (ev.xAxis > 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this) + } else if (ev.xAxis < 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this) + } else { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this) + } + if (ev.yAxis < 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this) + } else if (ev.yAxis > 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this) + } else { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this) + } + } + GamePadButtonInputId.LeftStick.ordinal -> { + val setting = QuickSettings(activity) + val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f) + val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f) + KenjinxNative.inputSetStickAxis(1, x, -y, this) + } + GamePadButtonInputId.RightStick.ordinal -> { + val setting = QuickSettings(activity) + val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f) + val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f) + KenjinxNative.inputSetStickAxis(2, x, -y, this) + } + } + } + } + } + } +} + +private fun generateConfig5(isLeft: Boolean, useSwitchLayout: Boolean): RadialGamePadConfig { + val distance = 0.3f + val buttonScale = 1f + + if (isLeft) { + return RadialGamePadConfig( + /* ringSegments = */ 12, + /* Primary (Stick) */ + // IMPORTANT: pressButtonId -> DUMMY_LEFT_STICK_PRESS_ID, so that double tap does not trigger L3 + PrimaryDialConfig.Stick( + GamePadButtonInputId.LeftStick.ordinal, + DUMMY_LEFT_STICK_PRESS_ID, + setOf(), + "LeftStick", + null + ), + listOf( + // D-Pad + SecondaryDialConfig.Cross( + /* sector */ 10, + /* size */ 3, + /* gap */ 2.1f, + distance, + CrossConfig( + GamePadButtonInputId.DpadUp.ordinal, + CrossConfig.Shape.STANDARD, + null, + setOf(), + CrossContentDescription(), + true, + null + ), + SecondaryDialConfig.RotationProcessor() + ), + + // Minus + SecondaryDialConfig.SingleButton( + /* sector */ 9, + buttonScale, + 0.3f, + ButtonConfig( + GamePadButtonInputId.Minus.ordinal, + "-", + true, + null, + "Minus", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // L-Bumper + SecondaryDialConfig.SingleButton( + /* sector */ 3, + 1.5f, + 1.0f, + ButtonConfig( + GamePadButtonInputId.LeftShoulder.ordinal, + "L", + true, + null, + "LeftBumper", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // ZL-Trigger + SecondaryDialConfig.SingleButton( + /* sector */ 2, + 1.5f, + 1.1f, + ButtonConfig( + GamePadButtonInputId.LeftTrigger.ordinal, + "ZL", + true, + null, + "LeftTrigger", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + ) + ) + } else { + return RadialGamePadConfig( + /* ringSegments = */ 12, + /* Primary (ABXY) */ + PrimaryDialConfig.PrimaryButtons( + if (useSwitchLayout) { + // Switch/Nintendo: A=Right, X=Top, Y=Left, B=Bottom + listOf( + ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null), // Right + ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Top + ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Left + ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null) // Bottom + ) + } else { + // Xbox-Stil: B=Right, Y=Top, X=Left, A=Bottom + listOf( + ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null), // Right + ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Top + ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Left + ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null) // Bottom + ) + }, + null, + 0f, + true, + null + ), + listOf( + // Right stick + // IMPORTANT: pressButtonId -> DUMMY_RIGHT_STICK_PRESS_ID, so that double tap does not trigger R3 + SecondaryDialConfig.Stick( + /* sector */ 6, + /* size */ 3, + /* gap */ 2.7f, + distance, + GamePadButtonInputId.RightStick.ordinal, + DUMMY_RIGHT_STICK_PRESS_ID, + null, + setOf(), + "RightStick", + SecondaryDialConfig.RotationProcessor() + ), + + // Plus + SecondaryDialConfig.SingleButton( + /* sector */ 9, + buttonScale, + 0.3f, + ButtonConfig( + GamePadButtonInputId.Plus.ordinal, + "+", + true, + null, + "Plus", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // R-Bumper + SecondaryDialConfig.SingleButton( + /* sector */ 3, + 1.5f, + 1.0f, + ButtonConfig( + GamePadButtonInputId.RightShoulder.ordinal, + "R", + true, + null, + "RightBumper", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // ZR-Trigger + SecondaryDialConfig.SingleButton( + /* sector */ 4, + 1.5f, + 1.1f, + ButtonConfig( + GamePadButtonInputId.RightTrigger.ordinal, + "ZR", + true, + null, + "RightTrigger", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // R3 separat + SecondaryDialConfig.SingleButton( + /* sector */ 3, + 1.1f, + 0f, + ButtonConfig( + GamePadButtonInputId.RightStickButton.ordinal, + "R3", + true, + null, + "RightStickButton", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // L3 separat + SecondaryDialConfig.SingleButton( + /* sector */ 4, + 1.1f, + 0f, + ButtonConfig( + GamePadButtonInputId.LeftStickButton.ordinal, + "L3", + true, + null, + "LeftStickButton", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + ) + ) + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController6.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController6.kt new file mode 100644 index 000000000..64ba6355f --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameController6.kt @@ -0,0 +1,412 @@ +package org.kenjinx.android + +import android.app.Activity +import android.content.Context +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.math.MathUtils +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.swordfish.radialgamepad.library.RadialGamePad +import com.swordfish.radialgamepad.library.config.ButtonConfig +import com.swordfish.radialgamepad.library.config.CrossConfig +import com.swordfish.radialgamepad.library.config.CrossContentDescription +import com.swordfish.radialgamepad.library.config.PrimaryDialConfig +import com.swordfish.radialgamepad.library.config.RadialGamePadConfig +import com.swordfish.radialgamepad.library.config.SecondaryDialConfig +import com.swordfish.radialgamepad.library.event.Event +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import org.kenjinx.android.viewmodels.MainViewModel +import org.kenjinx.android.viewmodels.QuickSettings + +private const val DUMMY_LEFT_STICK_PRESS_ID = 10001 +private const val DUMMY_RIGHT_STICK_PRESS_ID = 10002 + +class GameController6(var activity: Activity) : IGameController { + + companion object { + private fun init(context: Context, controller: GameController6): View { + val inflater = LayoutInflater.from(context) + val parent = FrameLayout(context) + val view = inflater.inflate(R.layout.game_layout, parent, false) + + val leftContainer = view.findViewById(R.id.leftcontainer)!! + val rightContainer = view.findViewById(R.id.rightcontainer)!! + + leftContainer.addView(controller.leftGamePad) + rightContainer.addView(controller.rightGamePad) + + leftContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f) + v.pivotX = 0f + v.pivotY = v.height.toFloat() + v.scaleX = scale + v.scaleY = scale + } + rightContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f) + v.pivotX = v.width.toFloat() + v.pivotY = v.height.toFloat() + v.scaleX = scale + v.scaleY = scale + } + + leftContainer.post { leftContainer.requestLayout() } + rightContainer.post { rightContainer.requestLayout() } + + return view + } + + @Composable + fun Compose(viewModel: MainViewModel) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + val controller = GameController6(viewModel.activity) + val c = init(context, controller) + + viewModel.activity.lifecycleScope.launch { + val events = merge( + controller.leftGamePad.events(), + controller.rightGamePad.events() + ) + events.safeCollect { controller.handleEvent(it) } + } + + controller.controllerView = c + viewModel.setGameController(controller) + controller.setVisible(QuickSettings(viewModel.activity).useVirtualController) + c + } + ) + } + } + + private var controllerView: View? = null + var leftGamePad: RadialGamePad + var rightGamePad: RadialGamePad + var controllerId: Int = -1 + override val isVisible: Boolean + get() = controllerView?.isVisible ?: false + + init { + val useSwitchLayout = QuickSettings(activity).useSwitchLayout + leftGamePad = RadialGamePad(generateConfig6(true, useSwitchLayout), 16f, activity) + rightGamePad = RadialGamePad(generateConfig6(false, useSwitchLayout), 16f, activity) + + leftGamePad.primaryDialMaxSizeDp = 200f + rightGamePad.primaryDialMaxSizeDp = 200f + + leftGamePad.gravityX = -1f + leftGamePad.gravityY = 1f + rightGamePad.gravityX = 1f + rightGamePad.gravityY = 1f + } + + override fun setVisible(isVisible: Boolean) { + controllerView?.apply { + this.isVisible = isVisible + if (isVisible) connect() + } + } + + override fun connect() { + if (controllerId == -1) + controllerId = KenjinxNative.inputConnectGamepad(0) + } + + private fun handleEvent(ev: Event) { + if (controllerId == -1) + controllerId = KenjinxNative.inputConnectGamepad(0) + + controllerId.apply { + when (ev) { + is Event.Button -> { + if (ev.id == DUMMY_LEFT_STICK_PRESS_ID || ev.id == DUMMY_RIGHT_STICK_PRESS_ID) return + when (ev.action) { + KeyEvent.ACTION_UP -> KenjinxNative.inputSetButtonReleased(ev.id, this) + KeyEvent.ACTION_DOWN -> KenjinxNative.inputSetButtonPressed(ev.id, this) + } + } + is Event.Direction -> { + when (ev.id) { + GamePadButtonInputId.DpadUp.ordinal -> { + if (ev.xAxis > 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this) + } else if (ev.xAxis < 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this) + } else { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this) + } + if (ev.yAxis < 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this) + } else if (ev.yAxis > 0) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this) + } else { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this) + } + } + GamePadButtonInputId.LeftStick.ordinal -> { + val setting = QuickSettings(activity) + val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f) + val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f) + KenjinxNative.inputSetStickAxis(1, x, -y, this) + } + GamePadButtonInputId.RightStick.ordinal -> { + val setting = QuickSettings(activity) + val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f) + val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f) + KenjinxNative.inputSetStickAxis(2, x, -y, this) + } + } + } + } + } + } +} + +private fun generateConfig6(isLeft: Boolean, useSwitchLayout: Boolean): GamePadConfig { + val distance = 0.3f + val buttonScale = 1f + + if (isLeft) { + return GamePadConfig( + /* ringSegments = */ 12, + /* Primary (Stick) */ + // IMPORTANT: pressButtonId -> DUMMY_LEFT_STICK_PRESS_ID, so that double tap does not trigger L3 + PrimaryDialConfig.Stick( + GamePadButtonInputId.LeftStick.ordinal, + DUMMY_LEFT_STICK_PRESS_ID, + setOf(), + "LeftStick", + null + ), + listOf( + // D-Pad + SecondaryDialConfig.Cross( + /* sector */ 10, + /* size */ 3, + /* gap */ 2.5f, + distance, + CrossConfig( + GamePadButtonInputId.DpadUp.ordinal, + CrossConfig.Shape.STANDARD, + null, + setOf(), + CrossContentDescription(), + true, + null + ), + SecondaryDialConfig.RotationProcessor() + ), + + // Minus + SecondaryDialConfig.SingleButton( + /* sector */ 1, + buttonScale, + 2f, + ButtonConfig( + GamePadButtonInputId.Minus.ordinal, + "-", + true, + null, + "Minus", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // L-Bumper + SecondaryDialConfig.DoubleButton( + /* sector */ 2, + distance, + ButtonConfig( + GamePadButtonInputId.LeftShoulder.ordinal, + "L", + true, + null, + "LeftBumper", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // ZL-Trigger + SecondaryDialConfig.SingleButton( + /* sector */ 9, + buttonScale, + distance, + ButtonConfig( + GamePadButtonInputId.LeftTrigger.ordinal, + "ZL", + true, + null, + "LeftTrigger", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + ) + ) + } else { + return GamePadConfig( + /* ringSegments = */ 12, + /* Primary (ABXY) */ + PrimaryDialConfig.PrimaryButtons( + if (useSwitchLayout) { + // Switch/Nintendo: A=Right, X=Top, Y=Left, B=Bottom + listOf( + ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null), // Right + ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Top + ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Left + ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null) // Bottom + ) + } else { + // Xbox-Stil: B=Right, Y=Top, X=Left, A=Bottom + listOf( + ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null), // Right + ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Top + ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Left + ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null) // Bottom + ) + }, + null, + 0f, + true, + null + ), + listOf( + // Right stick (unchanged) + // IMPORTANT: pressButtonId -> DUMMY_RIGHT_STICK_PRESS_ID, so that double tap does not trigger R3 + SecondaryDialConfig.Stick( + /* sector */ 6, + /* size */ 3, + /* gap */ 2.5f, + 0.7f, + GamePadButtonInputId.RightStick.ordinal, + DUMMY_RIGHT_STICK_PRESS_ID, + null, + setOf(), + "RightStick", + SecondaryDialConfig.RotationProcessor() + ), + + // Plus + SecondaryDialConfig.SingleButton( + /* sector */ 5, + buttonScale, + 2f, + ButtonConfig( + GamePadButtonInputId.Plus.ordinal, + "+", + true, + null, + "Plus", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // R-Bumper + SecondaryDialConfig.DoubleButton( + /* sector */ 3, + distance, + ButtonConfig( + GamePadButtonInputId.RightShoulder.ordinal, + "R", + true, + null, + "RightBumper", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // ZR-Trigger + SecondaryDialConfig.SingleButton( + /* sector */ 9, + buttonScale, + distance, + ButtonConfig( + GamePadButtonInputId.RightTrigger.ordinal, + "ZR", + true, + null, + "RightTrigger", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + + // NEW (remains): R3 as a separate button + SecondaryDialConfig.SingleButton( + /* sector */ 5, + buttonScale, + 0.0f, + ButtonConfig( + GamePadButtonInputId.RightStickButton.ordinal, + "R3", + true, + null, + "RightStickButton", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + // NEW (remains): L3 as a separate button + SecondaryDialConfig.SingleButton( + /* sector */ 6, + buttonScale, + 0.0f, + ButtonConfig( + GamePadButtonInputId.LeftStickButton.ordinal, + "L3", + true, + null, + "LeftStickButton", + setOf(), + true, + null + ), + null, + SecondaryDialConfig.RotationProcessor() + ), + ) + ) + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameHost.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameHost.kt index 77ff6e52b..b7926c7a4 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameHost.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/GameHost.kt @@ -1,14 +1,19 @@ package org.kenjinx.android import android.annotation.SuppressLint +import android.content.ComponentName import android.content.Context +import android.content.Intent +import android.content.ServiceConnection import android.os.Build import android.os.Handler +import android.os.IBinder import android.os.Looper import android.util.Log import android.view.SurfaceHolder import android.view.SurfaceView import androidx.compose.runtime.MutableState +import org.kenjinx.android.service.EmulationService import org.kenjinx.android.viewmodels.GameModel import org.kenjinx.android.viewmodels.MainViewModel import kotlin.concurrent.thread @@ -22,6 +27,7 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su private var progressValue: MutableState? = null private var showLoading: MutableState? = null private var game: GameModel? = null + private var _isClosed: Boolean = false private var _renderingThreadWatcher: Thread? = null private var _height: Int = 0 @@ -34,7 +40,33 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su private val mainHandler = Handler(Looper.getMainLooper()) - // Stabilizer-State + // ---- Foreground-Service Binding ---- + private var emuBound = false + private var emuBinder: EmulationService.LocalBinder? = null + private var _startedViaService = false + private var _inputInitialized: Boolean = false + + private val emuConn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + emuBinder = service as EmulationService.LocalBinder + emuBound = true + ghLog("EmulationService bound") + + // Falls Start bereits vorbereitet wurde und noch kein Loop läuft → jetzt im Service starten + if (_isStarted && !_startedViaService && _guestThread == null) { + startRunLoopInService() + } + } + + override fun onServiceDisconnected(name: ComponentName) { + ghLog("EmulationService unbound") + emuBound = false + emuBinder = null + _startedViaService = false + } + } + + // Resize-Stabilizer private var stabilizerActive = false // last known Android rotation (0,1,2,3) @@ -60,10 +92,98 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su if (enabled) Log.d("GameHost", msg) } - override fun surfaceCreated(holder: SurfaceHolder) { - // no-op + /** + * (Re)bind the current ANativeWindow to the renderer. + * Forces a fresh native pointer and passes it to C#. + */ + fun rebindNativeWindow(force: Boolean = false) { + if (_isClosed) return + try { + currentSurface = _nativeWindow.requeryWindowHandle() + _nativeWindow.swapInterval = 0 + KenjinxNative.deviceSetWindowHandle(currentWindowHandle) + + val w = if (holder.surfaceFrame.width() > 0) holder.surfaceFrame.width() else width + val h = if (holder.surfaceFrame.height() > 0) holder.surfaceFrame.height() else height + if (w > 0 && h > 0) { + if (MainActivity.mainViewModel?.rendererReady == true) { + try { KenjinxNative.graphicsRendererSetSize(w, h) } catch (_: Throwable) {} + } + if (_inputInitialized) { + try { KenjinxNative.inputSetClientSize(w, h) } catch (_: Throwable) {} + } + } + } catch (_: Throwable) { } } + /** + * Nach erfolgreichem Reattach die Swapchain/Viewport sicher „aufwecken“: + * - Rotation setzen + * - Zwei aufeinanderfolgende Resize-Kicks + */ + fun postReattachKicks(rotation: Int?) { + if (_isClosed) return + try { + KenjinxNative.setSurfaceRotationByAndroidRotation(rotation ?: 0) + val w = if (holder.surfaceFrame.width() > 0) holder.surfaceFrame.width() else width + val h = if (holder.surfaceFrame.height() > 0) holder.surfaceFrame.height() else height + if (w > 0 && h > 0 && + MainActivity.mainViewModel?.rendererReady == true && + _isStarted && _inputInitialized + ) { + try { KenjinxNative.resizeRendererAndInput(w, h) } catch (_: Throwable) {} + mainHandler.postDelayed({ + try { KenjinxNative.resizeRendererAndInput(w, h) } catch (_: Throwable) {} + }, 32) + } + } catch (_: Throwable) { } + } + + // -------- Surface Lifecycle -------- + + override fun surfaceCreated(holder: SurfaceHolder) { + ghLog("surfaceCreated") + // Früh binden, damit der Service schon steht, bevor wir starten + ensureServiceStartedAndBound() + rebindNativeWindow(force = true) + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + ghLog("surfaceChanged ${width}x$height") + if (_isClosed) return + + // IMMER neu binden – auch wenn die Größe gleich bleibt + rebindNativeWindow(force = true) + + val sizeChanged = (_width != width || _height != height) + _width = width + _height = height + + // Service sicherstellen & Renderstart + ensureServiceStartedAndBound() + start(holder) + + // Resize stabilisieren (übernimmt plausibles final size set) + startStabilizedResize(expectedRotation = lastRotation) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + ghLog("surfaceDestroyed → shutdownBinding()") + // Immer binden lösen (verhindert Leaks beim Task-Swipe) + shutdownBinding() + // Eigentliche Emu-Beendigung passiert via close() / Exit Game + } + + override fun onWindowVisibilityChanged(visibility: Int) { + super.onWindowVisibilityChanged(visibility) + if (visibility != android.view.View.VISIBLE) { + ghLog("window not visible → shutdownBinding()") + shutdownBinding() + } + } + + // -------- UI Progress -------- + fun setProgress(info: String, progressVal: Float) { showLoading?.apply { progressValue?.apply { this.value = progressVal } @@ -71,78 +191,80 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su } } - override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { - if (_isClosed) return + fun setProgressStates( + showLoading: MutableState?, + progressValue: MutableState?, + progress: MutableState? + ) { + this.showLoading = showLoading + this.progressValue = progressValue + this.progress = progress + showLoading?.apply { value = !isProgressHidden } + } - val sizeChanged = (_width != width || _height != height) - - if (sizeChanged) { - // Requery Surface / Window handle and report to C# - currentSurface = _nativeWindow.requeryWindowHandle() - _nativeWindow.swapInterval = 0 - try { KenjinxNative.deviceSetWindowHandle(currentWindowHandle) } catch (_: Throwable) {} + fun hideProgressIndicator() { + isProgressHidden = true + showLoading?.apply { + if (value == isProgressHidden) value = !isProgressHidden } - - _width = width - _height = height - - // Start renderer (if not already started) - start(holder) - - // Do not set size immediately → Stabilizer takes over - startStabilizedResize(expectedRotation = lastRotation) } - override fun surfaceDestroyed(holder: SurfaceHolder) { - // no-op (renderer lives in its own thread; close via close()) - } - - fun close() { - _isClosed = true - _isInit = false - _isStarted = false - - KenjinxNative.uiHandlerSetResponse(false, "") - - try { _updateThread?.join(200) } catch (_: Throwable) {} - try { _renderingThreadWatcher?.join(200) } catch (_: Throwable) {} - } + // -------- Start/Stop Emulation -------- private fun start(surfaceHolder: SurfaceHolder) { if (_isStarted) return - _isStarted = true + + // NICHT gleich _isStarted = true → erst alles vorbereiten + rebindNativeWindow(force = true) game = if (mainViewModel.isMiiEditorLaunched) null else mainViewModel.gameModel - // Initialize input + // Input initialisieren KenjinxNative.inputInitialize(width, height) + _inputInitialized = true val id = mainViewModel.physicalControllerManager?.connect() mainViewModel.motionSensorManager?.setControllerId(id ?: -1) - // No initial "flip" special case: we give the real rotation downwards val currentRot = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { mainViewModel.activity.display?.rotation - } else { - TODO("VERSION.SDK_INT < R") - } + } else null lastRotation = currentRot + try { - KenjinxNative.setSurfaceRotationByAndroidRotation(currentRot) - // Pass the window handle for safety reasons (if Surface has just been refreshed) + KenjinxNative.setSurfaceRotationByAndroidRotation(currentRot ?: 0) try { KenjinxNative.deviceSetWindowHandle(currentWindowHandle) } catch (_: Throwable) {} - // gentle kick: set identical size again - if (width > 0 && height > 0) { + + // Sanfter Kick nur wenn Renderer READY **und** Input init + if (width > 0 && height > 0 && + MainActivity.mainViewModel?.rendererReady == true && + _inputInitialized + ) { try { KenjinxNative.resizeRendererAndInput(width, height) } catch (_: Throwable) {} } } catch (_: Throwable) {} val qs = org.kenjinx.android.viewmodels.QuickSettings(mainViewModel.activity) - try { - KenjinxNative.graphicsSetFullscreenStretch(qs.stretchToFullscreen) - } catch (_: Throwable) {} - _guestThread = thread(start = true, name = "KenjinxGuest") { - runGame() + try { KenjinxNative.graphicsSetFullscreenStretch(qs.stretchToFullscreen) } catch (_: Throwable) {} + + // Host gilt nun als „gestartet“ + _isStarted = true + + // Immer bevorzugt im Service starten; wenn Bind noch nicht fertig → kurz warten, dann fallback + if (emuBound) { + startRunLoopInService() + } else { + ghLog("Service not yet bound → delayed runloop start") + mainHandler.postDelayed({ + if (!_isStarted) return@postDelayed + if (emuBound) { + startRunLoopInService() + } else { + // Fallback: lokaler Thread (sollte selten passieren) + ghLog("Fallback: starting RunLoop in local thread") + _guestThread = thread(start = true, name = "KenjinxGuest") { runGame() } + } + }, 150) } _updateThread = thread(start = true, name = "KenjinxInput/Stats") { @@ -150,8 +272,7 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su while (_isStarted) { KenjinxNative.inputUpdate() Thread.sleep(1) - c++ - if (c >= 1000) { + if (++c >= 1000) { if (progressValue?.value == -1f) { progress?.apply { this.value = "Loading ${if (mainViewModel.isMiiEditorLaunched) "Mii Editor" else game?.titleName ?: ""}" @@ -173,35 +294,49 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su game?.close() } - fun setProgressStates( - showLoading: MutableState?, - progressValue: MutableState?, - progress: MutableState? - ) { - this.showLoading = showLoading - this.progressValue = progressValue - this.progress = progress - showLoading?.apply { value = !isProgressHidden } + fun close() { + ghLog("close()") + _isClosed = true + _isInit = false + _isStarted = false + _inputInitialized = false + + KenjinxNative.uiHandlerSetResponse(false, "") + + // Emulation im Service stoppen (falls dort gestartet) + try { + if (emuBound && _startedViaService) { + emuBinder?.stopEmulation { + try { KenjinxNative.deviceCloseEmulation() } catch (_: Throwable) {} + } + } + } catch (_: Throwable) { } + + // Fallback: lokaler Thread beenden + try { _updateThread?.join(200) } catch (_: Throwable) {} + try { _renderingThreadWatcher?.join(200) } catch (_: Throwable) {} + + // Bindung lösen + shutdownBinding() + + // Service explizit beenden (falls noch läuft) + try { + mainViewModel.activity.stopService(Intent(mainViewModel.activity, EmulationService::class.java)) + } catch (_: Throwable) { } } - fun hideProgressIndicator() { - isProgressHidden = true - showLoading?.apply { - if (value == isProgressHidden) value = !isProgressHidden - } - } + // -------- Orientation / Resize -------- /** * Sicheres Setzen der Renderer-/Input-Größe. */ @Synchronized private fun safeSetSize(w: Int, h: Int) { - if (_isClosed) return - if (w <= 0 || h <= 0) return + if (_isClosed || w <= 0 || h <= 0) return try { - ghLog("safeSetSize: ${w}x$h (started=$_isStarted)") + ghLog("safeSetSize: ${w}x$h (started=$_isStarted, inputInit=$_inputInitialized)") KenjinxNative.graphicsRendererSetSize(w, h) - if (_isStarted) { + if (_isStarted && _inputInitialized) { KenjinxNative.inputSetClientSize(w, h) } } catch (t: Throwable) { @@ -222,19 +357,10 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su val isSideFlip = (old == 1 && rotation == 3) || (old == 3 && rotation == 1) if (isSideFlip) { - // 1) Report NativeRotation - try { KenjinxNative.setSurfaceRotationByAndroidRotation(rotation) } catch (_: Throwable) {} - - // 2) Requery NativeWindow immediately (forces real rebind) + window handle to C# - try { - currentSurface = _nativeWindow.requeryWindowHandle() - _nativeWindow.swapInterval = 0 - try { KenjinxNative.deviceSetWindowHandle(currentWindowHandle) } catch (_: Throwable) {} - } catch (_: Throwable) {} - - // 3) Debounced kick of identical size (update swap chain/viewport) + try { KenjinxNative.setSurfaceRotationByAndroidRotation(rotation ?: 0) } catch (_: Throwable) {} + rebindNativeWindow(force = true) val now = android.os.SystemClock.uptimeMillis() - if (now - lastKickAt >= 300L) { + if (now - lastKickAt >= 300L && _inputInitialized && MainActivity.mainViewModel?.rendererReady == true) { lastKickAt = now val w = if (holder.surfaceFrame.width() > 0) holder.surfaceFrame.width() else width val h = if (holder.surfaceFrame.height() > 0) holder.surfaceFrame.height() else height @@ -284,8 +410,7 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su // If rotation is known: Force plausibility (Landscape ↔ Portrait) expectedRotation?.let { rot -> - // ROTATION_90 (1) / ROTATION_270 (3) => Landscape - val landscape = (rot == 1 || rot == 3) + val landscape = (rot == 1 || rot == 3) // ROTATION_90/270 if (landscape && h > w) { val t = w; w = h; h = t } else if (!landscape && w > h) { @@ -304,7 +429,7 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su attempts++ - // slightly tightened: 1 stable tick or max. 12 attempts + // 1 stabiler Tick oder max. 12 Versuche if ((stableCount >= 1 || attempts >= 12) && w > 0 && h > 0) { ghLog("resize stabilized after $attempts ticks → ${w}x$h") safeSetSize(w, h) @@ -312,7 +437,6 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su return } - // continue to pollen if (stabilizerActive) { mainHandler.postDelayed(this, 16) } @@ -321,4 +445,56 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su mainHandler.post(task) } + + // ===== Service helpers ===== + + /** Von Activity/Surface-Lifecycle aufrufbar, um FGS sicher zu haben */ + fun ensureServiceStartedAndBound() { + val act = mainViewModel.activity + val intent = Intent(act, EmulationService::class.java) + try { + if (Build.VERSION.SDK_INT >= 26) { + act.startForegroundService(intent) + } else { + @Suppress("DEPRECATION") + act.startService(intent) + } + } catch (_: Throwable) { } + + try { + if (!emuBound) { + act.bindService(intent, emuConn, Context.BIND_AUTO_CREATE) + } + } catch (_: Throwable) { } + } + + /** Von Activity in onPause/onStop/onDestroy aufrufen (und intern in surfaceDestroyed/onWindowVisibilityChanged) */ + fun shutdownBinding() { + if (emuBound) { + try { + mainViewModel.activity.unbindService(emuConn) + } catch (_: Throwable) { } + emuBound = false + emuBinder = null + _startedViaService = false + ghLog("shutdownBinding() → unbound") + } + } + + private fun startRunLoopInService() { + if (!emuBound) return + if (_startedViaService) return + _startedViaService = true + + emuBinder?.startEmulation { + try { + KenjinxNative.graphicsRendererRunLoop() + } catch (t: Throwable) { + Log.e("GameHost", "RunLoop crash in service", t) + } finally { + _startedViaService = false + } + } + ghLog("RunLoop started in EmulationService") + } } diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/IGameController.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/IGameController.kt new file mode 100644 index 000000000..9bc245a74 --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/IGameController.kt @@ -0,0 +1,7 @@ +package org.kenjinx.android + +interface IGameController { + val isVisible: Boolean + fun setVisible(isVisible: Boolean) + fun connect() +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/KenjinxNative.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/KenjinxNative.kt index 29753acb6..124e5dd4f 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/KenjinxNative.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/KenjinxNative.kt @@ -6,6 +6,7 @@ import com.sun.jna.Native import org.kenjinx.android.viewmodels.GameInfo import java.util.Collections import android.view.Surface +import android.util.Log interface KenjinxNativeJna : Library { fun deviceInitialize( @@ -69,6 +70,14 @@ interface KenjinxNativeJna : Library { fun deviceCloseEmulation() fun deviceReinitEmulation() fun deviceSignalEmulationClose() + + // >>> Rendering-bezogene Ergänzungen für den Toggle: + fun deviceWaitForGpuDone(timeoutMs: Int) + fun deviceRecreateSwapchain() + fun graphicsSetBackendThreading(mode: Int) + fun graphicsSetPresentEnabled(enabled: Boolean) + // <<< + fun userGetOpenedUser(): String fun userGetUserPicture(userId: String): String fun userSetUserPicture(userId: String, picture: String) @@ -95,6 +104,13 @@ interface KenjinxNativeJna : Library { fun deviceResize(width: Int, height: Int) // Set window handle after each query fun deviceSetWindowHandle(handle: Long) + // Amiibo + fun amiiboLoadBin(bytes: ByteArray, length: Int): Boolean + fun amiiboClear() + + // AUDIO (neu): direkte JNA-Brücke zu C#-Exports + fun audioSetPaused(paused: Boolean) + fun audioSetMuted(muted: Boolean) } val jnaInstance: KenjinxNativeJna = Native.load( @@ -108,9 +124,121 @@ object KenjinxNative : KenjinxNativeJna by jnaInstance { fun loggingSetEnabled(logLevel: LogLevel, enabled: Boolean) = loggingSetEnabled(logLevel.ordinal, enabled) + // --- Rendering: Single-Thread-Option & sichere Wrapper -------------------- + + // 0 = Auto, 1 = SingleThread (Disable Threaded), 2 = Threaded + private const val THREADING_AUTO = 0 + private const val THREADING_SINGLE = 1 + private const val THREADING_THREADED = 2 + + override fun graphicsSetBackendThreading(mode: Int) { + try { + jnaInstance.graphicsSetBackendThreading(mode) + } catch (_: Throwable) { + Log.w("KenjinxNative", "graphicsSetBackendThreading not available") + } + } + + override fun deviceRecreateSwapchain() { + try { jnaInstance.deviceRecreateSwapchain() } catch (_: Throwable) { /* ignore */ } + } + + override fun deviceWaitForGpuDone(timeoutMs: Int) { + try { jnaInstance.deviceWaitForGpuDone(timeoutMs) } catch (_: Throwable) { /* ignore */ } + } + + override fun graphicsSetPresentEnabled(enabled: Boolean) { + try { jnaInstance.graphicsSetPresentEnabled(enabled) } catch (_: Throwable) { /* ignore */ } + } + + // Sichere deviceResize-Implementierung (reines Rendering) + override fun deviceResize(width: Int, height: Int) { + try { + graphicsRendererSetSize(width, height) + inputSetClientSize(width, height) + } catch (_: Throwable) { /* ignore */ } + } + + // Robustes graphicsInitialize mit QCOM-Heuristik + Fallback → SingleThread + override fun graphicsInitialize( + rescale: Float, + maxAnisotropy: Float, + fastGpuTime: Boolean, + fast2DCopy: Boolean, + enableMacroJit: Boolean, + enableMacroHLE: Boolean, + enableShaderCache: Boolean, + enableTextureRecompression: Boolean, + backendThreading: Int + ): Boolean { + val requested = backendThreading + val isQcom = "qcom".equals(android.os.Build.HARDWARE, true) + + // Heuristik: Auf QCOM bei „Auto“ zunächst SingleThread probieren, + // explizit gesetzte Werte bleiben unberührt. + val firstChoice = + if (isQcom && requested == THREADING_AUTO) THREADING_SINGLE else requested + + Log.i( + "KenjinxNative", + "graphicsInitialize: request=$requested firstChoice=$firstChoice hw=${android.os.Build.HARDWARE}" + ) + + return try { + jnaInstance.graphicsInitialize( + rescale, + maxAnisotropy, + fastGpuTime, + fast2DCopy, + enableMacroJit, + enableMacroHLE, + enableShaderCache, + enableTextureRecompression, + firstChoice + ) + } catch (t: Throwable) { + Log.e( + "KenjinxNative", + "graphicsInitialize failed (firstChoice=$firstChoice). Fallback → SingleThread", + t + ) + try { + jnaInstance.graphicsInitialize( + rescale, + maxAnisotropy, + fastGpuTime, + fast2DCopy, + enableMacroJit, + enableMacroHLE, + enableShaderCache, + enableTextureRecompression, + THREADING_SINGLE + ) + } catch (t2: Throwable) { + Log.e("KenjinxNative", "graphicsInitialize fallback failed", t2) + false + } + } + } + + // --- optionale Wrapper für Audio (safer logging) --- + override fun audioSetPaused(paused: Boolean) { + try { jnaInstance.audioSetPaused(paused) } + catch (t: Throwable) { Log.w("KenjinxNative", "audioSetPaused unavailable", t) } + } + + override fun audioSetMuted(muted: Boolean) { + try { jnaInstance.audioSetMuted(muted) } + catch (t: Throwable) { Log.w("KenjinxNative", "audioSetMuted unavailable", t) } + } + // ---------------------------------------------------- + @JvmStatic fun frameEnded() = MainActivity.frameEnded() + @JvmStatic + fun test() { /* no-op */ } + @JvmStatic fun getSurfacePtr(): Long = MainActivity.mainViewModel?.gameHost?.currentSurface ?: -1 @@ -124,6 +252,7 @@ object KenjinxNative : KenjinxNativeJna by jnaInstance { val text = NativeHelpers.instance.getStringJava(infoPtr) MainActivity.mainViewModel?.gameHost?.setProgress(text, progress) } + @JvmStatic fun onSurfaceSizeChanged(width: Int, height: Int) { // No-Op: Placeholder – Hook if needed. @@ -150,6 +279,26 @@ object KenjinxNative : KenjinxNativeJna by jnaInstance { } catch (_: Throwable) {} } + @JvmStatic + fun detachWindow() { + try { graphicsSetPresentEnabled(false) } catch (_: Throwable) {} + try { deviceWaitForGpuDone(100) } catch (_: Throwable) {} + try { deviceSetWindowHandle(0) } catch (_: Throwable) {} + } + + @JvmStatic + fun reattachWindowIfReady(): Boolean { + return try { + val handle = getWindowHandle() + if (handle <= 0) return false + deviceSetWindowHandle(handle) + deviceRecreateSwapchain() + true + } catch (_: Throwable) { + false + } + } + /** * Variant A (Pointer → Strings via NativeHelpers). * Used by older JNI/Interop paths. diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/MainActivity.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/MainActivity.kt index 9bd1b7676..440446526 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/MainActivity.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/MainActivity.kt @@ -29,8 +29,21 @@ import org.kenjinx.android.viewmodels.QuickSettings import org.kenjinx.android.viewmodels.GameModel import org.kenjinx.android.views.MainView import android.content.pm.ActivityInfo +import android.content.res.Configuration import android.hardware.display.DisplayManager +import android.net.Uri import android.view.Surface +import androidx.preference.PreferenceManager +import java.io.File +import androidx.activity.result.contract.ActivityResultContracts +import android.Manifest +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import android.content.BroadcastReceiver +import android.content.IntentFilter +import android.content.Context +import org.kenjinx.android.service.EmulationService class MainActivity : BaseActivity() { private var physicalControllerManager: PhysicalControllerManager = @@ -38,6 +51,10 @@ class MainActivity : BaseActivity() { private lateinit var motionSensorManager: MotionSensorManager private var _isInit: Boolean = false private val handler = Handler(Looper.getMainLooper()) + private val ENABLE_PRESENT_DELAY_MS = 400L + private val REATTACH_DELAY_MS = 300L + private var wantPresentEnabled = false + private val TAG_FG = "FgPresent" private val delayedHandleIntent = object : Runnable { override fun run() { handleIntent() } } var storedIntent: Intent = Intent() var isGameRunning = false @@ -45,6 +62,10 @@ class MainActivity : BaseActivity() { var storageHelper: SimpleStorageHelper? = null lateinit var uiHandler: UiHandler + // Persistenz für Zombie-Erkennung + private val PREFS = "emu_core" + private val KEY_EMU_RUNNING = "emu_running" + // Display Rotation + Orientation Handling private lateinit var displayManager: DisplayManager private var lastKnownRotation: Int? = null @@ -58,6 +79,17 @@ class MainActivity : BaseActivity() { if (enabled) Log.d(TAG_ROT, msg) } + private val serviceStopReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == EmulationService.ACTION_STOPPED) { + handler.removeCallbacks(reattachWindowWhenReady) + handler.removeCallbacks(enablePresentWhenReady) + clearEmuRunningFlag() + hardColdReset("service stopped broadcast") + } + } + } + private val displayListener = object : DisplayManager.DisplayListener { override fun onDisplayAdded(displayId: Int) {} override fun onDisplayRemoved(displayId: Int) {} @@ -65,7 +97,8 @@ class MainActivity : BaseActivity() { if (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { display?.displayId != displayId } else { - TODO("VERSION.SDK_INT < R") + @Suppress("DEPRECATION") + return } ) return val rot = display?.rotation @@ -104,6 +137,49 @@ class MainActivity : BaseActivity() { else -> -1 } + private fun setPresentEnabled(enabled: Boolean, reason: String) { + wantPresentEnabled = enabled + try { + KenjinxNative.graphicsSetPresentEnabled(enabled) + Log.d(TAG_FG, "present=${if (enabled) "ENABLED" else "DISABLED"} ($reason)") + } catch (_: Throwable) { + Log.d(TAG_FG, "native toggle not available ($reason)") + } + } + + private val enablePresentWhenReady = object : Runnable { + override fun run() { + val isReallyResumed = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) && isActive + val hasFocusNow = hasWindowFocus() + val rendererReady = MainActivity.mainViewModel?.rendererReady == true + + if (!isReallyResumed || !hasFocusNow || !rendererReady) { + handler.postDelayed(this, ENABLE_PRESENT_DELAY_MS) + return + } + setPresentEnabled(true, "focus regained + delay") + } + } + + private val reattachWindowWhenReady = object : Runnable { + override fun run() { + val isReallyResumed = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) && isActive + val hasFocusNow = hasWindowFocus() + if (!isReallyResumed || !hasFocusNow) { + handler.postDelayed(this, REATTACH_DELAY_MS) + return + } + + try { mainViewModel?.gameHost?.rebindNativeWindow(force = true) } catch (_: Throwable) {} + + if (!KenjinxNative.reattachWindowIfReady()) { + handler.postDelayed(this, REATTACH_DELAY_MS) + return + } + Log.d(TAG_FG, "window reattached") + } + } + private fun doOrientationPulse(currentRot: Int) { val now = android.os.SystemClock.uptimeMillis() if (pulsingOrientation || now - lastPulseAt < 350L) return @@ -150,6 +226,11 @@ class MainActivity : BaseActivity() { var AppPath: String = "" var StorageHelper: SimpleStorageHelper? = null + const val EXTRA_BOOT_PATH = "bootPath" + const val EXTRA_FORCE_NCE_PPTC = "forceNceAndPptc" + const val EXTRA_TITLE_ID = "titleId" + const val EXTRA_TITLE_NAME = "titleName" + @JvmStatic fun frameEnded() { mainViewModel?.activity?.apply { @@ -181,7 +262,7 @@ class MainActivity : BaseActivity() { if (_isInit) return val appPath: String = AppPath - var quickSettings = QuickSettings(this) + val quickSettings = QuickSettings(this) KenjinxNative.loggingSetEnabled(LogLevel.Info, quickSettings.enableInfoLogs) KenjinxNative.loggingSetEnabled(LogLevel.Stub, quickSettings.enableStubLogs) KenjinxNative.loggingSetEnabled(LogLevel.Warning, quickSettings.enableWarningLogs) @@ -197,6 +278,7 @@ class MainActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + ensureNotificationPermission() motionSensorManager = MotionSensorManager(this) Thread.setDefaultUncaughtExceptionHandler(crashHandler) @@ -213,6 +295,8 @@ class MainActivity : BaseActivity() { } AppPath = this.getExternalFilesDir(null)!!.absolutePath + + coldResetIfZombie("onCreate") initialize() window.attributes.layoutInDisplayCutoutMode = @@ -282,53 +366,204 @@ class MainActivity : BaseActivity() { return super.dispatchGenericMotionEvent(ev) } + // --- Audio foreground/background gating --- + private fun setAudioForegroundState(inForeground: Boolean) { + // bevorzugt: pausieren statt nur muten + try { KenjinxNative.audioSetPaused(!inForeground) } catch (_: Throwable) {} + // fallback: Master-Mute + try { KenjinxNative.audioSetMuted(!inForeground) } catch (_: Throwable) {} + } + + // --------- BACKGROUND STABILITY: Present gating --------- + override fun onStart() { + super.onStart() + coldResetIfZombie("onStart") + + if (isGameRunning && MainActivity.mainViewModel?.rendererReady == true) { + try { + KenjinxNative.graphicsSetPresentEnabled(true) + Log.d(TAG_FG, "present=ENABLED (onStart)") + } catch (_: Throwable) {} + } else { + Log.d(TAG_FG, "skip enable present (onStart) — rendererReady=${MainActivity.mainViewModel?.rendererReady}") + setPresentEnabled(false, "cold reset: onStart (no game)") + } + } + override fun onStop() { super.onStop() - isActive = false - if (isGameRunning) mainViewModel?.performanceManager?.setTurboMode(false) + if (isGameRunning) { + setAudioForegroundState(false) + handler.removeCallbacks(reattachWindowWhenReady) + handler.removeCallbacks(enablePresentWhenReady) + setPresentEnabled(false, "onStop") + try { KenjinxNative.detachWindow() } catch (_: Throwable) {} + } + // WICHTIG: Bindung sicher lösen (verhindert Leak) + try { mainViewModel?.gameHost?.shutdownBinding() } catch (_: Throwable) {} } + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN && isGameRunning) { + setAudioForegroundState(false) + if (MainActivity.mainViewModel?.rendererReady == true) { + try { + KenjinxNative.graphicsSetPresentEnabled(false) + Log.d(TAG_FG, "present=DISABLED (onTrimMemory:$level)") + } catch (_: Throwable) {} + } else { + Log.d(TAG_FG, "skip disable present (onTrimMemory) — rendererReady=${MainActivity.mainViewModel?.rendererReady}") + } + } + } + // -------------------------------------------------------- + override fun onResume() { super.onResume() - // Reapply alignment if necessary + isActive = true + setAudioForegroundState(true) + + coldResetIfZombie("onResume") + applyOrientationPreference() // Enable display listener if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { lastKnownRotation = display?.rotation - rotLog("onResume: display.rotation=${display?.rotation} → ${deg(display?.rotation)}°") + rotLog("onResume: display.rotation=${display?.rotation} → ${deg(display?.rotation)}°") } + try { displayManager.registerDisplayListener(displayListener, handler) } catch (_: Throwable) {} + try { + if (Build.VERSION.SDK_INT >= 33) { + registerReceiver( + serviceStopReceiver, + IntentFilter(EmulationService.ACTION_STOPPED), + Context.RECEIVER_EXPORTED + ) + } else { + @Suppress("DEPRECATION") + registerReceiver(serviceStopReceiver, IntentFilter(EmulationService.ACTION_STOPPED)) + } + } catch (_: Throwable) {} + + handler.removeCallbacks(reattachWindowWhenReady) + handler.removeCallbacks(enablePresentWhenReady) + + try { mainViewModel?.gameHost?.rebindNativeWindow(force = true) } catch (_: Throwable) {} + handler.postDelayed(delayedHandleIntent, 10) - isActive = true - if (isGameRunning && QuickSettings(this).enableMotion) motionSensorManager.register() + + if (isGameRunning && QuickSettings(this).enableMotion) { + motionSensorManager.register() + } + + if (isGameRunning) { + handler.postDelayed(reattachWindowWhenReady, REATTACH_DELAY_MS) + if (hasWindowFocus()) { + handler.postDelayed(enablePresentWhenReady, ENABLE_PRESENT_DELAY_MS) + } + } else { + setPresentEnabled(false, "cold reset: onResume (no game)") + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (!isGameRunning) return + + handler.removeCallbacks(reattachWindowWhenReady) + handler.removeCallbacks(enablePresentWhenReady) + + if (hasFocus && isActive) { + setAudioForegroundState(true) + // NEU: zuerst sicherstellen, dass die Bindung existiert + try { mainViewModel?.gameHost?.ensureServiceStartedAndBound() } catch (_: Throwable) {} + + setPresentEnabled(false, "focus gained → pre-rebind") + try { mainViewModel?.gameHost?.rebindNativeWindow(force = true) } catch (_: Throwable) {} + handler.postDelayed(reattachWindowWhenReady, 150L) + val rot = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display?.rotation else null + handler.postDelayed({ try { mainViewModel?.gameHost?.postReattachKicks(rot) } catch (_: Throwable) {} }, 200L) + handler.postDelayed(enablePresentWhenReady, 450L) + } else { + setAudioForegroundState(false) + setPresentEnabled(false, "focus lost") + try { KenjinxNative.detachWindow() } catch (_: Throwable) {} + } } override fun onPause() { super.onPause() isActive = false - if (isGameRunning) mainViewModel?.performanceManager?.setTurboMode(false) - motionSensorManager.unregister() + setAudioForegroundState(false) + + handler.removeCallbacks(reattachWindowWhenReady) + handler.removeCallbacks(enablePresentWhenReady) + + if (isGameRunning) { + setPresentEnabled(false, "onPause") + try { KenjinxNative.detachWindow() } catch (_: Throwable) {} + mainViewModel?.performanceManager?.setTurboMode(false) + motionSensorManager.unregister() + } + try { displayManager.unregisterDisplayListener(displayListener) } catch (_: Throwable) {} + try { unregisterReceiver(serviceStopReceiver) } catch (_: Throwable) {} + + // NEU: Bindung aufräumen (verhindert Leak beim Task-Swipe) + try { mainViewModel?.gameHost?.shutdownBinding() } catch (_: Throwable) {} } private fun handleIntent() { when (storedIntent.action) { Intent.ACTION_VIEW, "org.kenjinx.android.LAUNCH_GAME" -> { - val bootPath = storedIntent.getStringExtra("bootPath") - val forceNceAndPptc = storedIntent.getBooleanExtra("forceNceAndPptc", false) + val bootPathExtra = storedIntent.getStringExtra(EXTRA_BOOT_PATH) + val forceNceAndPptc = storedIntent.getBooleanExtra(EXTRA_FORCE_NCE_PPTC, false) + val titleId = storedIntent.getStringExtra(EXTRA_TITLE_ID) ?: "" + val titleName = storedIntent.getStringExtra(EXTRA_TITLE_NAME) ?: "" + val dataUri: Uri? = storedIntent.data - if (bootPath != null) { - val uri = bootPath.toUri() - val documentFile = DocumentFile.fromSingleUri(this, uri) + val chosenUri: Uri? = when { + !bootPathExtra.isNullOrEmpty() -> bootPathExtra.toUri() + dataUri != null -> dataUri + else -> null + } - if (documentFile != null) { - val gameModel = GameModel(documentFile, this) + if (chosenUri != null) { + val doc = when (chosenUri.scheme?.lowercase()) { + "content" -> DocumentFile.fromSingleUri(this, chosenUri) + "file" -> chosenUri.path?.let { File(it) }?.let { DocumentFile.fromFile(it) } + else -> { + chosenUri.path?.let { File(it) }?.takeIf { it.exists() }?.let { DocumentFile.fromFile(it) } + ?: DocumentFile.fromSingleUri(this, chosenUri) + } + } + + if (doc != null && doc.exists()) { + val gameModel = GameModel(doc, this) gameModel.getGameInfo() mainViewModel?.loadGameModel?.value = gameModel mainViewModel?.bootPath?.value = "gameItem_${gameModel.titleName}" mainViewModel?.forceNceAndPptc?.value = forceNceAndPptc + storedIntent = Intent() + return + } else { + Log.w("ShortcutDebug", "DocumentFile not found or not accessible: $chosenUri") + } + } + + if (titleId.isNotEmpty() || titleName.isNotEmpty()) { + resolveGameByTitleIdOrName(titleId, titleName)?.let { doc -> + val gameModel = GameModel(doc, this) + gameModel.getGameInfo() + mainViewModel?.loadGameModel?.value = gameModel + mainViewModel?.bootPath?.value = "gameItem_${gameModel.titleName}" + mainViewModel?.forceNceAndPptc?.value = forceNceAndPptc + storedIntent = Intent() + return } } } @@ -341,12 +576,104 @@ class MainActivity : BaseActivity() { val rot = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { this.display?.rotation } else { - TODO("VERSION.SDK_INT < R") + @Suppress("DEPRECATION") + null } rotLog("applyOrientationPreference: rot=$rot → ${deg(rot)}°, pref=${pref.name}") try { KenjinxNative.setSurfaceRotationByAndroidRotation(rot) } catch (_: Throwable) {} } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + val rot = this.display?.rotation + val old = lastKnownRotation + lastKnownRotation = rot + + rotLog("onConfigurationChanged: display.rotation=$rot → ${deg(rot)}°") + + try { KenjinxNative.setSurfaceRotationByAndroidRotation(rot) } catch (_: Throwable) {} + + val pref = QuickSettings(this).orientationPreference + val shouldPropagate = + pref == QuickSettings.OrientationPreference.Sensor || + pref == QuickSettings.OrientationPreference.SensorLandscape + + if (shouldPropagate && isGameRunning) { + handler.post { try { mainViewModel?.gameHost?.onOrientationOrSizeChanged(rot) } catch (_: Throwable) {} } + } + + if (pref == QuickSettings.OrientationPreference.SensorLandscape && old != null && rot != null) { + val isSideFlip = (old == Surface.ROTATION_90 && rot == Surface.ROTATION_270) || + (old == Surface.ROTATION_270 && rot == Surface.ROTATION_90) + if (isSideFlip) doOrientationPulse(rot) + } + } + + private val requestNotifPerm = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { /* optional: Log/Toast */ } + + private fun ensureNotificationPermission() { + if (android.os.Build.VERSION.SDK_INT >= 33) { + val granted = ContextCompat.checkSelfPermission( + this, Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + if (!granted) { + requestNotifPerm.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } + + private fun resolveGameByTitleIdOrName(titleIdHex: String?, displayName: String?): DocumentFile? { + val gamesRoot = getDefaultGamesTree() ?: return null + for (child in gamesRoot.listFiles()) { + if (!child.isFile) continue + if (!displayName.isNullOrBlank()) { + val n = child.name ?: "" + if (n.contains(displayName, ignoreCase = true)) return child + } + if (!titleIdHex.isNullOrBlank()) { + val tid = getTitleIdFast(child) + if (tid != null && tid.equals(titleIdHex, ignoreCase = true)) return child + } + } + if (!titleIdHex.isNullOrBlank()) { + for (child in gamesRoot.listFiles()) { + if (!child.isFile) continue + val tid = getTitleIdFast(child) + if (tid != null && tid.equals(titleIdHex, ignoreCase = true)) return child + } + } + return null + } + + private fun getDefaultGamesTree(): DocumentFile? { + val vm = mainViewModel + if (vm?.defaultGameFolderUri != null) { + return DocumentFile.fromTreeUri(this, vm.defaultGameFolderUri!!) + } + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + val legacyPath = prefs.getString("gameFolder", null) + if (!legacyPath.isNullOrEmpty()) { + // Ohne SAF-URI kein Tree-Listing möglich + } + return null + } + + private fun getTitleIdFast(file: DocumentFile): String? { + val name = file.name ?: return null + val dot = name.lastIndexOf('.') + if (dot <= 0 || dot >= name.length - 1) return null + val ext = name.substring(dot + 1).lowercase() + return try { + contentResolver.openFileDescriptor(file.uri, "r")?.use { pfd -> + val info = org.kenjinx.android.viewmodels.GameInfo() + KenjinxNative.deviceGetGameInfo(pfd.fd, ext, info) + info.TitleId?.lowercase() + } + } catch (_: Exception) { null } + } + fun shutdownAndRestart() { val packageManager = packageManager val intent = packageManager.getLaunchIntentForPackage(packageName) @@ -356,4 +683,53 @@ class MainActivity : BaseActivity() { startActivity(restartIntent) Runtime.getRuntime().exit(0) } + + override fun onDestroy() { + handler.removeCallbacks(enablePresentWhenReady) + handler.removeCallbacks(reattachWindowWhenReady) + // NEU: falls die Activity stirbt → Bindung garantiert lösen + try { mainViewModel?.gameHost?.shutdownBinding() } catch (_: Throwable) {} + super.onDestroy() + } + + // ---------- Helpers ---------- + + private fun setEmuRunningFlag(value: Boolean) { + try { + getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_EMU_RUNNING, value) + .apply() + } catch (_: Throwable) { } + } + + private fun clearEmuRunningFlag() = setEmuRunningFlag(false) + + private fun hardColdReset(reason: String) { + Log.d(TAG_FG, "Cold graphics reset ($reason)") + isGameRunning = false + mainViewModel?.rendererReady = false + + try { setPresentEnabled(false, "cold reset: $reason") } catch (_: Throwable) {} + try { KenjinxNative.detachWindow() } catch (_: Throwable) {} + + try { stopService(Intent(this, EmulationService::class.java)) } catch (_: Throwable) {} + + try { mainViewModel?.loadGameModel?.value = null } catch (_: Throwable) {} + try { mainViewModel?.bootPath?.value = "" } catch (_: Throwable) {} + try { mainViewModel?.forceNceAndPptc?.value = false } catch (_: Throwable) {} + storedIntent = Intent() + } + + private fun coldResetIfZombie(phase: String) { + try { + val zombie = getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_EMU_RUNNING, false) + if (zombie) { + clearEmuRunningFlag() + setPresentEnabled(false, "kill stray: $phase") + hardColdReset("kill stray: $phase") + } + } catch (_: Throwable) { } + } } diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/PhysicalControllerManager.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/PhysicalControllerManager.kt index ec05d45e3..05bd4b7d0 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/PhysicalControllerManager.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/PhysicalControllerManager.kt @@ -4,10 +4,17 @@ import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent import org.kenjinx.android.viewmodels.QuickSettings +import kotlin.math.abs class PhysicalControllerManager(val activity: MainActivity) { private var controllerId: Int = -1 + // Trigger-Entprellung (analog → digital) + private var leftTriggerPressed = false + private var rightTriggerPressed = false + private val pressThreshold = 0.65f + private val releaseThreshold = 0.45f + fun onKeyEvent(event: KeyEvent): Boolean { // Make sure we are connected if (controllerId == -1) { @@ -17,69 +24,116 @@ class PhysicalControllerManager(val activity: MainActivity) { val id = getGamePadButtonInputId(event.keyCode) if (id != GamePadButtonInputId.None) { val isNotFallback = (event.flags and KeyEvent.FLAG_FALLBACK) == 0 - // Many gamepads send additional fallback events – we suppress them. if (isNotFallback) { when (event.action) { - KeyEvent.ACTION_UP -> { - KenjinxNative.inputSetButtonReleased(id.ordinal, controllerId) - } - KeyEvent.ACTION_DOWN -> { - KenjinxNative.inputSetButtonPressed(id.ordinal, controllerId) - } + KeyEvent.ACTION_UP -> KenjinxNative.inputSetButtonReleased(id.ordinal, controllerId) + KeyEvent.ACTION_DOWN -> KenjinxNative.inputSetButtonPressed(id.ordinal, controllerId) } } return true } - return false } fun onMotionEvent(ev: MotionEvent) { - if (ev.action == MotionEvent.ACTION_MOVE) { - if (controllerId == -1) { - controllerId = KenjinxNative.inputConnectGamepad(0) - } + if (ev.action != MotionEvent.ACTION_MOVE) return - val leftStickX = ev.getAxisValue(MotionEvent.AXIS_X) - val leftStickY = ev.getAxisValue(MotionEvent.AXIS_Y) - val rightStickX = ev.getAxisValue(MotionEvent.AXIS_Z) - val rightStickY = ev.getAxisValue(MotionEvent.AXIS_RZ) + if (controllerId == -1) { + controllerId = KenjinxNative.inputConnectGamepad(0) + } - KenjinxNative.inputSetStickAxis(1, leftStickX, -leftStickY, controllerId) - KenjinxNative.inputSetStickAxis(2, rightStickX, -rightStickY, controllerId) + val device = ev.device + val source = InputDevice.SOURCE_JOYSTICK - ev.device?.apply { - if (sources and InputDevice.SOURCE_DPAD != InputDevice.SOURCE_DPAD) { - // Controller uses HAT instead of “real” DPAD - val dPadHor = ev.getAxisValue(MotionEvent.AXIS_HAT_X) - val dPadVert = ev.getAxisValue(MotionEvent.AXIS_HAT_Y) + fun hasAxis(axis: Int): Boolean = + device?.getMotionRange(axis, source) != null - if (dPadVert == 0.0f) { - KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, controllerId) - KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, controllerId) - } - if (dPadHor == 0.0f) { - KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, controllerId) - KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, controllerId) - } + fun axisValue(axis: Int): Float = ev.getAxisValue(axis) - if (dPadVert < 0.0f) { - KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, controllerId) - KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, controllerId) - } - if (dPadHor < 0.0f) { - KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, controllerId) - KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, controllerId) - } + // --- Sticks (rechts bevorzugt RX/RY, Fallback Z/RZ) --- + val rightXaxis = if (hasAxis(MotionEvent.AXIS_RX)) MotionEvent.AXIS_RX else MotionEvent.AXIS_Z + val rightYaxis = if (hasAxis(MotionEvent.AXIS_RY)) MotionEvent.AXIS_RY else MotionEvent.AXIS_RZ - if (dPadVert > 0.0f) { - KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, controllerId) - KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, controllerId) - } - if (dPadHor > 0.0f) { - KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, controllerId) - KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, controllerId) - } + val leftStickX = if (hasAxis(MotionEvent.AXIS_X)) axisValue(MotionEvent.AXIS_X) else 0f + val leftStickY = if (hasAxis(MotionEvent.AXIS_Y)) axisValue(MotionEvent.AXIS_Y) else 0f + val rightStickX = if (hasAxis(rightXaxis)) axisValue(rightXaxis) else 0f + val rightStickY = if (hasAxis(rightYaxis)) axisValue(rightYaxis) else 0f + + KenjinxNative.inputSetStickAxis(1, leftStickX, -leftStickY, controllerId) + KenjinxNative.inputSetStickAxis(2, rightStickX, -rightStickY, controllerId) + + // --- Trigger lesen (mit Fallbacks) --- + // Bevorzugt: LTRIGGER/RTRIGGER, dann BRAKE/GAS. + // Wenn der rechte Stick RX/RY nutzt (Standard bei Xbox), sind Z/RZ frei -> als weiterer Fallback verwenden. + // Nutzt der Stick Z/RZ, werden diese NICHT für Trigger verwendet (um Konflikte zu vermeiden). + val rightStickUsesZ = (rightXaxis == MotionEvent.AXIS_Z) + val rightStickUsesRZ = (rightYaxis == MotionEvent.AXIS_RZ) + + val rawLT = when { + hasAxis(MotionEvent.AXIS_LTRIGGER) -> axisValue(MotionEvent.AXIS_LTRIGGER) + hasAxis(MotionEvent.AXIS_BRAKE) -> axisValue(MotionEvent.AXIS_BRAKE) + !rightStickUsesZ && hasAxis(MotionEvent.AXIS_Z) -> axisValue(MotionEvent.AXIS_Z) + else -> 0f + } + val rawRT = when { + hasAxis(MotionEvent.AXIS_RTRIGGER) -> axisValue(MotionEvent.AXIS_RTRIGGER) + hasAxis(MotionEvent.AXIS_GAS) -> axisValue(MotionEvent.AXIS_GAS) + !rightStickUsesRZ && hasAxis(MotionEvent.AXIS_RZ) -> axisValue(MotionEvent.AXIS_RZ) + else -> 0f + } + + // Einige Pads liefern leichte Offsets – normalisieren + val lt = if (abs(rawLT) < 0.02f) 0f else rawLT.coerceIn(0f, 1f) + val rt = if (abs(rawRT) < 0.02f) 0f else rawRT.coerceIn(0f, 1f) + + // Analog → digital mit Hysterese + if (!leftTriggerPressed && lt >= pressThreshold) { + leftTriggerPressed = true + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.LeftTrigger.ordinal, controllerId) + } else if (leftTriggerPressed && lt <= releaseThreshold) { + leftTriggerPressed = false + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.LeftTrigger.ordinal, controllerId) + } + + if (!rightTriggerPressed && rt >= pressThreshold) { + rightTriggerPressed = true + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.RightTrigger.ordinal, controllerId) + } else if (rightTriggerPressed && rt <= releaseThreshold) { + rightTriggerPressed = false + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.RightTrigger.ordinal, controllerId) + } + + // --- DPAD als HAT (wie gehabt) --- + device?.apply { + if (sources and InputDevice.SOURCE_DPAD != InputDevice.SOURCE_DPAD) { + val dPadHor = ev.getAxisValue(MotionEvent.AXIS_HAT_X) + val dPadVert = ev.getAxisValue(MotionEvent.AXIS_HAT_Y) + + if (dPadVert == 0.0f) { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, controllerId) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, controllerId) + } + if (dPadHor == 0.0f) { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, controllerId) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, controllerId) + } + + if (dPadVert < 0.0f) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, controllerId) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, controllerId) + } + if (dPadHor < 0.0f) { + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, controllerId) + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, controllerId) + } + + if (dPadVert > 0.0f) { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, controllerId) + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, controllerId) + } + if (dPadHor > 0.0f) { + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, controllerId) + KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, controllerId) } } } @@ -92,39 +146,46 @@ class PhysicalControllerManager(val activity: MainActivity) { fun disconnect() { controllerId = -1 + // Falls ein Trigger beim Disconnect "hing", sicherheitshalber releasen + if (leftTriggerPressed) { + leftTriggerPressed = false + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.LeftTrigger.ordinal, controllerId) + } + if (rightTriggerPressed) { + rightTriggerPressed = false + KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.RightTrigger.ordinal, controllerId) + } } private fun getGamePadButtonInputId(keycode: Int): GamePadButtonInputId { val quickSettings = QuickSettings(activity) return when (keycode) { - // ABXY (Switch/Xbox layout switchable) + // ABXY (Switch/Xbox Layout KeyEvent.KEYCODE_BUTTON_A -> if (!quickSettings.useSwitchLayout) GamePadButtonInputId.A else GamePadButtonInputId.B KeyEvent.KEYCODE_BUTTON_B -> if (!quickSettings.useSwitchLayout) GamePadButtonInputId.B else GamePadButtonInputId.A KeyEvent.KEYCODE_BUTTON_X -> if (!quickSettings.useSwitchLayout) GamePadButtonInputId.X else GamePadButtonInputId.Y KeyEvent.KEYCODE_BUTTON_Y -> if (!quickSettings.useSwitchLayout) GamePadButtonInputId.Y else GamePadButtonInputId.X - // Shoulder buttons + // Shoulder & Trigger (falls ein Pad sie doch als Keys sendet) KeyEvent.KEYCODE_BUTTON_L1 -> GamePadButtonInputId.LeftShoulder KeyEvent.KEYCODE_BUTTON_L2 -> GamePadButtonInputId.LeftTrigger KeyEvent.KEYCODE_BUTTON_R1 -> GamePadButtonInputId.RightShoulder KeyEvent.KEYCODE_BUTTON_R2 -> GamePadButtonInputId.RightTrigger - // **L3 / R3 (Stick-Click) – CORRECT: *_Button** + // L3 / R3 KeyEvent.KEYCODE_BUTTON_THUMBL -> GamePadButtonInputId.LeftStickButton KeyEvent.KEYCODE_BUTTON_THUMBR -> GamePadButtonInputId.RightStickButton - - // Additional fallback keycodes for some pads (optional) - KeyEvent.KEYCODE_BUTTON_11 -> GamePadButtonInputId.LeftStickButton // isolated L3 - KeyEvent.KEYCODE_BUTTON_12 -> GamePadButtonInputId.RightStickButton // isolated R3 + KeyEvent.KEYCODE_BUTTON_11 -> GamePadButtonInputId.LeftStickButton + KeyEvent.KEYCODE_BUTTON_12 -> GamePadButtonInputId.RightStickButton // D-Pad - KeyEvent.KEYCODE_DPAD_UP -> GamePadButtonInputId.DpadUp - KeyEvent.KEYCODE_DPAD_DOWN -> GamePadButtonInputId.DpadDown - KeyEvent.KEYCODE_DPAD_LEFT -> GamePadButtonInputId.DpadLeft + KeyEvent.KEYCODE_DPAD_UP -> GamePadButtonInputId.DpadUp + KeyEvent.KEYCODE_DPAD_DOWN -> GamePadButtonInputId.DpadDown + KeyEvent.KEYCODE_DPAD_LEFT -> GamePadButtonInputId.DpadLeft KeyEvent.KEYCODE_DPAD_RIGHT -> GamePadButtonInputId.DpadRight // Plus/Minus - KeyEvent.KEYCODE_BUTTON_START -> GamePadButtonInputId.Plus + KeyEvent.KEYCODE_BUTTON_START -> GamePadButtonInputId.Plus KeyEvent.KEYCODE_BUTTON_SELECT -> GamePadButtonInputId.Minus else -> GamePadButtonInputId.None diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutHelper.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutHelper.kt new file mode 100644 index 000000000..f540662d1 --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutHelper.kt @@ -0,0 +1,112 @@ +package org.kenjinx.android + +import android.app.PendingIntent +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Base64 +import android.widget.Toast +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat + +object ShortcutHelper { + + /** + * Erstellt einen Shortcut zum Starten eines Spiels. + * + * @param context Context + * @param title Anzeigename des Shortcuts + * @param bootPathUri URI/String der Game-Datei + * @param useGridIcon Wenn true, wird das Grid-Icon bevorzugt (Bitmap oder Base64). + * @param gridIconBitmap Optional: direkt das Bitmap aus deinem Grid (empfohlen) + * @param gridIconBase64 Optional: Base64-Icon (falls du das an der Stelle hast) + */ + fun createGameShortcut( + context: Context, + title: String, + bootPathUri: String, + useGridIcon: Boolean, + gridIconBitmap: Bitmap? = null, + gridIconBase64: String? = null + ) { + val uri = runCatching { Uri.parse(bootPathUri) }.getOrNull() + + // --- Icon wählen (Grid-Bitmap > Base64 > App-Icon) + var icon = IconCompat.createWithResource(context, R.mipmap.ic_launcher) + if (useGridIcon) { + val bmp = gridIconBitmap ?: decodeBase64ToBitmap(gridIconBase64) + if (bmp != null) icon = IconCompat.createWithBitmap(bmp) + } + + // --- EXPLIZITER Intent exakt wie im funktionierenden Wizard --- + // ACTION_VIEW + setDataAndType + clipData + GRANT-Flags + Component auf MainActivity + val launchIntent = Intent(Intent.ACTION_VIEW).apply { + component = ComponentName(context, MainActivity::class.java) + if (uri != null) { + setDataAndType(uri, context.contentResolver.getType(uri) ?: "*/*") + clipData = android.content.ClipData.newUri( + context.contentResolver, + "GameUri", + uri + ) + } + putExtra("bootPath", bootPathUri) + putExtra("forceNceAndPptc", false) + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + + // Bestmögliche Persistierung/Grants (failsafe, falls bereits persistiert → Exceptions ignorieren) + if (uri != null) { + val rw = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + runCatching { context.contentResolver.takePersistableUriPermission(uri, rw) } + runCatching { context.grantUriPermission(context.packageName, uri, rw) } + } + + // Stabiles ID-Schema, damit derselbe Titel/bootPath nicht zigmal dupliziert wird. + val shortcutId = makeStableId(title, bootPathUri) + + val shortcut = ShortcutInfoCompat.Builder(context, shortcutId) + .setShortLabel(title) + .setLongLabel(title) + .setIcon(icon) + .setIntent(launchIntent) + .build() + + // Eigene App-Meldung (wie vorher): direkt vor dem System-Pin-Dialog + Toast.makeText(context, "Creating shortcut “$title”…", Toast.LENGTH_SHORT).show() + + val callbackIntent = ShortcutManagerCompat.createShortcutResultIntent(context, shortcut) + val successCallback = PendingIntent.getBroadcast( + context, + 0, + callbackIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // Anfrage an den Launcher + ShortcutManagerCompat.requestPinShortcut(context, shortcut, successCallback.intentSender) + } + + private fun makeStableId(title: String?, bootPath: String?): String { + val safeTitle = (title ?: "").trim() + val safeBoot = (bootPath ?: "").trim() + return "kenjinx_${safeTitle}_${safeBoot}".take(90) // ID muss <100 Zeichen bleiben + } + + private fun decodeBase64ToBitmap(b64: String?): Bitmap? { + if (b64.isNullOrBlank()) return null + return runCatching { + val bytes = Base64.decode(b64, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + }.getOrNull() + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutUtils.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutUtils.kt new file mode 100644 index 000000000..b896f847d --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutUtils.kt @@ -0,0 +1,149 @@ +package org.kenjinx.android + +import android.app.Activity +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ActivityInfo +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.graphics.Bitmap +import android.graphics.drawable.Icon +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import android.view.View + +object ShortcutUtils { + + fun suggestLabelFromUri(uri: Uri): String { + val last = uri.lastPathSegment ?: return "Start Game" + val raw = last.substringAfterLast("%2F").substringAfterLast("/") + val decoded = Uri.decode(raw) + return decoded.substringBeforeLast('.').ifBlank { "Start Game" } + } + + fun persistReadWrite(activity: Activity, uri: Uri) { + val rw = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + try { activity.contentResolver.takePersistableUriPermission(uri, rw) } catch (_: Exception) {} + try { activity.grantUriPermission(activity.packageName, uri, rw) } catch (_: Exception) {} + try { activity.grantUriPermission("org.kenjinx.android", uri, rw) } catch (_: Exception) {} + } + + /** + * Portrait während des System-Pin-Dialogs erzwingen und erst danach auf LANDSCAPE zurück. + * Warten auf IntentSender-Callback, sonst Fallback: erster Touch (+2s) oder 15s Timeout. + * Nach dem Restore optional onCompleted() ausführen (z.B. Activity.finish()). + */ + fun pinShortcutForGame( + activity: Activity, + gameUri: Uri, + label: String, + iconBitmap: Bitmap? = null, + onCompleted: (() -> Unit)? = null + ): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + val sm = activity.getSystemService(ShortcutManager::class.java) ?: return false + + val rw = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + val mime = activity.contentResolver.getType(gameUri) ?: "*/*" + val clip = ClipData.newUri(activity.contentResolver, "GameUri", gameUri) + + val launchIntent = Intent(activity, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + setDataAndType(gameUri, mime) + clipData = clip + putExtra("bootPath", gameUri.toString()) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or rw) + } + + val icon = iconBitmap?.let { Icon.createWithBitmap(it) } + ?: Icon.createWithResource(activity, R.mipmap.ic_launcher) + + val shortcut = ShortcutInfo.Builder(activity, "kenji_game_${gameUri.hashCode()}") + .setShortLabel(label.take(24)) + .setLongLabel(label) + .setIcon(icon) + .setIntent(launchIntent) + .build() + + // ---- Portrait erzwingen ---- + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + + val mainHandler = Handler(Looper.getMainLooper()) + val decorView = activity.window?.decorView + var restored = false + var touchScheduled = false + var pinResultReceiver: BroadcastReceiver? = null + + fun restoreOrientation(@Suppress("UNUSED_PARAMETER") reason: String) { + if (restored) return + restored = true + try { decorView?.setOnTouchListener(null) } catch (_: Exception) {} + try { pinResultReceiver?.let { activity.unregisterReceiver(it) } } catch (_: Exception) {} + mainHandler.removeCallbacksAndMessages(null) + + // → explizit auf LANDSCAPE zurück (MainActivity ist Landscape-locked) + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + + // jetzt darf die Wizard-Activity zu, wenn gewünscht + onCompleted?.invoke() + } + + // Old Shortcut Settings, can delete later + // Touch-Fallback: erster Touch → 2s später + //val touchListener = View.OnTouchListener { _: View, _: MotionEvent -> + // if (!restored && !touchScheduled) { + // touchScheduled = true + // mainHandler.postDelayed({ restoreOrientation("touch+delay") }, 2000) + // } + // false + //} + //decorView?.setOnTouchListener(touchListener) + + // Harte Obergrenze: 15s + //mainHandler.postDelayed({ restoreOrientation("timeout15s") }, 15_000) + + // BroadcastReceiver für den IntentSender-Callback + val ACTION_PIN_RESULT = "${activity.packageName}.PIN_SHORTCUT_RESULT" + pinResultReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + restoreOrientation("intent-callback") + } + } + val filter = IntentFilter(ACTION_PIN_RESULT) + if (Build.VERSION.SDK_INT >= 33) { + activity.registerReceiver(pinResultReceiver!!, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + activity.registerReceiver(pinResultReceiver, filter) + } + + // Anfrage an den Launcher + if (sm.isRequestPinShortcutSupported) { + val successIntent = sm.createShortcutResultIntent(shortcut).apply { + action = ACTION_PIN_RESULT + } + val sender = PendingIntent.getBroadcast( + activity, + 0, + successIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ).intentSender + + sm.requestPinShortcut(shortcut, sender) + // Kein sofortiges Restore: wir warten auf Callback/Touch/Timeout + return true + } else { + // Fallback: kein Systemdialog → dynamisch hinzufügen und sofort zurückstellen + sm.addDynamicShortcuts(listOf(shortcut)) + restoreOrientation("no-pin-support") + return true + } + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutWizardActivity.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutWizardActivity.kt new file mode 100644 index 000000000..1000aa3cd --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/ShortcutWizardActivity.kt @@ -0,0 +1,170 @@ +package org.kenjinx.android + +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Bundle +import android.widget.EditText +import android.widget.Toast +import android.content.pm.ActivityInfo +import android.util.Base64 +import androidx.documentfile.provider.DocumentFile +import org.kenjinx.android.viewmodels.GameInfo + +class ShortcutWizardActivity : Activity() { + + private val REQ_PICK_GAME = 1 + private val REQ_PICK_ICON = 2 + + private var pickedGameUri: Uri? = null + private var pendingLabel: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Portrait für den gesamten Wizard, bis Shortcut-Pinning fertig ist + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + + // Direkt beim Start: Spiel auswählen + requestGameFile() + } + + private fun requestGameFile() { + val pick = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + startActivityForResult(pick, REQ_PICK_GAME) + } + + @Deprecated("simple flow") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (requestCode == REQ_PICK_GAME) { + if (resultCode != RESULT_OK) { finish(); return } + val uri = data?.data ?: run { finish(); return } + pickedGameUri = uri + ShortcutUtils.persistReadWrite(this, uri) + + val suggested = ShortcutUtils.suggestLabelFromUri(uri) + val input = EditText(this).apply { setText(suggested) } + + AlertDialog.Builder(this) + .setTitle("Shortcut name") + .setView(input) + .setPositiveButton("Next (Icon)") { _, _ -> + val label = input.text?.toString()?.takeIf { it.isNotBlank() } ?: suggested + pendingLabel = label + requestIconImage() + } + .setNeutralButton("Use app icon") { _, _ -> + val label = input.text?.toString()?.takeIf { it.isNotBlank() } ?: suggested + // NEU: Verwende das Grid-Icon (GameInfo.Icon Base64) als Shortcut-Icon + val bmp = pickedGameUri?.let { loadGridIconBitmap(it) } + createShortcut(label, bmp) // Fallback auf App-Icon passiert intern in ShortcutUtils, wenn bmp=null ist + } + .setNegativeButton("Cancel") { _, _ -> finish() } + .setCancelable(false) + .show() + return + } + + if (requestCode == REQ_PICK_ICON) { + if (resultCode != RESULT_OK) { + // Abbruch → Fallback App-Icon (oder Grid-Icon wäre hier optional) + val label = pendingLabel ?: "Start Game" + createShortcut(label, null) + return + } + val imageUri = data?.data + val bmp = imageUri?.let { loadBitmap(it) } + val label = pendingLabel ?: "Start Game" + createShortcut(label, bmp) + return + } + + finish() + } + + private fun requestIconImage() { + val pickIcon = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "image/*" + addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + startActivityForResult(Intent.createChooser(pickIcon, "Select icon"), REQ_PICK_ICON) + } + + private fun loadBitmap(uri: Uri): Bitmap? = + try { + contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + contentResolver.openInputStream(uri).use { BitmapFactory.decodeStream(it) } + } catch (_: Exception) { + null + } + + /** + * NEU: + * Holt das gleiche Icon, das im Spiele-Grid angezeigt wird: + * - öffnet das FileDescriptor + * - ruft native GameInfo (inkl. Base64-Icon) ab + * - dekodiert zu Bitmap + */ + private fun loadGridIconBitmap(gameUri: Uri): Bitmap? { + return try { + val doc = DocumentFile.fromSingleUri(this, gameUri) + val name = doc?.name ?: return null + val ext = name.substringAfterLast('.', "").lowercase() + + val pfd = contentResolver.openFileDescriptor(gameUri, "r") ?: return null + pfd.use { + val info = GameInfo() + // native call: deviceGetGameInfo(fd, extension, info) + KenjinxNative.deviceGetGameInfo(it.fd, ext, info) + val b64 = info.Icon ?: return null + val bytes = Base64.decode(b64, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } + } catch (_: Exception) { + null + } + } + + private fun createShortcut(label: String, bmp: Bitmap?) { + val gameUri = pickedGameUri + if (gameUri == null) { finish(); return } + + val ok = ShortcutUtils.pinShortcutForGame( + activity = this, + gameUri = gameUri, + label = label, + iconBitmap = bmp + ) { + // wird erst aufgerufen, wenn Portrait wieder freigegeben wurde + finish() + } + + // Kein finish() hier! — wir warten auf den Callback + Toast.makeText( + this, + if (ok) "Shortcut “$label” created." else "Shortcut failed.", + Toast.LENGTH_SHORT + ).show() + } + + override fun onResume() { + super.onResume() + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt new file mode 100644 index 000000000..bde41a778 --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt @@ -0,0 +1,317 @@ +package org.kenjinx.android.cheats + +import android.app.Activity +import android.util.Log +import java.io.File +import java.nio.charset.Charset +import android.net.Uri +import android.provider.OpenableColumns +import android.content.Intent + +data class CheatItem(val buildId: String, val name: String) { + val key get() = "$buildId-$name" +} + +/* -------- Pfade -------- */ + +private fun cheatsDirExternal(activity: Activity, titleId: String): File { + val base = activity.getExternalFilesDir(null) // /storage/emulated/0/Android/data//files + return File(base, "mods/contents/$titleId/cheats") +} + +private fun allCheatDirs(activity: Activity, titleId: String): List { + return listOf(cheatsDirExternal(activity, titleId)) + .distinct() + .filter { it.exists() && it.isDirectory } +} + +/* -------- Parser -------- */ + +private fun parseCheatNames(text: String): List { + // Trim BOM, CRLF tolerant + val clean = text.replace("\uFEFF", "") + val rx = Regex("""(?m)^\s*\[(.+?)\]\s*$""") + return rx.findAll(clean) + .map { it.groupValues[1].trim() } + .filter { it.isNotEmpty() } + .toList() +} + +/* -------- Public: Cheats laden -------- */ + +fun loadCheatsFromDisk(activity: Activity, titleId: String): List { + val dirs = allCheatDirs(activity, titleId) + if (dirs.isEmpty()) { + Log.d("CheatFs", "No cheat dirs for $titleId (checked internal+external).") + return emptyList() + } + + val out = mutableListOf() + for (dir in dirs) { + dir.listFiles { f -> f.isFile && f.name.endsWith(".txt", ignoreCase = true) }?.forEach { file -> + val buildId = file.nameWithoutExtension + val text = runCatching { file.readText(Charset.forName("UTF-8")) }.getOrElse { "" } + parseCheatNames(text).forEach { nm -> + out += CheatItem(buildId, nm) + } + } + } + + return out + .distinctBy { it.key.lowercase() } + .sortedWith(compareBy({ it.buildId.lowercase() }, { it.name.lowercase() })) +} + +/* -------- Public: Auswahl SOFORT auf Disk anwenden -------- */ + +fun applyCheatSelectionOnDisk(activity: Activity, titleId: String, enabledKeys: Set) { + // Wir wählen genau EINE BUILDID-Datei (die „beste“), und schalten darin Sections. + val dirs = allCheatDirs(activity, titleId) + val allTxt = dirs.flatMap { d -> + d.listFiles { f -> f.isFile && f.name.endsWith(".txt", ignoreCase = true) }?.toList() ?: emptyList() + } + if (allTxt.isEmpty()) { + Log.d("CheatFs", "applyCheatSelectionOnDisk: no *.txt found for $titleId") + return + } + + val buildFile = pickBestBuildFile(allTxt) + val text = runCatching { buildFile.readText(Charset.forName("UTF-8")) }.getOrElse { "" } + if (text.isEmpty()) return + + // Enabled-Set normalisieren: Keys sind "-" + val enabledSections = enabledKeys.asSequence() + .mapNotNull { key -> + val dash = key.indexOf('-') + if (dash <= 0) null else key.substring(dash + 1).trim() + } + .map { it.lowercase() } + .toSet() + + val rewritten = rewriteCheatFile(text, enabledSections) + + runCatching { + buildFile.writeText(rewritten, Charset.forName("UTF-8")) + }.onFailure { + Log.w("CheatFs", "Failed to write ${buildFile.absolutePath}: ${it.message}") + } +} + +/* -------- Implementierung: Auswahl anwenden (nur ';' als Kommentar) -------- */ + +private fun pickBestBuildFile(files: List): File { + fun looksHexName(p: File): Boolean { + val n = p.nameWithoutExtension + return n.length >= 16 && n.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + } + return files.firstOrNull(::looksHexName) + ?: files.maxByOrNull { runCatching { it.lastModified() }.getOrDefault(0L) } + ?: files.first() +} + +private fun isSectionHeader(line: String): Boolean { + val t = line.trim() + return t.length > 2 && t.first() == '[' && t.contains(']') +} + +private fun sectionNameFromHeader(line: String): String { + val t = line.trim() + val close = t.indexOf(']') + return if (t.startsWith("[") && close > 1) t.substring(1, close).trim() else "" +} + +/** + * Entfernt EIN führendes Kommentarzeichen (';') + optionales Leerzeichen. + * Nur am absoluten Zeilenanfang (keine führenden Spaces erlaubt). + */ +private fun uncommentOnce(raw: String): String { + if (raw.isEmpty()) return raw + return if (raw.startsWith(";")) { + raw.drop(1).let { if (it.startsWith(" ")) it.drop(1) else it } + } else raw +} + +/** + * Kommentiert die Zeile aus, wenn sie nicht bereits mit ';' beginnt. + * Atmosphère nutzt ';' – das verwenden wir ausschließlich. + */ +private fun commentOut(raw: String): String { + val t = raw.trimStart() + if (t.isEmpty()) return raw + if (t.startsWith(";")) return raw + return "; $raw" +} + +/** + * Schreibt die Datei neu: + * - Keine Marker einfügen + * - Pro Section den Body gemäß enabled/disabled (enabledSections) kommentieren/entkommentieren + * - Reine Kommentar-/Leerzeilen (nur ';') bleiben erhalten + */ +// Hilfsfunktionen: trailing Blankzeilen trimmen / Header normalisieren +private fun trimTrailingBlankLines(lines: MutableList) { + while (lines.isNotEmpty() && lines.last().trim().isEmpty()) { + lines.removeAt(lines.lastIndex) + } +} + +private fun joinHeaderBufferOnce(header: List): String { + // Header-Zeilen unverändert, aber trailing Blanks entfernen und genau 1 Leerzeile danach + val buf = header.toMutableList() + trimTrailingBlankLines(buf) + return if (buf.isEmpty()) "" else buf.joinToString("\n") + "\n\n" +} + +/** + * Schreibt die Datei neu: + * - Keine Marker einfügen + * - Pro Section den Body gemäß enabled/disabled (enabledSections) kommentieren/entkommentieren + * - Reine Kommentar-/Leerzeilen bleiben erhalten + * - Zwischen Sections genau EINE Leerzeile, am Ende genau EIN Newline. + */ +private fun rewriteCheatFile(original: String, enabledSections: Set): String { + val lines = original.replace("\uFEFF", "").lines() + + val out = StringBuilder(original.length + 1024) + + var currentSection: String? = null + val currentBlock = ArrayList() + val headerBuffer = ArrayList() + var sawAnySection = false + var wroteAnySection = false + + fun flushCurrent() { + val sec = currentSection ?: return + + // trailing Blankzeilen im Block entfernen, damit keine doppelten Abstände wachsen + trimTrailingBlankLines(currentBlock) + + val enabled = enabledSections.contains(sec.lowercase()) + + // Zwischen Sections genau eine Leerzeile einfügen (aber nicht vor der ersten) + if (wroteAnySection) out.append('\n') + + out.append('[').append(sec).append(']').append('\n') + + if (enabled) { + // Entkommentieren (nur ein führendes ';' an Spalte 0) + for (l in currentBlock) { + val trimmed = l.trim() + if (trimmed.isEmpty() || (trimmed.startsWith(";") && trimmed.length <= 1)) { + out.append(l).append('\n') + } else { + if (l.startsWith(";")) { + out.append( + l.drop(1).let { if (it.startsWith(" ")) it.drop(1) else it } + ).append('\n') + } else { + out.append(l).append('\n') + } + } + } + } else { + // Disablen: alles, was nicht schon mit ';' beginnt und nicht leer ist, auskommentieren + for (l in currentBlock) { + val t = l.trim() + if (t.isEmpty() || t.startsWith(";")) { + out.append(l).append('\n') + } else { + out.append("; ").append(l).append('\n') + } + } + } + + wroteAnySection = true + currentSection = null + currentBlock.clear() + } + + for (raw in lines) { + if (isSectionHeader(raw)) { + flushCurrent() + currentSection = sectionNameFromHeader(raw) + sawAnySection = true + continue + } + + if (!sawAnySection) { + headerBuffer.add(raw) + } else { + currentBlock.add(raw) + } + } + flushCurrent() + + // Header vorn einsetzen (mit genau einer Leerzeile danach, falls vorhanden) + val headerText = joinHeaderBufferOnce(headerBuffer) + if (headerText.isNotEmpty()) { + out.insert(0, headerText) + } + + // Globale Normalisierung: 3+ Newlines -> 2, und am Ende genau EIN '\n' + var result = out.toString() + .replace(Regex("\n{3,}"), "\n\n") // nie mehr als 1 Leerzeile zwischen Abschnitten + .trimEnd() + "\n" // genau ein Newline am Ende + + return result +} +private fun cheatsDirPreferredForWrite(activity: Activity, titleId: String): File { + val dir = cheatsDirExternal(activity, titleId) + if (!dir.exists()) dir.mkdirs() + return dir +} + +private fun getDisplayName(activity: Activity, uri: Uri): String? { + return runCatching { + val cr = activity.contentResolver + cr.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c -> + if (c.moveToFirst()) c.getString(0) else null + } + }.getOrNull() +} + +private fun uniqueFile(targetDir: File, baseName: String): File { + var name = baseName + if (!name.lowercase().endsWith(".txt")) name += ".txt" + var out = File(targetDir, name) + var idx = 1 + val stem = name.substringBeforeLast(".") + val ext = ".txt" + while (out.exists()) { + out = File(targetDir, "$stem ($idx)$ext") + idx++ + } + return out +} + +/** + * Importiert eine .txt aus einem SAF-Uri in den Cheats-Ordner des Titels. + * Gibt das Zieldatei-Objekt zurück, wenn erfolgreich. + */ +fun importCheatTxt(activity: Activity, titleId: String, source: Uri): Result { + return runCatching { + // Lese-Rechte ggf. dauerhaft sichern + try { + activity.contentResolver.takePersistableUriPermission( + source, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (_: Throwable) {} + + val targetDir = cheatsDirPreferredForWrite(activity, titleId) + + val display = getDisplayName(activity, source) ?: "cheats.txt" + val target = uniqueFile(targetDir, display) + + activity.contentResolver.openInputStream(source).use { ins -> + requireNotNull(ins) { "InputStream null" } + target.outputStream().use { outs -> + ins.copyTo(outs) + } + } + + // nach Import: optional sofort neu einlesen/normalisieren wäre möglich, + // aber wir belassen die Datei so wie geliefert. + target + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatPrefs.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatPrefs.kt new file mode 100644 index 000000000..667efd61f --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatPrefs.kt @@ -0,0 +1,16 @@ +package org.kenjinx.android.cheats + +import android.content.Context + +class CheatPrefs(private val context: Context) { + private fun key(titleId: String) = "cheats_$titleId" + private val prefs get() = context.getSharedPreferences("cheats", Context.MODE_PRIVATE) + + fun getEnabled(titleId: String): MutableSet { + return prefs.getStringSet(key(titleId), emptySet())?.toMutableSet() ?: mutableSetOf() + } + + fun setEnabled(titleId: String, keys: Set) { + prefs.edit().putStringSet(key(titleId), keys.toSet()).apply() + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/ModFs.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/ModFs.kt new file mode 100644 index 000000000..dea14ed5f --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/ModFs.kt @@ -0,0 +1,195 @@ +package org.kenjinx.android.cheats + +import android.app.Activity +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import java.io.File +import java.io.InputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +/* -------- Pfade -------- */ + +private fun modsRootExternal(activity: Activity): File { + // /storage/emulated/0/Android/data//files/sdcard/atmosphere/contents + return File(activity.getExternalFilesDir(null), "sdcard/atmosphere/contents") +} + +private fun modsTitleDir(activity: Activity, titleIdUpper: String): File { + // TITLEID muss groß geschrieben sein + return File(modsRootExternal(activity), titleIdUpper) +} + +private fun modDir(activity: Activity, titleIdUpper: String, modName: String): File { + return File(modsTitleDir(activity, titleIdUpper), modName) +} + +/* -------- Auflisten & Löschen -------- */ + +fun listMods(activity: Activity, titleId: String): List { + val titleIdUpper = titleId.trim().uppercase() + val dir = modsTitleDir(activity, titleIdUpper) + if (!dir.exists() || !dir.isDirectory) return emptyList() + + return dir.listFiles { f -> f.isDirectory } // NAME-Ordner + ?.map { it.name } + ?.sortedBy { it.lowercase() } + ?: emptyList() +} + +fun deleteMod(activity: Activity, titleId: String, modName: String): Boolean { + val target = modDir(activity, titleId.trim().uppercase(), modName) + return target.safeDeleteRecursively() +} + +private fun File.safeDeleteRecursively(): Boolean { + if (!exists()) return true + return try { + walkBottomUp().forEach { + runCatching { if (it.isDirectory) it.delete() else it.delete() } + } + !exists() + } catch (_: Throwable) { + false + } +} + +/* -------- Import ZIP -------- */ + +data class ImportProgress( + val bytesRead: Long, + val totalBytes: Long, + val currentEntry: String = "" +) { + val fraction: Float + get() = if (totalBytes <= 0) 0f else (bytesRead.coerceAtMost(totalBytes).toFloat() / totalBytes.toFloat()) +} + +// NEU: Multi-Import. Top-Level-Ordner in der ZIP sind die Mod-Namen. +data class ImportModsResult( + val imported: List, + val ok: Boolean +) + +fun importModsZip( + activity: Activity, + titleId: String, + zipUri: Uri, + onProgress: (ImportProgress) -> Unit +): ImportModsResult { + val titleIdUpper = titleId.trim().uppercase() + val baseDir = modsTitleDir(activity, titleIdUpper).apply { mkdirs() } + + val (_, totalBytes) = resolveDisplayNameAndSize(activity.contentResolver, zipUri) + var bytes = 0L + fun bump(read: Int, entryName: String = "") { + if (read > 0) { + bytes += read + onProgress(ImportProgress(bytesRead = bytes, totalBytes = totalBytes, currentEntry = entryName)) + } + } + + // Für jeden Top-Level-Ordner (Mod-Name) einmalig vorbereiten (ggf. alten Ordner löschen). + val preparedMods = mutableSetOf() + val importedMods = linkedSetOf() // Reihenfolge stabil + + return try { + activity.contentResolver.openInputStream(zipUri).use { raw -> + if (raw == null) return@use + ZipInputStream(raw).use { zis -> + var entry = zis.nextEntry + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + while (entry != null) { + val rawName = entry.name.replace('\\', '/') // normalisieren + // Sicherheitsfilter & leere Namen überspringen + if (rawName.isBlank() || rawName.startsWith("/") || rawName.contains("..")) { + zis.closeEntry() + entry = zis.nextEntry + continue + } + + // Top-Level: erster Segment vor dem ersten '/' + val slash = rawName.indexOf('/') + val topLevel = if (slash > 0) rawName.substring(0, slash) else rawName + if (topLevel.isBlank()) { + zis.closeEntry() + entry = zis.nextEntry + continue + } + + // restlicher Pfad innerhalb des Mod-Ordners + val relPath = if (slash >= 0 && slash + 1 < rawName.length) rawName.substring(slash + 1) else "" + + // Nur Einträge verarbeiten, die innerhalb eines Modordners liegen (wir wollen NAME/... Strukturen) + if (relPath.isBlank() && entry.isDirectory.not()) { + // Datei direkt im Top-Level (z.B. NAME.txt) ignorieren + zis.closeEntry() + entry = zis.nextEntry + continue + } + + // Mod-Ordner vorbereiten (einmalig: ggf. alten Ordner entfernen) + if (preparedMods.add(topLevel)) { + val modFolder = modDir(activity, titleIdUpper, topLevel) + if (modFolder.exists()) modFolder.safeDeleteRecursively() + modFolder.mkdirs() + importedMods += topLevel + } + + // Zielpfad: .../TITLEID// + val dest = if (relPath.isBlank()) { + // nur ein Ordner-Eintrag (NAME/ oder NAME/exefs/) + File(modDir(activity, titleIdUpper, topLevel), "") + } else { + File(modDir(activity, titleIdUpper, topLevel), relPath) + } + + if (entry.isDirectory) { + dest.mkdirs() + } else { + dest.parentFile?.mkdirs() + dest.outputStream().use { os -> + var n = zis.read(buffer) + while (n > 0) { + os.write(buffer, 0, n) + bump(n, rawName) + n = zis.read(buffer) + } + } + } + + zis.closeEntry() + entry = zis.nextEntry + } + } + } + + ImportModsResult(imported = importedMods.toList(), ok = importedMods.isNotEmpty()) + } catch (t: Throwable) { + Log.w("ModFs", "importModsZip failed: ${t.message}") + // Best effort: schon angelegte Mods sauber entfernen + importedMods.forEach { name -> + runCatching { modDir(activity, titleIdUpper, name).safeDeleteRecursively() } + } + ImportModsResult(imported = emptyList(), ok = false) + } +} + +private fun resolveDisplayNameAndSize(cr: ContentResolver, uri: Uri): Pair { + var name: String? = null + var size: Long = -1 + try { + cr.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c -> + if (c.moveToFirst()) { + val nameIdx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = c.getColumnIndex(OpenableColumns.SIZE) + if (nameIdx >= 0) name = c.getString(nameIdx) + if (sizeIdx >= 0) size = c.getLong(sizeIdx) + } + } + } catch (_: Throwable) {} + return name to size +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt new file mode 100644 index 000000000..ea787ba12 --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/saves/SaveFs.kt @@ -0,0 +1,342 @@ +package org.kenjinx.android.saves + +import android.app.Activity +import android.content.ContentResolver +import android.net.Uri +import android.util.Log +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.nio.charset.Charset +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +/* ============================= Paths ============================= */ + +private fun savesRootExternal(activity: Activity): File { + // /storage/emulated/0/Android/data//files/bis/user/save + val base = activity.getExternalFilesDir(null) + return File(base, "bis/user/save") +} + +private fun isHex16(name: String): Boolean = + name.length == 16 && name.all { it in '0'..'9' || it.lowercaseChar() in 'a'..'f' } + +/* ========================= Metadata & Scan ======================== */ + +data class SaveFolderMeta( + val dir: File, + val indexHex: String, // z.B. 0000000000000008 oder 000000000000000a + val titleId: String?, // aus TITLEID.txt (lowercase) oder geerbter Wert + val titleName: String?, // zweite Zeile aus TITLEID.txt, falls vorhanden + val hasMarker: Boolean // true, wenn dieser Ordner die TITLEID.txt selbst hat +) + +/** + * Scannt .../bis/user/save; ordnet nummerierte Ordner (16 Hex-Zeichen) per „Marker-Vererbung“: + * Ein Ordner ohne TITLEID.txt gehört zum zuletzt gesehenen Ordner mit TITLEID.txt davor. + */ +fun listSaveFolders(activity: Activity): List { + val root = savesRootExternal(activity) + if (!root.exists()) return emptyList() + + val dirs = root.listFiles { f -> f.isDirectory && isHex16(f.name) } + ?.sortedBy { it.name.lowercase(Locale.ROOT) } + ?: return emptyList() + + val out = ArrayList(dirs.size) + var currentTid: String? = null + var currentName: String? = null + + for (d in dirs) { + val marker = File(d, "TITLEID.txt") + val has = marker.exists() + if (has) { + val txt = runCatching { marker.readText(Charset.forName("UTF-8")) }.getOrElse { "" } + val lines = txt.split('\n', '\r').map { it.trim() }.filter { it.isNotEmpty() } + val tid = lines.getOrNull(0)?.lowercase(Locale.ROOT) + val name = lines.getOrNull(1) + if (!tid.isNullOrBlank()) { + currentTid = tid + currentName = name + } + out += SaveFolderMeta(d, d.name, currentTid, currentName, hasMarker = true) + } else { + out += SaveFolderMeta(d, d.name, currentTid, currentName, hasMarker = false) + } + } + return out +} + +/* ===== TitleID-Kandidaten (Base/Update tolerant) & Gruppierung ===== */ + +private fun hex16CandidatesForSaves(id: String): List { + val lc = id.trim().lowercase(Locale.ROOT) + if (!isHex16(lc)) return listOf(lc) + val head = lc.substring(0, 13) // erste 13 Zeichen + val base = head + "000" // ...000 + val upd = head + "800" // ...800 + return listOf(lc, base, upd).distinct() +} + +/** Alle Save-Ordner einer TitleID-Gruppe (Marker-Vererbung), Base/Update tolerant. */ +private fun listSaveGroupForTitle(activity: Activity, titleId: String): List { + val candidates = hex16CandidatesForSaves(titleId) + val metas = listSaveFolders(activity) + return metas.filter { meta -> + val tid = meta.titleId ?: return@filter false + candidates.any { it.equals(tid, ignoreCase = true) } + } +} + +/** Bevorzugt den Ordner mit TITLEID.txt; Fallback: lexikografisch kleinster der Gruppe. */ +private fun pickSaveDirWithMarker(activity: Activity, titleId: String): File? { + val group = listSaveGroupForTitle(activity, titleId) + val marker = group.firstOrNull { it.hasMarker }?.dir + if (marker != null) return marker + return group.minByOrNull { it.indexHex.lowercase(Locale.ROOT) }?.dir +} + +/* =========================== Export ============================== */ + +data class ExportProgress(val bytes: Long, val total: Long, val currentPath: String) +data class ExportResult(val ok: Boolean, val error: String? = null) + +private fun sanitizeFileName(s: String): String = + s.replace(Regex("""[\\/:*?"<>|]"""), "_").trim().ifBlank { "save" } + +/** + * Schreibt eine ZIP im Format: ZIP enthält Ordner "TITLEID_UPPER/…" und darin + * den reinen Inhalt des Ordners "0" (nicht den Ordner 0 selbst). + */ +fun exportSaveToZip( + activity: Activity, + titleId: String, + destUri: Uri, + onProgress: (ExportProgress) -> Unit +): ExportResult { + val tidUpper = titleId.trim().uppercase(Locale.ROOT) + val primary = pickSaveDirWithMarker(activity, titleId) + ?: return ExportResult(false, "Save folder not found. Start game once.") + + val folder0 = File(primary, "0") + if (!folder0.exists() || !folder0.isDirectory) { + return ExportResult(false, "Missing '0' save folder.") + } + + val files = folder0.walkTopDown().filter { it.isFile }.toList() + val total = files.sumOf { it.length() } + + return try { + activity.contentResolver.openOutputStream(destUri)?.use { os -> + ZipOutputStream(os).use { zos -> + var written = 0L + val buf = ByteArray(DEFAULT_BUFFER_SIZE) + + fun putFile(f: File, rel: String) { + val entryPath = "$tidUpper/$rel" + val entry = ZipEntry(entryPath) + zos.putNextEntry(entry) + FileInputStream(f).use { inp -> + var n = inp.read(buf) + while (n > 0) { + zos.write(buf, 0, n) + written += n + onProgress(ExportProgress(written, total, entryPath)) + n = inp.read(buf) + } + } + zos.closeEntry() + } + + folder0.walkTopDown().forEach { f -> + if (f.isFile) { + val rel = f.relativeTo(folder0).invariantSeparatorsPath + putFile(f, rel) + } + } + } + } ?: return ExportResult(false, "Failed to open destination") + ExportResult(true, null) + } catch (t: Throwable) { + Log.w("SaveFs", "exportSaveToZip failed: ${t.message}") + ExportResult(false, t.message ?: "Export failed") + } +} + +/** Hilfsname für CreateDocument: "_save_YYYY-MM-DD.zip" (aus Marker-Ordner) */ +fun buildSuggestedExportName(activity: Activity, titleId: String): String { + val primary = pickSaveDirWithMarker(activity, titleId) + val displayName = if (primary != null) { + val txt = File(primary, "TITLEID.txt") + runCatching { + txt.takeIf { it.exists() }?.readLines(Charset.forName("UTF-8"))?.getOrNull(1) + }.getOrNull()?.takeIf { it.isNotBlank() } ?: "Save" + } else { + "Save" + } + val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date()) + return sanitizeFileName("${displayName}_save_$date") + ".zip" +} + +/* =========================== Import ============================== */ + +data class ImportProgress(val bytes: Long, val total: Long, val currentEntry: String) +data class ImportResult(val ok: Boolean, val message: String) + +/* Helpers */ + +private fun isHexTitleId(s: String): Boolean = + s.length == 16 && s.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + +private fun topLevelSegment(path: String): String { + val p = path.replace('\\', '/').trim('/') + val idx = p.indexOf('/') + return if (idx >= 0) p.substring(0, idx) else p +} + +private fun ensureInside(base: File, child: File): Boolean = + try { + val basePath = base.canonicalPath + val childPath = child.canonicalPath + childPath.startsWith(basePath + File.separator) + } catch (_: Throwable) { false } + +private fun clearDirectory(dir: File) { + if (!dir.isDirectory) return + dir.listFiles()?.forEach { f -> + if (f.isDirectory) f.deleteRecursively() else runCatching { f.delete() } + } +} + +/** + * Erwartet ZIP mit Struktur: TITLEID_UPPER/… – schreibt NUR in den Ordner mit TITLEID.txt. + */ +fun importSaveFromZip( + activity: Activity, + zipUri: Uri, + onProgress: (ImportProgress) -> Unit +): ImportResult { + val cr: ContentResolver = activity.contentResolver + + // TitelID-Ordner im ZIP finden + var titleIdFromZip: String? = null + var totalBytes = 0L + + // Pass 1: Top-Level TitelID und Total ermitteln + runCatching { + cr.openInputStream(zipUri)?.use { ins -> + ZipInputStream(BufferedInputStream(ins)).use { zis -> + val tops = mutableSetOf() + var ze = zis.nextEntry + while (ze != null) { + val name = ze.name.replace('\\', '/') + if (!ze.isDirectory) tops += topLevelSegment(name) + ze = zis.nextEntry + } + titleIdFromZip = tops.firstOrNull { isHexTitleId(it) } + } + } + }.onFailure { + return ImportResult(false, "error importing save. invalid zip") + } + + if (titleIdFromZip == null) return ImportResult(false, "error importing save. missing TITLEID folder") + val tidZip = titleIdFromZip!!.lowercase(Locale.ROOT) + + // Größe nur unterhalb //… summieren + runCatching { + cr.openInputStream(zipUri)?.use { ins -> + ZipInputStream(BufferedInputStream(ins)).use { zis -> + var ze = zis.nextEntry + while (ze != null) { + val name = ze.name.replace('\\', '/') + if (!ze.isDirectory && topLevelSegment(name).equals(tidZip, ignoreCase = true)) { + if (ze.size >= 0) totalBytes += ze.size + } + ze = zis.nextEntry + } + } + } + } + + // Ziel: NUR der Marker-Ordner + val targetRoot = pickSaveDirWithMarker(activity, tidZip) + ?: pickSaveDirWithMarker(activity, tidZip.uppercase(Locale.ROOT)) + ?: return ImportResult(false, "error importing save. start game once.") + + // 0/1 vorbereiten (leeren) + val zero = File(targetRoot, "0").apply { mkdirs() } + val one = File(targetRoot, "1").apply { mkdirs() } + clearDirectory(zero) + clearDirectory(one) + + // Pass 2: extrahieren + var written = 0L + val buf = ByteArray(DEFAULT_BUFFER_SIZE) + + val ok = runCatching { + cr.openInputStream(zipUri)?.use { ins -> + ZipInputStream(BufferedInputStream(ins)).use { zis -> + var ze = zis.nextEntry + while (ze != null) { + val entryNameRaw = ze.name.replace('\\', '/').trimStart('/') + val top = topLevelSegment(entryNameRaw) + + if (!ze.isDirectory && top.equals(tidZip, ignoreCase = true)) { + val rel = entryNameRaw.substring(top.length).trimStart('/') + if (rel.isNotEmpty()) { + val out0 = File(zero, rel) + val out1 = File(one, rel) + out0.parentFile?.mkdirs() + out1.parentFile?.mkdirs() + + if (!ensureInside(zero, out0) || !ensureInside(one, out1)) { + // Zip-Slip Schutz + zis.closeEntry() + ze = zis.nextEntry + continue + } + + // Einmal lesen, zweimal schreiben + val os0 = out0.outputStream() + val os1 = out1.outputStream() + try { + var n = zis.read(buf) + while (n > 0) { + os0.write(buf, 0, n) + os1.write(buf, 0, n) + written += n + onProgress(ImportProgress(written, totalBytes, rel)) + n = zis.read(buf) + } + } finally { + runCatching { os0.close() } + runCatching { os1.close() } + } + } + } + + zis.closeEntry() + ze = zis.nextEntry + } + } + } + true + }.getOrElse { + Log.w("SaveFs", "importSaveFromZip failed: ${it.message}") + false + } + + return if (ok) ImportResult(true, "save imported") + else ImportResult(false, "error importing save. start game once.") +} + +/* ========================= UI Helpers ============================ */ + +fun suggestedCreateDocNameForExport(activity: Activity, titleId: String): String = + buildSuggestedExportName(activity, titleId) diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/service/EmulationService.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/service/EmulationService.kt new file mode 100644 index 000000000..2c2b12cc8 --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/service/EmulationService.kt @@ -0,0 +1,170 @@ +package org.kenjinx.android.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Binder +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import org.kenjinx.android.MainActivity +import org.kenjinx.android.R +import org.kenjinx.android.KenjinxNative +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Foreground-Service für stabile Emulation im Hintergrund. + * Manifest: android:foregroundServiceType="mediaPlayback" + */ +class EmulationService : Service() { + + companion object { + private const val CHANNEL_ID = "kenjinx_emulation" + private const val NOTIF_ID = 42 + const val ACTION_STOPPED = "org.kenjinx.android.action.EMULATION_SERVICE_STOPPED" + } + + inner class LocalBinder : Binder() { + fun startEmulation(runLoopBlock: () -> Unit) = this@EmulationService.startEmulation(runLoopBlock) + fun stopEmulation(onStop: () -> Unit) = this@EmulationService.stopEmulation(onStop) + fun shutdownService() = this@EmulationService.shutdownService() + } + + private val binder = LocalBinder() + private lateinit var executor: ExecutorService + private var future: Future<*>? = null + private val running = AtomicBoolean(false) + // Nur wenn eine Emulation wirklich lief, dürfen wir nativ „hard close“ machen + private val startedOnce = AtomicBoolean(false) + + override fun onCreate() { + super.onCreate() + executor = Executors.newSingleThreadExecutor { r -> + Thread(r, "Kenjinx-Emu").apply { + isDaemon = false + priority = Thread.NORM_PRIORITY + 2 + } + } + createNotificationChannel() + startForeground(NOTIF_ID, buildNotification()) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_NOT_STICKY + override fun onBind(intent: Intent?): IBinder = binder + + override fun onUnbind(intent: Intent?): Boolean { + if (!running.get()) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + return super.onUnbind(intent) + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + try { future?.cancel(true) } catch (_: Throwable) {} + // Nur schließen, wenn zuvor gestartet + hardCloseNativeIfStarted("onTaskRemoved") + running.set(false) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + try { sendBroadcast(Intent(ACTION_STOPPED).setPackage(packageName)) } catch (_: Throwable) {} + } + + override fun onDestroy() { + super.onDestroy() + try { future?.cancel(true) } catch (_: Throwable) {} + hardCloseNativeIfStarted("onDestroy") + running.set(false) + stopForeground(STOP_FOREGROUND_REMOVE) + try { sendBroadcast(Intent(ACTION_STOPPED).setPackage(packageName)) } catch (_: Throwable) {} + } + + // ---- Steuerung via Binder ---- + + private fun startEmulation(runLoopBlock: () -> Unit) { + // Nur einen RunLoop zulassen + if (!running.compareAndSet(false, true)) return + + future = executor.submit { + try { + // *** Kein Preflight-HardClose mehr! *** (crasht beim allerersten Start) + startedOnce.set(true) + runLoopBlock() // blockiert bis Emulation endet + } finally { + startedOnce.set(false) + running.set(false) + } + } + } + + private fun stopEmulation(onStop: () -> Unit) { + executor.execute { + try { + try { onStop() } catch (_: Throwable) {} + } finally { + try { future?.cancel(true) } catch (_: Throwable) {} + hardCloseNativeIfStarted("stopEmulation") + running.set(false) + } + } + } + + private fun shutdownService() { + try { future?.cancel(true) } catch (_: Throwable) {} + hardCloseNativeIfStarted("shutdownService") + running.set(false) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + // ---- Native Cleanup nur wenn jemals gestartet ---- + private fun hardCloseNativeIfStarted(reason: String) { + if (!startedOnce.get()) return + try { KenjinxNative.detachWindow() } catch (_: Throwable) {} + try { KenjinxNative.deviceCloseEmulation() } catch (_: Throwable) {} + // KEIN graphicsSetPresentEnabled(false) hier – führt bei kaltem Start zu NRE in VulkanRenderer.ReleaseSurface() + // android.util.Log.d("EmuService", "hardCloseNativeIfStarted: $reason") + } + + // ---- Notification ---- + + private fun buildNotification(): Notification { + val openIntent = Intent(this, MainActivity::class.java) + val flags = if (Build.VERSION.SDK_INT >= 23) + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + else + PendingIntent.FLAG_UPDATE_CURRENT + val pi = PendingIntent.getActivity(this, 0, openIntent, flags) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setOngoing(true) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(getString(R.string.app_name)) + .setContentText("Emulation is running…") + .setContentIntent(pi) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= 26) { + val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val ch = NotificationChannel( + CHANNEL_ID, + "Emulation", + NotificationManager.IMPORTANCE_LOW + ) + ch.description = "Keeps the Emulation active." + mgr.createNotificationChannel(ch) + } + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/DlcViewModel.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/DlcViewModel.kt index 58862a981..92fedaddd 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/DlcViewModel.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/DlcViewModel.kt @@ -188,7 +188,7 @@ class DlcViewModel(val titleId: String) { } } - private fun saveChanges() { + fun saveChanges() { data?.apply { dlcItemsState?.forEach { item -> for (container in this) { diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/HomeViewModel.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/HomeViewModel.kt index 92e9073a7..ce4232ec5 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/HomeViewModel.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/HomeViewModel.kt @@ -14,7 +14,9 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import org.kenjinx.android.KenjinxNative import org.kenjinx.android.MainActivity +import java.io.File import java.util.Locale import kotlin.concurrent.thread @@ -104,10 +106,14 @@ class HomeViewModel( return@sortWith strA.length - strB.length } - for(game in loadedCache) - { + for (game in loadedCache) { game.getGameInfo() } + + // Auto-load DLCs and Title Updates from configured directories (if any) + try { + autoloadContent() + } catch (_: Throwable) { } } finally { isLoading.value = false GlobalScope.launch(Dispatchers.Main){ @@ -116,4 +122,115 @@ class HomeViewModel( } } } + + // Helper function to compare update versions from filenames + // Returns true if newPath represents a newer version than currentPath + private fun shouldSelectNewerUpdate(currentPath: String, newPath: String): Boolean { + // Extract version numbers from filenames using regex pattern [vXXXXXX] + val versionPattern = Regex("\\[v(\\d+)]") + + val currentVersion = versionPattern.find(currentPath.lowercase(Locale.getDefault()))?.groupValues?.get(1)?.toIntOrNull() ?: 0 + val newVersion = versionPattern.find(newPath.lowercase(Locale.getDefault()))?.groupValues?.get(1)?.toIntOrNull() ?: 0 + + return newVersion > currentVersion + } + + // Scans configured directory for NSPs containing DLCs/Updates and associates them to known titles. + private fun autoloadContent() { + val prefs = sharedPref ?: return + + val updatesFolder = prefs.getString("updatesFolder", "") ?: "" + + if (updatesFolder.isEmpty()) return + + // Build a map of titleId -> helpers + val gamesByTitle = loadedCache.mapNotNull { g -> + val tid = g.titleId + if (!tid.isNullOrBlank()) tid.lowercase(Locale.getDefault()) to tid else null + }.toMap() + + var updatesAdded = 0 + var dlcAdded = 0 + + val base = File(updatesFolder) + if (!base.exists() || !base.isDirectory) return + + base.walkTopDown().forEach fileLoop@{ f -> + if (!f.isFile) return@fileLoop + val name = f.name.lowercase(Locale.getDefault()) + if (!name.endsWith(".nsp")) return@fileLoop + + // Extract title ID from filename + val tidPattern = Regex("\\[([0-9a-fA-F]{16})]") + val tidMatch = tidPattern.find(name) ?: return@fileLoop + val fileTid = tidMatch.groupValues[1].lowercase(Locale.getDefault()) + + // Try to find DLC content for all games + var isDlc = false + try { + for ((_, tidOrig) in gamesByTitle) { + val contents = KenjinxNative.deviceGetDlcContentList(f.absolutePath, tidOrig.toLong(16)) + + if (contents.isNotEmpty()) { + isDlc = true + val containerPath = f.absolutePath + val vm = DlcViewModel(tidOrig) + val already = vm.data?.any { it.path == containerPath } == true + + if (!already) { + val container = DlcContainerList(containerPath) + for (content in contents) { + container.dlc_nca_list.add( + DlcContainer( + true, + KenjinxNative.deviceGetDlcTitleId(containerPath, content).toLong(16), + content + ) + ) + } + vm.data?.add(container) + vm.saveChanges() + dlcAdded++ + } + break + } + } + } catch (_: Throwable) { } + + if (isDlc) return@fileLoop + + // Treat as Title Update - convert update ID to base ID + // Update title IDs end in 800, base game IDs end in 000 + val baseTid = if (fileTid.endsWith("800")) { + fileTid.substring(0, fileTid.length - 3) + "000" + } else { + fileTid + } + + val originalTid = gamesByTitle[baseTid] + if (originalTid != null) { + val vm = TitleUpdateViewModel(originalTid) + val path = f.absolutePath + val exists = (vm.data?.paths?.contains(path) == true) + + if (!exists) { + // Add the new update path + vm.data?.paths?.add(path) + + // Auto-select this update if it's newer than the currently selected one + // or if no update is currently selected + val currentSelected = vm.data?.selected ?: "" + val shouldSelect = currentSelected.isEmpty() || + shouldSelectNewerUpdate(currentSelected, path) + + if (shouldSelect) { + vm.data?.selected = path + } + + vm.saveChanges() + updatesAdded++ + } + } + } + } } diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/MainViewModel.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/MainViewModel.kt index d7bcb18fa..d96b9726c 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/MainViewModel.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/MainViewModel.kt @@ -1,6 +1,7 @@ package org.kenjinx.android.viewmodels import android.annotation.SuppressLint +import android.net.Uri import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.navigation.NavHostController @@ -8,7 +9,7 @@ import androidx.preference.PreferenceManager import com.anggrayudi.storage.extension.launchOnUiThread import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Semaphore -import org.kenjinx.android.GameController +import org.kenjinx.android.IGameController import org.kenjinx.android.GameHost import org.kenjinx.android.Logging import org.kenjinx.android.MainActivity @@ -30,7 +31,7 @@ class MainViewModel(val activity: MainActivity) { var physicalControllerManager: PhysicalControllerManager? = null var motionSensorManager: MotionSensorManager? = null var gameModel: GameModel? = null - var controller: GameController? = null + var controller: IGameController? = null var performanceManager: PerformanceManager? = null var selected: GameModel? = null val loadGameModel: MutableState = mutableStateOf(null) @@ -50,7 +51,15 @@ class MainViewModel(val activity: MainActivity) { private var progressValue: MutableState? = null private var showLoading: MutableState? = null private var refreshUser: MutableState? = null + @Volatile var rendererReady: Boolean = false + // Default Game Folder + var defaultGameFolderUri: Uri? = null + set(value) { + field = value + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + prefs.edit().putString("defaultGameFolderUri", value?.toString() ?: "").apply() + } var gameHost: GameHost? = null set(value) { field = value @@ -62,6 +71,12 @@ class MainViewModel(val activity: MainActivity) { init { performanceManager = PerformanceManager(activity) + + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + val saved = prefs.getString("defaultGameFolderUri", "") ?: "" + if (saved.isNotEmpty()) { + defaultGameFolderUri = Uri.parse(saved) + } } fun refreshFirmwareVersion() { @@ -75,6 +90,7 @@ class MainViewModel(val activity: MainActivity) { motionSensorManager?.unregister() physicalControllerManager?.disconnect() motionSensorManager?.setControllerId(-1) + rendererReady = false } // ---- Load language/region from Preferences (Defaults: AmericanEnglish/USA) ---- @@ -117,13 +133,16 @@ class MainViewModel(val activity: MainActivity) { settings.overrideSettings(forceNceAndPptc) } + // 0=Auto, 1=SingleThread, 2=Threaded + val backendMode = if (settings.disableThreadedRendering) 1 else 2 + var success = KenjinxNative.graphicsInitialize( enableMacroHLE = settings.enableMacroHLE, enableShaderCache = settings.enableShaderCache, enableTextureRecompression = settings.enableTextureRecompression, rescale = settings.resScale, maxAnisotropy = settings.maxAnisotropy, - backendThreading = org.kenjinx.android.BackendThreading.Auto.ordinal + backendThreading = backendMode ) if (!success) @@ -178,6 +197,7 @@ class MainViewModel(val activity: MainActivity) { extensions.size, driverHandle ) + rendererReady = success if (!success) return 0 @@ -227,13 +247,16 @@ class MainViewModel(val activity: MainActivity) { val settings = QuickSettings(activity) + // 0=Auto, 1=SingleThread, 2=Threaded + val backendMode = if (settings.disableThreadedRendering) 1 else 2 + var success = KenjinxNative.graphicsInitialize( enableMacroHLE = settings.enableMacroHLE, enableShaderCache = settings.enableShaderCache, enableTextureRecompression = settings.enableTextureRecompression, rescale = settings.resScale, maxAnisotropy = settings.maxAnisotropy, - backendThreading = org.kenjinx.android.BackendThreading.Auto.ordinal + backendThreading = backendMode ) if (!success) @@ -289,6 +312,7 @@ class MainViewModel(val activity: MainActivity) { extensions.size, driverHandle ) + rendererReady = success if (!success) return false @@ -422,7 +446,7 @@ class MainViewModel(val activity: MainActivity) { frequenciesState?.let { PerformanceMonitor.getFrequencies(it) } } - fun setGameController(controller: GameController) { + fun setGameController(controller: IGameController) { this.controller = controller } diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/QuickSettings.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/QuickSettings.kt index 762407ecb..b95cf91bd 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/QuickSettings.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/QuickSettings.kt @@ -24,6 +24,13 @@ class QuickSettings(val activity: Activity) { BottomMiddle, BottomLeft, BottomRight, TopMiddle, TopLeft, TopRight } + // --- Virtual Controller Preset + enum class VirtualControllerPreset { + Default, Layout2, Layout3, Layout4, Layout5, Layout6 + } + + var virtualControllerPreset: VirtualControllerPreset + var orientationPreference: OrientationPreference // --- Overlay Settings @@ -41,6 +48,18 @@ class QuickSettings(val activity: Activity) { var useNce: Boolean var memoryConfiguration: MemoryConfiguration var useVirtualController: Boolean + // Amiibo slots (URIs + names) + var amiibo1Uri: String? + var amiibo1Name: String? + var amiibo2Uri: String? + var amiibo2Name: String? + var amiibo3Uri: String? + var amiibo3Name: String? + var amiibo4Uri: String? + var amiibo4Name: String? + var amiibo5Uri: String? + var amiibo5Name: String? + var memoryManagerMode: MemoryManagerMode var enableShaderCache: Boolean var enableTextureRecompression: Boolean @@ -53,6 +72,10 @@ class QuickSettings(val activity: Activity) { var enableMotion: Boolean var enablePerformanceMode: Boolean var controllerStickSensitivity: Float + + // --- NEU: Controller Scale (0.5f..1.5f, Default 1.0f) + var controllerScale: Float + var enableStubLogs: Boolean var enableInfoLogs: Boolean var enableWarningLogs: Boolean @@ -63,14 +86,29 @@ class QuickSettings(val activity: Activity) { var enableDebugLogs: Boolean var enableGraphicsLogs: Boolean + // --- NEU: Threaded Rendering Toggle (persistiert) + var disableThreadedRendering: Boolean + private var sharedPref: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity) init { + // Load Amiibo slots + amiibo1Uri = sharedPref.getString("amiibo1Uri", null) + amiibo1Name = sharedPref.getString("amiibo1Name", null) + amiibo2Uri = sharedPref.getString("amiibo2Uri", null) + amiibo2Name = sharedPref.getString("amiibo2Name", null) + amiibo3Uri = sharedPref.getString("amiibo3Uri", null) + amiibo3Name = sharedPref.getString("amiibo3Name", null) + amiibo4Uri = sharedPref.getString("amiibo4Uri", null) + amiibo4Name = sharedPref.getString("amiibo4Name", null) + amiibo5Uri = sharedPref.getString("amiibo5Uri", null) + amiibo5Name = sharedPref.getString("amiibo5Name", null) + // --- Load alignment (Default: Sensor) val oriValue = sharedPref.getInt("orientationPreference", ActivityInfo.SCREEN_ORIENTATION_SENSOR) orientationPreference = OrientationPreference.fromValue(oriValue) - // --- NEU: Overlay Settings laden + // --- Overlay Settings laden overlayMenuPosition = OverlayMenuPosition.entries[ sharedPref.getInt("overlayMenuPosition", OverlayMenuPosition.BottomMiddle.ordinal) ] @@ -94,11 +132,18 @@ class QuickSettings(val activity: Activity) { resScale = sharedPref.getFloat("resScale", 1f) maxAnisotropy = sharedPref.getFloat("maxAnisotropy", 0f) useVirtualController = sharedPref.getBoolean("useVirtualController", true) + virtualControllerPreset = VirtualControllerPreset.entries[ + sharedPref.getInt("virtualControllerPreset", VirtualControllerPreset.Default.ordinal) + ] isGrid = sharedPref.getBoolean("isGrid", true) useSwitchLayout = sharedPref.getBoolean("useSwitchLayout", true) enableMotion = sharedPref.getBoolean("enableMotion", true) enablePerformanceMode = sharedPref.getBoolean("enablePerformanceMode", true) controllerStickSensitivity = sharedPref.getFloat("controllerStickSensitivity", 1.0f) + + // --- NEU laden: Controller Scale + controllerScale = sharedPref.getFloat("controllerScale", 1.0f).coerceIn(0.5f, 1.5f) + enableStubLogs = sharedPref.getBoolean("enableStubLogs", false) enableInfoLogs = sharedPref.getBoolean("enableInfoLogs", true) enableWarningLogs = sharedPref.getBoolean("enableWarningLogs", true) @@ -108,14 +153,29 @@ class QuickSettings(val activity: Activity) { enableTraceLogs = sharedPref.getBoolean("enableStubLogs", false) enableDebugLogs = sharedPref.getBoolean("enableDebugLogs", false) enableGraphicsLogs = sharedPref.getBoolean("enableGraphicsLogs", false) + + // --- NEU laden + disableThreadedRendering = sharedPref.getBoolean("disableThreadedRendering", false) } fun save() { sharedPref.edit { + // Amiibo slots + putString("amiibo1Uri", amiibo1Uri) + putString("amiibo1Name", amiibo1Name) + putString("amiibo2Uri", amiibo2Uri) + putString("amiibo2Name", amiibo2Name) + putString("amiibo3Uri", amiibo3Uri) + putString("amiibo3Name", amiibo3Name) + putString("amiibo4Uri", amiibo4Uri) + putString("amiibo4Name", amiibo4Name) + putString("amiibo5Uri", amiibo5Uri) + putString("amiibo5Name", amiibo5Name) + // --- Save orientation putInt("orientationPreference", orientationPreference.value) - // --- NEU: Overlay Settings speichern + // --- Overlay Settings speichern putInt("overlayMenuPosition", overlayMenuPosition.ordinal) putFloat("overlayMenuOpacity", overlayMenuOpacity.coerceIn(0f, 1f)) @@ -142,6 +202,10 @@ class QuickSettings(val activity: Activity) { putBoolean("enableMotion", enableMotion) putBoolean("enablePerformanceMode", enablePerformanceMode) putFloat("controllerStickSensitivity", controllerStickSensitivity) + + // --- NEU speichern: Controller Scale + putFloat("controllerScale", controllerScale.coerceIn(0.5f, 1.5f)) + putBoolean("enableStubLogs", enableStubLogs) putBoolean("enableInfoLogs", enableInfoLogs) putBoolean("enableWarningLogs", enableWarningLogs) @@ -151,6 +215,10 @@ class QuickSettings(val activity: Activity) { putBoolean("enableTraceLogs", enableTraceLogs) putBoolean("enableDebugLogs", enableDebugLogs) putBoolean("enableGraphicsLogs", enableGraphicsLogs) + putInt("virtualControllerPreset", virtualControllerPreset.ordinal) + + // --- NEU speichern + putBoolean("disableThreadedRendering", disableThreadedRendering) } } diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/SettingsViewModel.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/SettingsViewModel.kt index 7f522425f..dbdc52623 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/SettingsViewModel.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/SettingsViewModel.kt @@ -214,6 +214,9 @@ class SettingsViewModel(val activity: MainActivity) { sharedPref.edit { putString("gameFolder", p) } + runCatching { + MainActivity.mainViewModel?.defaultGameFolderUri = folder.uri + } activity.storageHelper!!.onFolderSelected = previousFolderCallback } @@ -226,6 +229,30 @@ class SettingsViewModel(val activity: MainActivity) { ) } + fun openUpdatesFolder() { + val path = sharedPref.getString("updatesFolder", "") ?: "" + + activity.storageHelper!!.onFolderSelected = { _, folder -> + val p = folder.getAbsolutePath(activity) + sharedPref.edit { + putString("updatesFolder", p) + } + activity.storageHelper!!.onFolderSelected = previousFolderCallback + + // Trigger a reload of the game list to apply the new updates/DLC folder + MainActivity.mainViewModel?.homeViewModel?.requestReload() + MainActivity.mainViewModel?.homeViewModel?.ensureReloadIfNecessary() + } + + if (path.isEmpty()) + activity.storageHelper?.storage?.openFolderPicker() + else + activity.storageHelper?.storage?.openFolderPicker( + activity.storageHelper!!.storage.requestCodeFolderPicker, + FileFullPath(activity, path) + ) + } + fun selectKey(installState: MutableState) { if (installState.value != KeyInstallState.File) return diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/TitleUpdateViewModel.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/TitleUpdateViewModel.kt index a2bf41488..67972b1b9 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/TitleUpdateViewModel.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/viewmodels/TitleUpdateViewModel.kt @@ -176,7 +176,7 @@ class TitleUpdateViewModel(val titleId: String) { } } - private fun saveChanges() { + fun saveChanges() { val metadata = data ?: TitleUpdateMetadata() val gson = Gson() diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/GameViews.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/GameViews.kt index 8ae382828..00cc2a486 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/GameViews.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/GameViews.kt @@ -38,6 +38,11 @@ import androidx.compose.ui.draw.alpha import compose.icons.CssGgIcons import compose.icons.cssggicons.ToolbarBottom import org.kenjinx.android.GameController +import org.kenjinx.android.GameController2 +import org.kenjinx.android.GameController3 +import org.kenjinx.android.GameController4 +import org.kenjinx.android.GameController5 +import org.kenjinx.android.GameController6 import org.kenjinx.android.GameHost import org.kenjinx.android.Icons import org.kenjinx.android.MainActivity @@ -48,6 +53,15 @@ import org.kenjinx.android.viewmodels.VSyncMode import org.kenjinx.android.widgets.SimpleAlertDialog import java.util.Locale import kotlin.math.roundToInt +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.ui.platform.LocalConfiguration +import org.kenjinx.android.viewmodels.QuickSettings.VirtualControllerPreset + class GameViews { companion object { @@ -55,7 +69,7 @@ class GameViews { fun Main() { Surface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background + color = Color.Black ) { GameView(mainViewModel = MainActivity.mainViewModel!!) } @@ -63,17 +77,69 @@ class GameViews { @Composable fun GameView(mainViewModel: MainViewModel) { - Box(modifier = Modifier.fillMaxSize()) { - AndroidView( + val cfg = LocalConfiguration.current + val isLandscape = cfg.screenWidthDp >= cfg.screenHeightDp + val isLarge = (cfg.smallestScreenWidthDp >= 600) || (cfg.screenWidthDp >= 900) + + // Setting aus den Preferences (wird beim Game-Start gelesen) + val stretch = QuickSettings(mainViewModel.activity).stretchToFullscreen + + // Standard-Ratio (Switch 16:9). Wenn du später dynamisch aus dem Renderer lesen willst, + // kannst du gameAspect hier zur Laufzeit aktualisieren. + val gameAspect = 16f / 9f + + if (stretch) { + // Vollbild strecken (keine Letterbox), oben verankert + Box( modifier = Modifier.fillMaxSize(), - factory = { context -> - GameHost(context, mainViewModel) + contentAlignment = Alignment.TopCenter + ) { + AndroidView( + modifier = Modifier + .fillMaxSize() + .align(Alignment.TopCenter), + factory = { context -> GameHost(context, mainViewModel) } + ) + GameOverlay(mainViewModel) + } + } else { + // Letterbox beibehalten, aber oben fixieren. Phones: smart-fit, + // Tablets/Foldables in Landscape: erzwinge fitWidth (wie gewünscht). + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val containerAspect = maxWidth.value / maxHeight.value + + val useFitWidth = + if (isLandscape && isLarge) true + else containerAspect < gameAspect + + val fitModifier = + if (useFitWidth) { + Modifier + .fillMaxWidth() + .aspectRatio(gameAspect) + .align(Alignment.TopCenter) + } else { + Modifier + .fillMaxHeight() + .aspectRatio(gameAspect) + .align(Alignment.TopCenter) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + AndroidView( + modifier = fitModifier, + factory = { context -> GameHost(context, mainViewModel) } + ) + GameOverlay(mainViewModel) } - ) - GameOverlay(mainViewModel) + } } } + @OptIn(ExperimentalMaterial3Api::class) @Composable fun GameOverlay(mainViewModel: MainViewModel) { @@ -107,6 +173,9 @@ class GameViews { } } + // helper: slot label + fun qsLabel(name: String?, slot: Int): String = + if (name.isNullOrBlank()) "Slot $slot" else name if (showStats.value) { GameStats(mainViewModel) @@ -155,7 +224,17 @@ class GameViews { } if (!showLoading.value) { - GameController.Compose(mainViewModel) + // Aktuelles Preset aus QuickSettings holen (bei jeder Recomposition neu – so greift auch ein Wechsel nach dem Speichern) + val preset = QuickSettings(mainViewModel.activity).virtualControllerPreset + + when (preset) { + VirtualControllerPreset.Default -> GameController.Compose(mainViewModel) + VirtualControllerPreset.Layout2 -> GameController2.Compose(mainViewModel) + VirtualControllerPreset.Layout3 -> GameController3.Compose(mainViewModel) + VirtualControllerPreset.Layout4 -> GameController4.Compose(mainViewModel) + VirtualControllerPreset.Layout5 -> GameController5.Compose(mainViewModel) + VirtualControllerPreset.Layout6 -> GameController6.Compose(mainViewModel) + } // --- Button at any corner/edge + transparency Row( @@ -245,6 +324,110 @@ class GameViews { ) } } + + // MINIMAL ADD: Amiibo slot buttons + Column(modifier = Modifier.padding(bottom = 8.dp)) { + Text(text = "Amiibo Slots") + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + androidx.compose.material3.Button(onClick = { + val qs = QuickSettings(mainViewModel.activity) + val u = qs.amiibo1Uri + val name = qs.amiibo1Name ?: "Slot 1" + if (u.isNullOrEmpty()) { + Toast.makeText(mainViewModel.activity, "Slot 1 is empty.", Toast.LENGTH_SHORT).show() + } else { + try { + val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() } + if (bytes != null && bytes.isNotEmpty()) { + val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size) + if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show() + else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show() + } else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show() + } catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() } + } + }) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo1Name, 1)) } + + androidx.compose.material3.Button(onClick = { + val qs = QuickSettings(mainViewModel.activity) + val u = qs.amiibo2Uri + val name = qs.amiibo2Name ?: "Slot 2" + if (u.isNullOrEmpty()) { + Toast.makeText(mainViewModel.activity, "Slot 2 is empty.", Toast.LENGTH_SHORT).show() + } else { + try { + val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() } + if (bytes != null && bytes.isNotEmpty()) { + val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size) + if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show() + else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show() + } else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show() + } catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() } + } + }) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo2Name, 2)) } + + androidx.compose.material3.Button(onClick = { + val qs = QuickSettings(mainViewModel.activity) + val u = qs.amiibo3Uri + val name = qs.amiibo3Name ?: "Slot 3" + if (u.isNullOrEmpty()) { + Toast.makeText(mainViewModel.activity, "Slot 3 is empty.", Toast.LENGTH_SHORT).show() + } else { + try { + val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() } + if (bytes != null && bytes.isNotEmpty()) { + val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size) + if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show() + else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show() + } else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show() + } catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() } + } + }) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo3Name, 3)) } + } + + Row(modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + androidx.compose.material3.Button(onClick = { + val qs = QuickSettings(mainViewModel.activity) + val u = qs.amiibo4Uri + val name = qs.amiibo4Name ?: "Slot 4" + if (u.isNullOrEmpty()) { + Toast.makeText(mainViewModel.activity, "Slot 4 is empty.", Toast.LENGTH_SHORT).show() + } else { + try { + val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() } + if (bytes != null && bytes.isNotEmpty()) { + val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size) + if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show() + else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show() + } else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show() + } catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() } + } + }) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo4Name, 4)) } + + androidx.compose.material3.Button(onClick = { + val qs = QuickSettings(mainViewModel.activity) + val u = qs.amiibo5Uri + val name = qs.amiibo5Name ?: "Slot 5" + if (u.isNullOrEmpty()) { + Toast.makeText(mainViewModel.activity, "Slot 5 is empty.", Toast.LENGTH_SHORT).show() + } else { + try { + val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() } + if (bytes != null && bytes.isNotEmpty()) { + val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size) + if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show() + else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show() + } else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show() + } catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() } + } + }) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo5Name, 5)) } + + androidx.compose.material3.OutlinedButton(onClick = { + KenjinxNative.amiiboClear() + Toast.makeText(mainViewModel.activity, "Amiibo cleared", Toast.LENGTH_SHORT).show() + }) { androidx.compose.material3.Text("Clear") } + } + } } } } diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt index fd9e3c206..943cc07ef 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt @@ -1,7 +1,11 @@ package org.kenjinx.android.views +import android.app.Activity +import android.content.Intent import android.content.res.Resources +import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image @@ -87,6 +91,29 @@ import org.kenjinx.android.viewmodels.GameModel import org.kenjinx.android.viewmodels.HomeViewModel import org.kenjinx.android.viewmodels.QuickSettings import org.kenjinx.android.widgets.SimpleAlertDialog +import org.kenjinx.android.ShortcutUtils +import org.kenjinx.android.ShortcutWizardActivity +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.TextButton +import androidx.documentfile.provider.DocumentFile + +// NEW: Cheats +import org.kenjinx.android.cheats.CheatPrefs +import org.kenjinx.android.cheats.CheatItem +import org.kenjinx.android.cheats.loadCheatsFromDisk +import org.kenjinx.android.cheats.applyCheatSelectionOnDisk +import org.kenjinx.android.cheats.importCheatTxt + +// NEW: Mods +import org.kenjinx.android.cheats.listMods +import org.kenjinx.android.cheats.deleteMod +import org.kenjinx.android.cheats.importModsZip + +// NEW: Saves +import org.kenjinx.android.saves.* class HomeViews { companion object { @@ -103,7 +130,18 @@ class HomeViews { modifier = modifier.padding(8.dp) ) } + // -- Helper for Shortcut-Flow + private fun resolveGameUri(gm: GameModel): Uri? = gm.file.uri + private fun decodeGameIcon(gm: GameModel): Bitmap? { + return try { + val b64 = gm.icon ?: return null + val bytes = Base64.getDecoder().decode(b64) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } catch (_: Throwable) { + null + } + } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun Home( @@ -124,8 +162,222 @@ class HomeViews { var isFabVisible by remember { mutableStateOf(true) } val isNavigating = remember { mutableStateOf(false) } + // NEW: Amiibo slot picker state + val showAmiiboSlotDialog = remember { mutableStateOf(false) } + val pendingSlot = remember { mutableStateOf(1) } + + // NEW: Cheats UI state + val openCheatsDialog = remember { mutableStateOf(false) } + val cheatsForSelected = remember { mutableStateOf(listOf()) } + val enabledCheatKeys = remember { mutableStateOf(mutableSetOf()) } + + // NEW: Mods UI state + val openModsDialog = remember { mutableStateOf(false) } + val modsForSelected = remember { mutableStateOf(listOf()) } + val modsImportProgress = remember { mutableStateOf(0f) } + val modsImportBusy = remember { mutableStateOf(false) } + val modsImportStatusText = remember { mutableStateOf("") } + + // Save Manager State + val openSavesDialog = remember { mutableStateOf(false) } + val saveImportBusy = remember { mutableStateOf(false) } + val saveExportBusy = remember { mutableStateOf(false) } + val saveImportProgress = remember { mutableStateOf(0f) } + val saveExportProgress = remember { mutableStateOf(0f) } + val saveImportStatus = remember { mutableStateOf("") } + val saveExportStatus = remember { mutableStateOf("") } + + val activity = LocalContext.current as? Activity + + // Import: OpenDocument (ZIP) + val importZipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + val act = activity + // Guard auf ausgewähltes Spiel – optional + val tIdNow = viewModel.mainViewModel?.selected?.titleId.orEmpty() + if (uri != null && act != null && tIdNow.isNotEmpty()) { + saveImportBusy.value = true + saveImportProgress.value = 0f + saveImportStatus.value = "Starting…" + + thread { + val res = importSaveFromZip(act, uri) { prog -> + val frac = if (prog.total > 0) prog.bytes.toFloat() / prog.total else 0f + saveImportProgress.value = frac.coerceIn(0f, 1f) + saveImportStatus.value = "Importing: ${prog.currentEntry}" + } + saveImportBusy.value = false + launchOnUiThread { + Toast.makeText(act, res.message, Toast.LENGTH_SHORT).show() + } + } + } + } + + // Export: CreateDocument (ZIP) + val exportZipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/zip") + ) { uri: Uri? -> + val act = activity + val tIdNow = viewModel.mainViewModel?.selected?.titleId.orEmpty() + if (uri != null && act != null && tIdNow.isNotEmpty()) { + saveExportBusy.value = true + saveExportProgress.value = 0f + saveExportStatus.value = "Starting…" + + thread { + val res = exportSaveToZip(act, tIdNow, uri) { prog -> + val frac = if (prog.total > 0) prog.bytes.toFloat() / prog.total else 0f + saveExportProgress.value = frac.coerceIn(0f, 1f) + saveExportStatus.value = "Exporting: ${prog.currentPath}" + } + saveExportBusy.value = false + launchOnUiThread { + Toast.makeText( + act, + if (res.ok) "save exported" else (res.error ?: "export failed"), + Toast.LENGTH_SHORT + ).show() + } + } + } + } + + // Shortcut-Dialog-State + val showShortcutDialog = remember { mutableStateOf(false) } + val shortcutName = remember { mutableStateOf("") } + val context = LocalContext.current + // NEW: Launcher für Amiibo (OpenDocument) + val pickAmiiboLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri != null && activity != null) { + try { + activity.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (_: Exception) {} + val name = DocumentFile.fromSingleUri(activity, uri)?.name ?: "amiibo.bin" + val qs = QuickSettings(activity) + when (pendingSlot.value) { + 1 -> { qs.amiibo1Uri = uri.toString(); qs.amiibo1Name = name } + 2 -> { qs.amiibo2Uri = uri.toString(); qs.amiibo2Name = name } + 3 -> { qs.amiibo3Uri = uri.toString(); qs.amiibo3Name = name } + 4 -> { qs.amiibo4Uri = uri.toString(); qs.amiibo4Name = name } + 5 -> { qs.amiibo5Uri = uri.toString(); qs.amiibo5Name = name } + } + qs.save() + Toast.makeText(activity, "Amiibo saved to slot ${pendingSlot.value}", Toast.LENGTH_SHORT).show() + } + } + + // NEW: Cheats Import (.txt) + val importCheatLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + val titleId = gm?.titleId ?: "" + if (uri != null && act != null && titleId.isNotEmpty()) { + // nur .txt akzeptieren + val okExt = runCatching { + DocumentFile.fromSingleUri(act, uri)?.name?.lowercase()?.endsWith(".txt") == true + }.getOrElse { false } + if (!okExt) { + Toast.makeText(act, "Please select a .txt file", Toast.LENGTH_SHORT).show() + return@rememberLauncherForActivityResult + } + + val res = importCheatTxt(act, titleId, uri) + if (res.isSuccess) { + Toast.makeText(act, "Imported: ${res.getOrNull()?.name}", Toast.LENGTH_SHORT).show() + // danach Liste aktualisieren + cheatsForSelected.value = loadCheatsFromDisk(act, titleId) + } else { + Toast.makeText(act, "Import failed: ${res.exceptionOrNull()?.message}", Toast.LENGTH_LONG).show() + } + } + } + // NEW: Launcher for Mods + val pickModZipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + val titleId = gm?.titleId ?: "" + if (uri != null && act != null && titleId.isNotEmpty()) { + // Persist permission (lesen) + try { + act.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: Exception) {} + + modsImportBusy.value = true + modsImportProgress.value = 0f + modsImportStatusText.value = "Starting…" + + thread { + val res = importModsZip( + act, + titleId, + uri + ) { prog -> + modsImportProgress.value = prog.fraction + modsImportStatusText.value = if (prog.currentEntry.isNotEmpty()) + "Copying: ${prog.currentEntry}" + else + "Copying… ${(prog.fraction * 100).toInt()}%" + } + + // Liste aktualisieren + modsForSelected.value = listMods(act, titleId) + modsImportBusy.value = false + + launchOnUiThread { + val msg = if (res.ok) + "Imported: ${res.imported.joinToString(", ")}" + else + "Import failed" + Toast.makeText(act, msg, Toast.LENGTH_SHORT).show() + } + } + + } + } + + + // Launcher for "Custom icon" (OpenDocument) + val pickImageLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + val gm = viewModel.mainViewModel?.selected + if (uri != null && gm != null && activity != null) { + val bmp = runCatching { + context.contentResolver.openInputStream(uri).use { BitmapFactory.decodeStream(it) } + }.getOrNull() + + val label = shortcutName.value.ifBlank { gm.titleName ?: "Start Game" } + val gameUri = resolveGameUri(gm) + if (gameUri != null) { + ShortcutUtils.persistReadWrite(activity, gameUri) + + ShortcutUtils.pinShortcutForGame( + activity = activity, + gameUri = gameUri, + label = label, + iconBitmap = bmp + ) { + + } + } else { + showError.value = "Shortcut failed (no game URI found)." + } + } + } + val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { @@ -241,7 +493,7 @@ class HomeViews { Icon(Icons.Filled.Settings, contentDescription = "Settings") } - } + } OutlinedTextField( value = query.value, @@ -256,27 +508,39 @@ class HomeViews { singleLine = true, shape = RoundedCornerShape(8.dp), colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - errorContainerColor = Color.Transparent, - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - ) + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + errorContainerColor = Color.Transparent, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + ) ) } }, floatingActionButton = { AnimatedVisibility(visible = isFabVisible) { - FloatingActionButton( - onClick = { - viewModel.requestReload() - viewModel.ensureReloadIfNecessary() - }, - shape = MaterialTheme.shapes.small, - containerColor = MaterialTheme.colorScheme.tertiary - ) { - Icon(Icons.Default.Refresh, contentDescription = "refresh") + // NEW: two FABs in a row: Refresh + Import Amiibo + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FloatingActionButton( + onClick = { + viewModel.requestReload() + viewModel.ensureReloadIfNecessary() + }, + shape = MaterialTheme.shapes.small, + containerColor = MaterialTheme.colorScheme.tertiary + ) { + Icon(Icons.Default.Refresh, contentDescription = "refresh") + } + FloatingActionButton( + onClick = { showAmiiboSlotDialog.value = true }, + shape = MaterialTheme.shapes.small + ) { + Icon( + org.kenjinx.android.Icons.folderOpen(MaterialTheme.colorScheme.onSurface), + contentDescription = "Import Amiibo" + ) + } } } }, @@ -375,6 +639,51 @@ class HomeViews { val name = viewModel.mainViewModel?.selected?.titleName ?: "" DlcViews.Main(titleId, name, openDlcDialog, canClose) } + + // NEW: Amiibo slot chooser dialog (outside of game) + if (showAmiiboSlotDialog.value) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { showAmiiboSlotDialog.value = false }, + title = { Text("Import Amiibo") }, + text = { + Column { + Text("Choose a slot to save this Amiibo:", modifier = Modifier.padding(bottom = 8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { + pendingSlot.value = 1 + pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*")) + showAmiiboSlotDialog.value = false + }) { Text("Slot 1") } + TextButton(onClick = { + pendingSlot.value = 2 + pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*")) + showAmiiboSlotDialog.value = false + }) { Text("Slot 2") } + TextButton(onClick = { + pendingSlot.value = 3 + pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*")) + showAmiiboSlotDialog.value = false + }) { Text("Slot 3") } + } + Row(modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { + pendingSlot.value = 4 + pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*")) + showAmiiboSlotDialog.value = false + }) { Text("Slot 4") } + TextButton(onClick = { + pendingSlot.value = 5 + pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*")) + showAmiiboSlotDialog.value = false + }) { Text("Slot 5") } + } + } + }, + confirmButton = { + TextButton(onClick = { showAmiiboSlotDialog.value = false }) { Text("Close") } + } + ) + } } if (viewModel.mainViewModel?.loadGameModel?.value != null) @@ -386,8 +695,14 @@ class HomeViews { thread { showLoading.value = true + + // NEW: Push Cheats vor dem Start (Auto-Start Pfad) + val gm = viewModel.mainViewModel.loadGameModel.value!! + val tId = gm.titleId ?: "" + val act = viewModel.activity + val success = viewModel.mainViewModel.loadGame( - viewModel.mainViewModel.loadGameModel.value!!, + gm, true, viewModel.mainViewModel.forceNceAndPptc.value ) @@ -415,10 +730,16 @@ class HomeViews { if (showAppActions.value) { IconButton(onClick = { if (viewModel.mainViewModel?.selected != null) { + + // NEW: Push Cheats vor dem Start (Run-Button) + val gmSel = viewModel.mainViewModel!!.selected!! + val tId = gmSel.titleId ?: "" + val act = viewModel.activity + thread { showLoading.value = true val success = viewModel.mainViewModel.loadGame( - viewModel.mainViewModel.selected!! + gmSel ) if (success == 1) { launchOnUiThread { @@ -439,6 +760,21 @@ class HomeViews { contentDescription = "Run" ) } + + // create Shortcut + IconButton(onClick = { + val gm = viewModel.mainViewModel?.selected + if (gm != null) { + shortcutName.value = gm.titleName ?: "" + showShortcutDialog.value = true + } + }) { + Icon( + Icons.Filled.Add, + contentDescription = "Create Shortcut" + ) + } + val showAppMenu = remember { mutableStateOf(false) } Box { IconButton(onClick = { showAppMenu.value = true }) { @@ -489,6 +825,47 @@ class HomeViews { openDlcDialog.value = true } ) + // NEW: Manage Cheats + DropdownMenuItem( + text = { Text(text = "Manage Cheats") }, + onClick = { + showAppMenu.value = false + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + if (gm != null && !gm.titleId.isNullOrEmpty() && act != null) { + val titleId = gm.titleId!! + cheatsForSelected.value = loadCheatsFromDisk(act, titleId) + enabledCheatKeys.value = CheatPrefs(act).getEnabled(titleId) + openCheatsDialog.value = true + } else { + showError.value = "No title selected." + } + } + ) + // NEW: Manage Mods + DropdownMenuItem( + text = { Text(text = "Manage Mods") }, + onClick = { + showAppMenu.value = false + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + if (gm != null && !gm.titleId.isNullOrEmpty() && act != null) { + val titleId = gm.titleId!! + modsForSelected.value = listMods(act, titleId) + openModsDialog.value = true + } else { + showError.value = "No title selected." + } + } + ) + DropdownMenuItem( + text = { Text(text = "Manage Saves") }, + onClick = { + showAppMenu.value = false + openSavesDialog.value = true + } + ) + } } } @@ -500,6 +877,328 @@ class HomeViews { } ) + // --- Cheats Bottom Sheet --- + if (openCheatsDialog.value) { + ModalBottomSheet( + onDismissRequest = { openCheatsDialog.value = false } + ) { + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + val titleId = gm?.titleId ?: "" + + Column(Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // LINKS: Import .txt + TextButton(onClick = { + importCheatLauncher.launch(arrayOf("text/plain", "text/*", "*/*")) + }) { Text("Import .txt") } + + // RECHTS: Cancel + Save + Row { + TextButton(onClick = { openCheatsDialog.value = false }) { Text("Cancel") } + TextButton(onClick = { + val act2 = act + if (act2 != null && titleId.isNotEmpty()) { + CheatPrefs(act2).setEnabled(titleId, enabledCheatKeys.value) + applyCheatSelectionOnDisk(act2, titleId, enabledCheatKeys.value) + cheatsForSelected.value = loadCheatsFromDisk(act2, titleId) + } + openCheatsDialog.value = false + }) { Text("Save") } + } + } + + Text("Manage Cheats", style = MaterialTheme.typography.titleLarge) + Text( + text = gm?.titleName ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(bottom = 8.dp) + ) + + if (cheatsForSelected.value.isEmpty()) { + Text("No cheats found for this title.") + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) { + items(cheatsForSelected.value) { cheat -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + Modifier + .weight(1f) + .padding(end = 12.dp) + ) { + Text(cheat.name, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text( + cheat.buildId, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + val checked = enabledCheatKeys.value.contains(cheat.key) + androidx.compose.material3.Switch( + checked = checked, + onCheckedChange = { isOn -> + enabledCheatKeys.value = + enabledCheatKeys.value.toMutableSet().apply { + if (isOn) add(cheat.key) else remove(cheat.key) + } + } + ) + } + } + } + } + } + } + } + + // --- Mods Bottom Sheet --- + if (openModsDialog.value) { + ModalBottomSheet( + onDismissRequest = { openModsDialog.value = false } + ) { + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + val titleId = gm?.titleId ?: "" + + Column(Modifier.padding(16.dp)) { + Text("Manage Mods", style = MaterialTheme.typography.titleLarge) + Text( + text = gm?.titleName ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Import-Zeile + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + enabled = !modsImportBusy.value, + onClick = { pickModZipLauncher.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) } + ) { Text("Import .zip") } + } + + // Progress + if (modsImportBusy.value) { + androidx.compose.material3.LinearProgressIndicator( + progress = { modsImportProgress.value }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + Text( + modsImportStatusText.value, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + // Liste der Mods + if (modsForSelected.value.isEmpty()) { + Text("No mods found for this title.") + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) { + items(modsForSelected.value) { modName -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(modName, maxLines = 2, overflow = TextOverflow.Ellipsis) + Row { + TextButton( + onClick = { + val a = act + if (a != null && titleId.isNotEmpty()) { + thread { + val ok = deleteMod(a, titleId, modName) + if (ok) { + modsForSelected.value = listMods(a, titleId) + } + } + } + } + ) { Text("Delete") } + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = { openModsDialog.value = false }) { Text("Close") } + } + } + } + } + + // --- Saves Bottom Sheet --- + if (openSavesDialog.value) { + ModalBottomSheet( + onDismissRequest = { openSavesDialog.value = false } + ) { + val act = activity + + Column(Modifier.padding(16.dp)) { + Text("Save Manager", style = MaterialTheme.typography.titleLarge) + Text( + text = viewModel.mainViewModel?.selected?.titleName ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(bottom = 12.dp) + ) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + // Import-Button + androidx.compose.material3.Button( + enabled = !saveImportBusy.value && !saveExportBusy.value && + (viewModel.mainViewModel?.selected?.titleId?.isNotEmpty() == true), + onClick = { + saveImportProgress.value = 0f + saveImportStatus.value = "" + importZipLauncher.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) + } + ) { Text("Import ZIP") } + + // Export-Button + androidx.compose.material3.Button( + enabled = !saveImportBusy.value && !saveExportBusy.value && + (viewModel.mainViewModel?.selected?.titleId?.isNotEmpty() == true), + onClick = { + val actLocal = activity + val tIdNow = viewModel.mainViewModel?.selected?.titleId.orEmpty() + if (actLocal != null && tIdNow.isNotEmpty()) { + val fname = suggestedCreateDocNameForExport(actLocal, tIdNow) + saveExportProgress.value = 0f + saveExportStatus.value = "" + exportZipLauncher.launch(fname) + } + } + ) { Text("Export ZIP") } + } + + if (saveImportBusy.value) { + Column(Modifier.padding(top = 12.dp)) { + androidx.compose.material3.LinearProgressIndicator(progress = { saveImportProgress.value }) + Text(saveImportStatus.value, modifier = Modifier.padding(top = 6.dp)) + } + } + if (saveExportBusy.value) { + Column(Modifier.padding(top = 12.dp)) { + androidx.compose.material3.LinearProgressIndicator(progress = { saveExportProgress.value }) + Text(saveExportStatus.value, modifier = Modifier.padding(top = 6.dp)) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + horizontalArrangement = Arrangement.End + ) { + androidx.compose.material3.TextButton( + onClick = { openSavesDialog.value = false } + ) { Text("Close") } + } + } + } + } + + // --- Shortcut-Dialog + if (showShortcutDialog.value) { + val gm = viewModel.mainViewModel?.selected + AlertDialog( + onDismissRequest = { showShortcutDialog.value = false }, + title = { Text("Create shortcut") }, + text = { + Column { + OutlinedTextField( + value = shortcutName.value, + onValueChange = { shortcutName.value = it }, + label = { Text("Name") }, + singleLine = true + ) + Text( + text = "Choose icon:", + modifier = Modifier.padding(top = 12.dp) + ) + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + TextButton(onClick = { + // App icon (Grid image) + if (gm != null && activity != null) { + val gameUri = resolveGameUri(gm) + if (gameUri != null) { + // persist rights for the game file + ShortcutUtils.persistReadWrite(activity, gameUri) + + val bmp = decodeGameIcon(gm) + val label = shortcutName.value.ifBlank { gm.titleName ?: "Start Game" } + + ShortcutUtils.pinShortcutForGame( + activity = activity, + gameUri = gameUri, + label = label, + iconBitmap = bmp + ) { } + showShortcutDialog.value = false + } else { + showShortcutDialog.value = false + } + } else { + showShortcutDialog.value = false + } + }) { Text("App icon") } + + TextButton(onClick = { + // Custom icon: open picker + pickImageLauncher.launch(arrayOf("image/*")) + showShortcutDialog.value = false + }) { Text("Custom icon") } + } + } + }, + confirmButton = { + TextButton(onClick = { showShortcutDialog.value = false }) { + Text("Close") + } + } + ) + } + // --- Version badge bottom left above the entire content VersionBadge( modifier = Modifier.align(Alignment.BottomStart) @@ -540,6 +1239,11 @@ class HomeViews { ) { thread { showLoading.value = true + + // NEW: Push Cheats vor dem Start + val tId = gameModel.titleId ?: "" + val act = viewModel.activity + val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false if (success == 1) { launchOnUiThread { viewModel.mainViewModel?.navigateToGame() } @@ -631,6 +1335,11 @@ class HomeViews { ) { thread { showLoading.value = true + + // NEW: Push Cheats vor dem Start + val tId = gameModel.titleId ?: "" + val act = viewModel.activity + val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false if (success == 1) { launchOnUiThread { viewModel.mainViewModel?.navigateToGame() } diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/SettingViews.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/SettingViews.kt index 2d0f4f565..1b5189d4f 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/SettingViews.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/SettingViews.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.MailOutline import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material.icons.outlined.FileOpen +import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Memory import androidx.compose.material.icons.outlined.Panorama import androidx.compose.material.icons.outlined.Settings @@ -47,6 +48,7 @@ import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -63,6 +65,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.concurrent.thread +import kotlin.math.roundToInt import org.kenjinx.android.MainActivity import org.kenjinx.android.providers.DocumentProvider import org.kenjinx.android.viewmodels.DataImportState @@ -70,8 +73,8 @@ import org.kenjinx.android.viewmodels.DataResetState import org.kenjinx.android.viewmodels.FirmwareInstallState import org.kenjinx.android.viewmodels.KeyInstallState import org.kenjinx.android.viewmodels.MainViewModel -import org.kenjinx.android.viewmodels.MemoryConfiguration import org.kenjinx.android.viewmodels.SettingsViewModel +import org.kenjinx.android.viewmodels.MemoryConfiguration import org.kenjinx.android.viewmodels.MemoryManagerMode import org.kenjinx.android.viewmodels.VSyncMode import org.kenjinx.android.widgets.ActionButton @@ -89,6 +92,11 @@ import org.kenjinx.android.viewmodels.QuickSettings.OverlayMenuPosition // ← N import org.kenjinx.android.SystemLanguage import org.kenjinx.android.RegionCode +import org.kenjinx.android.viewmodels.QuickSettings.VirtualControllerPreset + +// >>> NEU: KenjinxNative für das Live-Umschalten des Threading-Backends +import org.kenjinx.android.KenjinxNative + class SettingViews { companion object { const val EXPANSTION_TRANSITION_DURATION = 450 @@ -129,8 +137,17 @@ class SettingViews { val isGrid = remember { mutableStateOf(true) } val useSwitchLayout = remember { mutableStateOf(true) } val enableMotion = remember { mutableStateOf(true) } + val vcPreset = remember { + mutableStateOf(QuickSettings(mainViewModel.activity).virtualControllerPreset) + } val enablePerformanceMode = remember { mutableStateOf(true) } val controllerStickSensitivity = remember { mutableFloatStateOf(1.0f) } + + // --- NEU: Controller Scale (0.5 .. 1.5), direkt aus QuickSettings laden + val controllerScale = remember { + mutableFloatStateOf(QuickSettings(mainViewModel.activity).controllerScale) + } + val enableStubLogs = remember { mutableStateOf(true) } val enableInfoLogs = remember { mutableStateOf(true) } val enableWarningLogs = remember { mutableStateOf(true) } @@ -159,6 +176,12 @@ class SettingViews { mutableFloatStateOf(QuickSettings(mainViewModel.activity).overlayMenuOpacity.coerceIn(0f, 1f)) } + // --- NEU: Disable Threaded Rendering (aus QuickSettings laden) + val disableThreadedRendering = remember { + mutableStateOf(QuickSettings(mainViewModel.activity).disableThreadedRendering) + } + val threadToggleInitialized = remember { mutableStateOf(false) } + if (!loaded.value) { settingsViewModel.initializeState( memoryManagerMode, @@ -243,6 +266,7 @@ class SettingViews { regionCode ) + // Controller Scale wird separat direkt in QuickSettings gespeichert. if (!isNavigating.value) { isNavigating.value = true mainViewModel.navController?.popBackStack() @@ -421,6 +445,23 @@ class SettingViews { isFullWidth = false, ) } + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ){ + ActionButton( + onClick = { + settingsViewModel.openUpdatesFolder() + }, + text = "Select Updates/DLC Folder", + icon = Icons.Outlined.Folder, + modifier = Modifier.weight(1f), + isFullWidth = false, + ) + } Row( modifier = Modifier .fillMaxWidth() @@ -1224,6 +1265,79 @@ class SettingViews { Column(modifier = Modifier.fillMaxWidth()) { useVirtualController.SwitchSelector(label = "Use Virtual Controller") useSwitchLayout.SwitchSelector(label = "Use Switch Controller Layout") + if (useVirtualController.value) { + VirtualControllerPresetDropdown( + selectedPreset = vcPreset.value, + onPresetSelected = { preset -> + vcPreset.value = preset + val qs = QuickSettings(mainViewModel.activity) + qs.virtualControllerPreset = preset + qs.save() // sofort persistieren + } + ) + + // --- NEU: Controller Scale Slider (0.5x .. 1.5x) --- + val interactionSourceScale: MutableInteractionSource = remember { MutableInteractionSource() } + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Controller Scale (${(controllerScale.floatValue * 100f).roundToInt()}%)", + modifier = Modifier.align(Alignment.CenterVertically) + ) + Slider( + modifier = Modifier.width(250.dp), + value = controllerScale.floatValue, + onValueChange = { v -> + val clamped = v.coerceIn(0.5f, 1.5f) + controllerScale.floatValue = clamped + // Live auf den aktiven Virtual Controller anwenden (per Reflection) + mainViewModel.controller?.let { c -> + try { + val m = c::class.java.getMethod("setScale", Float::class.javaPrimitiveType) + m.invoke(c, clamped) + } catch (_: Throwable) { /* ignorieren, falls Layout ohne setScale */ } + } + }, + valueRange = 0.5f..1.5f, + steps = 19, + interactionSource = interactionSourceScale, + thumb = { + Label( + label = { + PlainTooltip( + modifier = Modifier + .sizeIn(45.dp, 25.dp) + .wrapContentWidth() + ) { + Text("${(controllerScale.floatValue * 100f).roundToInt()}%") + } + }, + interactionSource = interactionSourceScale + ) { + Icon( + imageVector = org.kenjinx.android.Icons.circle( + color = MaterialTheme.colorScheme.primary + ), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onValueChangeFinished = { + // Persistieren + val qs = QuickSettings(mainViewModel.activity) + qs.controllerScale = controllerScale.floatValue.coerceIn(0.5f, 1.5f) + qs.save() + } + ) + } + } val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } @@ -1321,6 +1435,34 @@ class SettingViews { enableShaderCache.SwitchSelector(label = "Shader Cache") enableTextureRecompression.SwitchSelector(label = "Texture Recompression") enableMacroHLE.SwitchSelector(label = "Macro HLE") + + // --- NEU: Toggle für Single-Thread-Renderer + disableThreadedRendering.SwitchSelector(label = "Disable Threaded Rendering") + + // Reaktion auf Toggle: persistieren + sanftes Reconfigure + LaunchedEffect(disableThreadedRendering.value) { + if (!threadToggleInitialized.value) { + threadToggleInitialized.value = true + } else { + val qs = QuickSettings(mainViewModel.activity) + qs.disableThreadedRendering = disableThreadedRendering.value + qs.save() + + val mode = if (disableThreadedRendering.value) 1 /*Single*/ else 2 /*Threaded*/ + + thread { + try { + KenjinxNative.graphicsSetPresentEnabled(false) + KenjinxNative.deviceWaitForGpuDone(500) + KenjinxNative.graphicsSetBackendThreading(mode) + KenjinxNative.deviceRecreateSwapchain() + } finally { + KenjinxNative.graphicsSetPresentEnabled(true) + } + } + } + } + stretchToFullscreen.SwitchSelector(label = "Stretch to Fullscreen") ResolutionScaleDropdown( selectedScale = resScale.floatValue, @@ -1503,8 +1645,6 @@ class SettingViews { ) } - // ---- Existing dropdowns ---- - // ---- Dropdown for orientation ---- @Composable fun OrientationDropdown( @@ -1532,7 +1672,38 @@ class SettingViews { ) } - // ---- Existing dropdowns ---- + @Composable + fun VirtualControllerPresetDropdown( + selectedPreset: VirtualControllerPreset, + onPresetSelected: (VirtualControllerPreset) -> Unit + ) { + val options = listOf( + VirtualControllerPreset.Default, + VirtualControllerPreset.Layout2, + VirtualControllerPreset.Layout3, + VirtualControllerPreset.Layout4, + VirtualControllerPreset.Layout5, + VirtualControllerPreset.Layout6 + ) + + DropdownSelector( + label = "Controller Layout", + selectedValue = selectedPreset, + options = options, + getDisplayText = { opt -> + when (opt) { + VirtualControllerPreset.Default -> "Default" + VirtualControllerPreset.Layout2 -> "Layout 2" + VirtualControllerPreset.Layout3 -> "Layout 3" + VirtualControllerPreset.Layout4 -> "Layout 4" + VirtualControllerPreset.Layout5 -> "Layout 5" + VirtualControllerPreset.Layout6 -> "Layout 6" + } + }, + onOptionSelected = onPresetSelected + ) + } + @Composable fun MemoryModeDropdown( diff --git a/src/LibKenjinx/Android/JniExportedMethods.cs b/src/LibKenjinx/Android/JniExportedMethods.cs index 9d64bdba2..23e7d4a5e 100644 --- a/src/LibKenjinx/Android/JniExportedMethods.cs +++ b/src/LibKenjinx/Android/JniExportedMethods.cs @@ -16,6 +16,7 @@ using System.Collections.Generic; using System.Linq; using System.Numerics; using System.Runtime.InteropServices; +using Ryujinx.Graphics.Vulkan; namespace LibKenjinx { @@ -34,6 +35,254 @@ namespace LibKenjinx public static VulkanLoader? VulkanLoader { get; private set; } + // ==== Audio Foreground/Background State (reflection-safe) ==== + private static bool _audioPaused = false; + private static bool _audioMuted = false; + + // strong reference if OpenAL backend is used + private static OpenALHardwareDeviceDriver? _openAl; + + // ---------- helpers for broad reflection coverage ---------- + private static readonly string[] PauseMethodCandidates = + { + "Pause", "SetPaused", "SetPause", "PauseAll", "RequestPause", + "SetIsPaused", "SetPauseState", "Suspend", "SetSuspended", + "PauseEmulation", "SetEmulationPaused", "SetRunning" // some implementations invert bool + }; + + private static readonly string[] VolumeMethodCandidates = + { + "SetVolumeMultiplier", "SetVolume", "SetMasterVolume", "SetGain" + }; + + private static readonly string[] VolumePropertyCandidates = + { + "VolumeMultiplier", "MasterVolume", "Volume", "Gain", "OutputVolume" + }; + + // Try call method with single bool + private static bool TryCallBool(object target, string[] names, bool arg) + { + if (target == null) return false; + var t = target.GetType(); + foreach (var name in names) + { + var m = t.GetMethod(name, new[] { typeof(bool) }); + if (m != null) + { + try { m.Invoke(target, new object[] { arg }); return true; } catch { } + } + } + return false; + } + + // Try call method with single float + private static bool TryCallFloat(object target, string[] names, float arg) + { + if (target == null) return false; + var t = target.GetType(); + foreach (var name in names) + { + var m = t.GetMethod(name, new[] { typeof(float) }); + if (m != null) + { + try { m.Invoke(target, new object[] { arg }); return true; } catch { } + } + } + return false; + } + + // Try set property (float/double) + private static bool TrySetFloatProp(object target, string[] names, float value) + { + if (target == null) return false; + var t = target.GetType(); + foreach (var name in names) + { + var p = t.GetProperty(name); + if (p != null && p.CanWrite) + { + try + { + object v = value; + if (p.PropertyType == typeof(double)) v = (double)value; + p.SetValue(target, v); + return true; + } + catch { } + } + } + return false; + } + + // Try set property (bool) for Mute/IsMuted etc. + private static bool TrySetBoolProp(object target, params string[] names) + { + if (target == null) return false; + var t = target.GetType(); + foreach (var name in names) + { + var p = t.GetProperty(name); + if (p != null && p.CanWrite && p.PropertyType == typeof(bool)) + { + try { p.SetValue(target, true); return true; } catch { } + } + } + return false; + } + + // breadth-first walk through "audio-ish" objects accessible from a root + private static IEnumerable WalkAudioObjects(object? root, int depth = 2) + { + if (root == null || depth < 0) yield break; + + yield return root; + + var t = root.GetType(); + var props = t.GetProperties(); + foreach (var p in props) + { + object? val = null; + try { val = p.GetValue(root); } catch { /* ignore */ } + + if (val == null) continue; + + // Prefer properties that look audio-related or are common containers + if (p.Name.IndexOf("Audio", StringComparison.OrdinalIgnoreCase) >= 0 || + p.Name.IndexOf("Sound", StringComparison.OrdinalIgnoreCase) >= 0 || + p.Name.IndexOf("Mixer", StringComparison.OrdinalIgnoreCase) >= 0 || + p.Name.IndexOf("Output", StringComparison.OrdinalIgnoreCase) >= 0 || + p.PropertyType.Name.IndexOf("Audio", StringComparison.OrdinalIgnoreCase) >= 0) + { + foreach (var x in WalkAudioObjects(val, depth - 1)) + yield return x; + } + } + } + + /// + /// Attempts to pause the entire emulation (strongest guarantee to stop audio). + /// We probe several likely targets via reflection to be compatible across forks. + /// + private static bool TryPauseEmulation(bool pause) + { + int hits = 0; + + try + { + // 1) Try SwitchDevice wrapper itself + var dev = SwitchDevice; + if (dev != null && TryCallBool(dev, PauseMethodCandidates, pause)) hits++; + + // 2) Try underlying Switch or similar inner object + var inner = + dev?.GetType().GetProperty("Switch")?.GetValue(dev) ?? + dev?.GetType().GetProperty("Device")?.GetValue(dev); + if (inner != null && TryCallBool(inner, PauseMethodCandidates, pause)) hits++; + + // 3) Try EmulationContext and its "System" (kernel/front controller etc.) + var ctx = dev?.EmulationContext; + if (ctx != null) + { + if (TryCallBool(ctx, PauseMethodCandidates, pause)) hits++; + + var sys = ctx.GetType().GetProperty("System")?.GetValue(ctx); + if (sys != null && TryCallBool(sys, PauseMethodCandidates, pause)) hits++; + + // 4) walk all audio-ish descendants and try pause + foreach (var node in WalkAudioObjects(ctx, depth: 2)) + { + if (node != null && TryCallBool(node, PauseMethodCandidates, pause)) + hits++; + } + } + } + catch { /* ignore */ } + + if (hits > 0) + Logger.Info?.Print(LogClass.Application, $"[PauseGate] Emulation pause={pause} hits={hits}"); + + return hits > 0; + } + + /// + /// Applies paused/muted state. Tries many backends/locations to be + /// resilient across Ryujinx revisions. + /// + private static void ApplyAudioState() + { + bool shouldMute = _audioPaused || _audioMuted; + float vol = shouldMute ? 0f : 1f; + int hits = 0; + + // A) Direct: OpenAL driver (if used) + try + { + var oal = _openAl; + if (oal != null) + { + bool p = TryCallBool(oal, PauseMethodCandidates, _audioPaused); + bool v = TryCallFloat(oal, VolumeMethodCandidates, vol) || TrySetFloatProp(oal, VolumePropertyCandidates, vol); + if (p || v) { hits++; Logger.Trace?.Print(LogClass.Application, $"[AudioGate] OpenAL applied p={p} v={v} vol={vol}"); } + } + } + catch { } + + // B) EmulationContext managers (AudioRendererManager/AudioManager/AudioOutManager/…) + try + { + var ctx = SwitchDevice?.EmulationContext; + if (ctx != null) + { + foreach (var node in WalkAudioObjects(ctx, depth: 2)) + { + if (node == null) continue; + + bool p = TryCallBool(node, PauseMethodCandidates, _audioPaused); + bool v = TryCallFloat(node, VolumeMethodCandidates, vol) || TrySetFloatProp(node, VolumePropertyCandidates, vol); + + // also try boolean mute-style properties (IsMuted/Mute) + if (shouldMute) + v = v || TrySetBoolProp(node, "IsMuted", "Muted", "Mute"); + + if (p || v) hits++; + } + } + } + catch { } + + // C) Generic driver fallback (whatever AudioDriver actually is) + try + { + var drv = AudioDriver; + if (drv != null) + { + bool p = TryCallBool(drv, PauseMethodCandidates, _audioPaused); + bool v = TryCallFloat(drv, VolumeMethodCandidates, vol) || TrySetFloatProp(drv, VolumePropertyCandidates, vol); + if (shouldMute) v = v || TrySetBoolProp(drv, "IsMuted", "Muted", "Mute"); + + if (p || v) hits++; + } + } + catch { } + + Logger.Info?.Print(LogClass.Application, $"[AudioGate] applied (hits={hits}) paused={_audioPaused} muted={_audioMuted} → vol={vol}"); + + // D) fallback: try to pause whole emulation if audio controls not hit + if (hits == 0 && _audioPaused) + { + if (TryPauseEmulation(true)) + Logger.Info?.Print(LogClass.Application, "[AudioGate] escalated: Emulation paused"); + } + else if (hits == 0 && !_audioPaused) + { + // try resume if we previously paused emulation + if (TryPauseEmulation(false)) + Logger.Info?.Print(LogClass.Application, "[AudioGate] escalated: Emulation resumed"); + } + } + // ============================================================= + [DllImport("libkenjinxjni")] internal extern static void setRenderingThread(); @@ -96,6 +345,7 @@ namespace LibKenjinx debug_break(4); Logger.Trace?.Print(LogClass.Application, "Jni Function Call"); AudioDriver = new OpenALHardwareDeviceDriver(); + _openAl = AudioDriver as OpenALHardwareDeviceDriver; // <-- audio patch: keep a strong ref var timezone = Marshal.PtrToStringAnsi(timeZonePtr); return InitializeDevice((MemoryManagerMode)memoryManagerMode, @@ -316,7 +566,7 @@ namespace LibKenjinx Window = (nint*)_surfacePtr, }; - var result = surfaceExtension.CreateAndroidSurface(new Instance(instance), createInfo, null, out var surface); + var result = surfaceExtension.CreateAndroidSurface(new Instance(instance), in createInfo, null, out var surface); // If a rotation was applied before the surface was created → apply it now if (_window != 0 && _pendingRotationDegrees != -1) @@ -565,6 +815,22 @@ namespace LibKenjinx DeleteUser(userId); } + // --- Audio JNI Exports (Foreground/Background gating) --- + [UnmanagedCallersOnly(EntryPoint = "audioSetPaused")] + public static void JniAudioSetPaused(bool paused) + { + _audioPaused = paused; + ApplyAudioState(); + } + + [UnmanagedCallersOnly(EntryPoint = "audioSetMuted")] + public static void JniAudioSetMuted(bool muted) + { + _audioMuted = muted; + ApplyAudioState(); + } + // --------------------------------------------------------- + [UnmanagedCallersOnly(EntryPoint = "uiHandlerSetup")] public static void JniSetupUiHandler() { @@ -688,9 +954,111 @@ namespace LibKenjinx Logger.Error?.Print(LogClass.Application, $"deviceRecreateSwapchain failed: {ex}"); } } + + // ===== PresentAllowed / Surface Control (JNI) ===== + + // alias für ältere Aufrufe, falls vorhanden + [UnmanagedCallersOnly(EntryPoint = "graphicsRendererSetPresent")] + public static void JniGraphicsRendererSetPresent(bool enabled) + { + try + { + if (Renderer is VulkanRenderer vr) + { + vr.SetPresentEnabled(enabled); + Logger.Trace?.Print(LogClass.Application, $"[JNI] PresentEnabled = {enabled}"); + } + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Application, $"graphicsRendererSetPresent failed: {ex}"); + } + } + + // neuer Name: passt zu KenjinxNative.graphicsSetPresentEnabled(...) + [UnmanagedCallersOnly(EntryPoint = "graphicsSetPresentEnabled")] + public static void JniGraphicsSetPresentEnabled(bool enabled) + { + try + { + (Renderer as VulkanRenderer)?.SetPresentEnabled(enabled); + Logger.Trace?.Print(LogClass.Application, $"[JNI] graphicsSetPresentEnabled({enabled})"); + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Application, $"graphicsSetPresentEnabled failed: {ex}"); + } + } + + [UnmanagedCallersOnly(EntryPoint = "graphicsRendererRecreateSurface")] + public static void JniGraphicsRendererRecreateSurface() + { + try + { + _ = (Renderer as VulkanRenderer)?.RecreateSurface(); + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Application, $"graphicsRendererRecreateSurface failed: {ex}"); + } + } + + // von MainActivity/GameHost benutzt + [UnmanagedCallersOnly(EntryPoint = "reattachWindowIfReady")] + public static bool JniReattachWindowIfReady() + { + try + { + return (Renderer as VulkanRenderer)?.RecreateSurface() ?? false; + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Application, $"reattachWindowIfReady failed: {ex}"); + return false; + } + } + + [UnmanagedCallersOnly(EntryPoint = "detachWindow")] + public static void JniDetachWindow() + { + try + { + (Renderer as VulkanRenderer)?.ReleaseSurface(); + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Application, $"detachWindow failed: {ex}"); + } + } + + // ===== Amiibo JNI Exports ===== + [UnmanagedCallersOnly(EntryPoint = "amiiboLoadBin")] + public static bool JniAmiiboLoadBin(IntPtr dataPtr, int length) + { + if (dataPtr == IntPtr.Zero || length <= 0) return false; + try + { + byte[] buf = new byte[length]; + Marshal.Copy(dataPtr, buf, 0, length); + return AmiiboLoadFromBytes(buf); + } + catch + { + return false; + } + } + + [UnmanagedCallersOnly(EntryPoint = "amiiboClear")] + public static void JniAmiiboClear() + { + AmiiboClear(); + } + // ===== End Amiibo JNI Exports ===== + } internal static partial class Logcat + { [LibraryImport("liblog", StringMarshalling = StringMarshalling.Utf8)] private static partial void __android_log_print(LogLevel level, string? tag, string format, string args, IntPtr ptr); diff --git a/src/LibKenjinx/LibKenjinx.Graphics.cs b/src/LibKenjinx/LibKenjinx.Graphics.cs index 657a7d12e..3430ead1e 100644 --- a/src/LibKenjinx/LibKenjinx.Graphics.cs +++ b/src/LibKenjinx/LibKenjinx.Graphics.cs @@ -61,7 +61,16 @@ namespace LibKenjinx } else if (graphicsBackend == GraphicsBackend.Vulkan) { - Renderer = new VulkanRenderer(Vk.GetApi(), (instance, _) => new SurfaceKHR(createSurfaceFunc == null ? null : (ulong?)createSurfaceFunc(instance.Handle)), + // Prefer the platform-provided Vulkan loader (if present), fall back to default. + var api = VulkanLoader?.GetApi() ?? Vk.GetApi(); + + Renderer = new VulkanRenderer( + api, + (instance, _) => + { + // use provided CreateSurface delegate (Android path will create ANativeWindow surface) + return new SurfaceKHR(createSurfaceFunc == null ? null : (ulong?)createSurfaceFunc(instance.Handle)); + }, () => requiredExtensions, null); } @@ -146,9 +155,9 @@ namespace LibKenjinx } } - if (device.Gpu.Renderer is ThreadedRenderer threaded) + if (device.Gpu.Renderer is ThreadedRenderer tr) { - threaded.FlushThreadedCommands(); + tr.FlushThreadedCommands(); } _gpuDoneEvent.Set(); @@ -165,7 +174,7 @@ namespace LibKenjinx { void SetInfo(string status, float value) { - if(PlatformInfo.IsBionic) + if (PlatformInfo.IsBionic) { Interop.UpdateProgress(status, value); } @@ -178,7 +187,7 @@ namespace LibKenjinx switch (state) { case LoadState ptcState: - if (float.IsNaN((progress))) + if (float.IsNaN(progress)) progress = 0; switch (ptcState) @@ -216,6 +225,26 @@ namespace LibKenjinx { _swapBuffersCallback = swapBuffersCallback; } + + // ===== Convenience-Wrapper für Vulkan re-attach (von JNI nutzbar) ===== + public static bool TryReattachSurface() + { + if (Renderer is VulkanRenderer vr) + { + return vr.RecreateSurface(); + } + return false; + } + + public static void ReleaseRendererSurface() + { + (Renderer as VulkanRenderer)?.ReleaseSurface(); + } + + public static void SetPresentEnabled(bool enabled) + { + (Renderer as VulkanRenderer)?.SetPresentEnabled(enabled); + } } [StructLayout(LayoutKind.Sequential)] diff --git a/src/LibKenjinx/LibKenjinx.cs b/src/LibKenjinx/LibKenjinx.cs index 3f151cdbe..4933cae50 100644 --- a/src/LibKenjinx/LibKenjinx.cs +++ b/src/LibKenjinx/LibKenjinx.cs @@ -20,6 +20,7 @@ using Ryujinx.Common.Logging.Targets; using Ryujinx.Common.Utilities; using Ryujinx.Graphics.GAL.Multithreading; using Ryujinx.HLE; +using Ryujinx.HLE.Kenjinx; using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.HOS; using Ryujinx.HLE.HOS.Services.Account.Acc; @@ -690,9 +691,64 @@ namespace LibKenjinx uiHandler.SetResponse(isOkPressed, input); } } + + // ===== Amiibo Helpers (Kenjinx) ===== + public static bool AmiiboLoadFromBytes(byte[] data) + { + if (data == null || data.Length == 0) + { + Logger.Warning?.Print(LogClass.Service, "[Amiibo] Load aborted: empty data."); + return false; + } + + var dev = SwitchDevice?.EmulationContext; + if (dev == null) + { + Logger.Warning?.Print(LogClass.Service, "[Amiibo] Load aborted: no active EmulationContext."); + return false; + } + + try + { + var ok = AmiiboBridge.TryLoadVirtualAmiibo(dev, data, out string msg); + if (ok) + Logger.Info?.Print(LogClass.Service, $"[Amiibo] Loaded {data.Length} bytes. {msg}"); + else + Logger.Warning?.Print(LogClass.Service, $"[Amiibo] Injection failed. {msg}"); + return ok; + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Service, $"[Amiibo] Exception: {ex}"); + return false; + } + } + + public static void AmiiboClear() + { + var dev = SwitchDevice?.EmulationContext; + if (dev == null) + { + Logger.Warning?.Print(LogClass.Service, "[Amiibo] Clear aborted: no active EmulationContext."); + return; + } + + try + { + AmiiboBridge.ClearVirtualAmiibo(dev); + Logger.Info?.Print(LogClass.Service, "[Amiibo] Cleared."); + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Service, $"[Amiibo] Clear exception: {ex}"); + } + } + // ===== End Amiibo Helpers ===== + } public class SwitchDevice : IDisposable + { private readonly SystemVersion _firmwareVersion; public VirtualFileSystem VirtualFileSystem { get; set; } @@ -966,156 +1022,152 @@ namespace LibKenjinx .Replace("\n", "\\n"); } -/// -/// Updates .../save/titleid_map.ndjson in NDJSON format: -/// - Reads existing lines -/// - Replaces/adds entry for titleId -/// - Completely rewrites the file (no unlimited size increase) -/// - NEVER overwrites the folder with an empty value; attempts to determine it via markers -/// -private static void UpsertTitleMapNdjson(string savesRoot, string titleIdHex, string titleName, string createdFolder) -{ - Directory.CreateDirectory(savesRoot); - string mapPath = Path.Combine(savesRoot, "titleid_map.ndjson"); - - // titleId (lowercase) -> (Name, Folder, Timestamp) - var byTitleId = new Dictionary(StringComparer.OrdinalIgnoreCase); - - // Bestehende Datei einlesen - try - { - if (File.Exists(mapPath)) + /// + /// Updates .../save/titleid_map.ndjson in NDJSON format: + /// - Reads existing lines + /// - Replaces/adds entry for titleId + /// - Completely rewrites the file (no unlimited size increase) + /// - NEVER overwrites the folder with an empty value; attempts to determine it via markers + /// + private static void UpsertTitleMapNdjson(string savesRoot, string titleIdHex, string titleName, string createdFolder) { - foreach (var line in File.ReadLines(mapPath, Encoding.UTF8)) - { - if (string.IsNullOrWhiteSpace(line)) continue; + Directory.CreateDirectory(savesRoot); + string mapPath = Path.Combine(savesRoot, "titleid_map.ndjson"); - try - { - using var doc = JsonDocument.Parse(line); - var root = doc.RootElement; - - string tid = root.TryGetProperty("titleId", out var tidEl) ? (tidEl.GetString() ?? "").Trim() : ""; - if (string.IsNullOrEmpty(tid)) continue; - - string name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : ""; - string folder = root.TryGetProperty("folder", out var folderEl) ? (folderEl.GetString() ?? "") : ""; - string ts = root.TryGetProperty("timestamp", out var tsEl) ? (tsEl.GetString() ?? "") : ""; - - byTitleId[tid.ToLowerInvariant()] = (name, folder, ts); - } - catch - { - // Korrupten Eintrag ignorieren - } - } - } - } - catch - { - byTitleId.Clear(); - } - - var nowIso = DateTime.UtcNow.ToString("O"); - var titleIdLc = (titleIdHex ?? string.Empty).ToLowerInvariant(); - - // 1) Bestimme den "bestehenden" Ordner aus der Map (falls vorhanden) - byTitleId.TryGetValue(titleIdLc, out var existing); - string existingFolder = existing.Folder ?? ""; - - // 2) Versuche, einen sinnvollen Ordner zu bestimmen: - // a) frisch erstellter Ordnername - // b) per Markerdatei in den Saves ermitteln - // c) bisherigen (nicht-leeren) Wert beibehalten - string effectiveFolder = createdFolder; - if (string.IsNullOrWhiteSpace(effectiveFolder)) - { - effectiveFolder = ResolveSaveFolderByMarker(savesRoot, titleIdLc); - } - if (string.IsNullOrWhiteSpace(effectiveFolder) && !string.IsNullOrWhiteSpace(existingFolder)) - { - effectiveFolder = existingFolder; - } - - // 3) Markerdatei sicherstellen, falls Ordner ermittelt - try - { - if (!string.IsNullOrWhiteSpace(effectiveFolder)) - { - string markerPath = Path.Combine(savesRoot, effectiveFolder, "TITLEID.txt"); - if (!File.Exists(markerPath)) - { - Directory.CreateDirectory(Path.GetDirectoryName(markerPath)!); - File.WriteAllText(markerPath, $"{titleIdLc}\n{titleName ?? "Unknown"}"); - } - } - } - catch - { - // Marker-Erstellung darf das Gameplay nicht stören - } - - // 4) Upsert: niemals mit leerem Folder überschreiben - string finalName = string.IsNullOrWhiteSpace(titleName) ? (existing.Name ?? "") : titleName; - string finalFolder = string.IsNullOrWhiteSpace(effectiveFolder) ? (existing.Folder ?? "") : effectiveFolder; - string finalTs = nowIso; - - byTitleId[titleIdLc] = (finalName ?? "", finalFolder ?? "", finalTs); - - // 5) Datei vollständig neu schreiben (stabil: nach titleId sortiert) - try - { - var ordered = byTitleId - .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase) - .Select(kv => - $"{{\"titleId\":\"{EscapeJson(kv.Key)}\",\"name\":\"{EscapeJson(kv.Value.Name)}\"," + - $"\"folder\":\"{EscapeJson(kv.Value.Folder)}\",\"timestamp\":\"{EscapeJson(kv.Value.Timestamp)}\"}}{Environment.NewLine}" - ); - - File.WriteAllText(mapPath, string.Concat(ordered), Encoding.UTF8); - } - catch - { - // Schreibfehler stillschweigend ignorieren - } -} - -/// -/// Durchsucht alle Save-Ordner nach einer TITLEID.txt, deren erste Zeile der titleId entspricht. -/// Gibt den Ordnernamen (z.B. "00000012") zurück oder null. -/// -private static string ResolveSaveFolderByMarker(string savesRoot, string titleIdLc) -{ - try - { - if (!Directory.Exists(savesRoot)) return null; - - foreach (var dir in Directory.GetDirectories(savesRoot)) - { - string marker = Path.Combine(dir, "TITLEID.txt"); - if (!File.Exists(marker)) continue; + // titleId (lowercase) -> (Name, Folder, Timestamp) + var byTitleId = new Dictionary(StringComparer.OrdinalIgnoreCase); + // Read existing file try { - using var sr = new StreamReader(marker, Encoding.UTF8, true); - string first = sr.ReadLine()?.Trim()?.ToLowerInvariant(); - if (first == titleIdLc) + if (File.Exists(mapPath)) { - return Path.GetFileName(dir); + foreach (var line in File.ReadLines(mapPath, Encoding.UTF8)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + + try + { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + string tid = root.TryGetProperty("titleId", out var tidEl) ? (tidEl.GetString() ?? "").Trim() : ""; + if (string.IsNullOrEmpty(tid)) continue; + + string name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : ""; + string folder = root.TryGetProperty("folder", out var folderEl) ? (folderEl.GetString() ?? "") : ""; + string ts = root.TryGetProperty("timestamp", out var tsEl) ? (tsEl.GetString() ?? "") : ""; + + byTitleId[tid.ToLowerInvariant()] = (name, folder, ts); + } + catch + { + // ignore corrupt entry + } + } } } catch { - // ignorieren und weiter + byTitleId.Clear(); + } + + var nowIso = DateTime.UtcNow.ToString("O"); + var titleIdLc = (titleIdHex ?? string.Empty).ToLowerInvariant(); + + // 1) take existing folder if known + byTitleId.TryGetValue(titleIdLc, out var existing); + string existingFolder = existing.Folder ?? ""; + + // 2) figure out effective folder + string effectiveFolder = createdFolder; + if (string.IsNullOrWhiteSpace(effectiveFolder)) + { + effectiveFolder = ResolveSaveFolderByMarker(savesRoot, titleIdLc); + } + if (string.IsNullOrWhiteSpace(effectiveFolder) && !string.IsNullOrWhiteSpace(existingFolder)) + { + effectiveFolder = existingFolder; + } + + // 3) ensure marker in the folder + try + { + if (!string.IsNullOrWhiteSpace(effectiveFolder)) + { + string markerPath = Path.Combine(savesRoot, effectiveFolder, "TITLEID.txt"); + if (!File.Exists(markerPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(markerPath)!); + File.WriteAllText(markerPath, $"{titleIdLc}\n{titleName ?? "Unknown"}"); + } + } + } + catch + { + // ignore + } + + // 4) upsert (never overwrite with empty folder) + string finalName = string.IsNullOrWhiteSpace(titleName) ? (existing.Name ?? "") : titleName; + string finalFolder = string.IsNullOrWhiteSpace(effectiveFolder) ? (existing.Folder ?? "") : effectiveFolder; + string finalTs = nowIso; + + byTitleId[titleIdLc] = (finalName ?? "", finalFolder ?? "", finalTs); + + // 5) rewrite file (stable: sort by titleId) + try + { + var ordered = byTitleId + .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase) + .Select(kv => + $"{{\"titleId\":\"{EscapeJson(kv.Key)}\",\"name\":\"{EscapeJson(kv.Value.Name)}\"," + + $"\"folder\":\"{EscapeJson(kv.Value.Folder)}\",\"timestamp\":\"{EscapeJson(kv.Value.Timestamp)}\"}}{Environment.NewLine}" + ); + + File.WriteAllText(mapPath, string.Concat(ordered), Encoding.UTF8); + } + catch + { + // ignore write errors } } - } - catch - { - // ignorieren - } - return null; -} + + /// + /// Scans save subfolders for a TITLEID.txt whose first line equals the titleId; returns folder name or null. + /// + private static string ResolveSaveFolderByMarker(string savesRoot, string titleIdLc) + { + try + { + if (!Directory.Exists(savesRoot)) return null; + + foreach (var dir in Directory.GetDirectories(savesRoot)) + { + string marker = Path.Combine(dir, "TITLEID.txt"); + if (!File.Exists(marker)) continue; + + try + { + using var sr = new StreamReader(marker, Encoding.UTF8, true); + string first = sr.ReadLine()?.Trim()?.ToLowerInvariant(); + if (first == titleIdLc) + { + return Path.GetFileName(dir); + } + } + catch + { + // ignore and continue + } + } + } + catch + { + // ignore + } + return null; + } /// diff --git a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs index 380995bce..eeb561a09 100644 --- a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs +++ b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs @@ -28,6 +28,9 @@ namespace Ryujinx.Graphics.Vulkan private bool _initialized; + // JNI/Lifecycle-Flag + internal volatile bool PresentAllowed = true; + public uint ProgramCount { get; set; } = 0; internal FormatCapabilities FormatCapabilities { get; private set; } @@ -49,6 +52,9 @@ namespace Ryujinx.Graphics.Vulkan internal Lock BackgroundQueueLock { get; private set; } internal Lock QueueLock { get; private set; } + // NEU: SurfaceLock, um Create/Destroy/Queries zu serialisieren + internal Lock SurfaceLock { get; private set; } + internal MemoryAllocator MemoryAllocator { get; private set; } internal HostMemoryAllocator HostMemoryAllocator { get; private set; } internal CommandBufferPool CommandBufferPool { get; private set; } @@ -500,6 +506,15 @@ namespace Ryujinx.Graphics.Vulkan Queue = queue; QueueLock = new(); + // Init Locks + SurfaceLock = new(); + if (maxQueueCount >= 2) + { + Api.GetDeviceQueue(_device, queueFamilyIndex, 1, out var backgroundQueue); + BackgroundQueue = backgroundQueue; + BackgroundQueueLock = new(); + } + LoadFeatures(maxQueueCount, queueFamilyIndex); QueueFamilyIndex = queueFamilyIndex; @@ -551,7 +566,7 @@ namespace Ryujinx.Graphics.Vulkan public IProgram CreateProgram(ShaderSource[] sources, ShaderInfo info) { ProgramCount++; - + bool isCompute = sources.Length == 1 && sources[0].Stage == ShaderStage.Compute; if (info.State.HasValue || isCompute) @@ -1006,13 +1021,100 @@ namespace Ryujinx.Graphics.Vulkan return !(IsMoltenVk || IsQualcommProprietary); } - internal unsafe void RecreateSurface() + // ===== Surface/Present Lifecycle helpers ===== + + public unsafe bool RecreateSurface() { - SurfaceApi.DestroySurface(_instance.Instance, _surface, null); + if (!PresentAllowed) + { + return false; + } - _surface = _getSurface(_instance.Instance, Api); + lock (SurfaceLock) + { + try + { + if (_surface.Handle != 0) + { + SurfaceApi.DestroySurface(_instance.Instance, _surface, null); + _surface = new SurfaceKHR(0); + } - (_window as Window)?.SetSurface(_surface); + _surface = _getSurface(_instance.Instance, Api); + if (_surface.Handle == 0) + { + return false; + } + + ( _window as Window )?.SetSurface(_surface); + ( _window as Window )?.SetSurfaceQueryAllowed(true); + return true; + } + catch + { + // retry später + return false; + } + } + } + + public unsafe void ReleaseSurface() + { + lock (SurfaceLock) + { + try + { + ( _window as Window )?.SetSurfaceQueryAllowed(false); + + if (_surface.Handle != 0) + { + SurfaceApi.DestroySurface(_instance.Instance, _surface, null); + _surface = new SurfaceKHR(0); + } + } + catch + { + // still + } + + ( _window as Window )?.OnSurfaceLost(); + } + } + + public void SetPresentEnabled(bool enabled) + { + PresentAllowed = enabled; + + if (!enabled) + { + ( _window as Window )?.SetSurfaceQueryAllowed(false); + ReleaseSurface(); + } + else + { + _ = RecreateSurface(); + } + } + + public void SetPresentAllowed(bool allowed) + { + PresentAllowed = allowed; + Logger.Trace?.Print(LogClass.Gpu, $"PresentAllowed={allowed}"); + + if (allowed) + { + try + { + ( _window as Window )?.SetSurfaceQueryAllowed(true); + _window?.SetSize(0, 0); + _ = RecreateSurface(); + } + catch { } + } + else + { + ( _window as Window )?.SetSurfaceQueryAllowed(false); + } } public unsafe void Dispose() @@ -1034,23 +1136,15 @@ namespace Ryujinx.Graphics.Vulkan MemoryAllocator.Dispose(); - foreach (var shader in Shaders) - { - shader.Dispose(); - } + foreach (var shader in Shaders) shader.Dispose(); + foreach (var texture in Textures) texture.Release(); + foreach (var sampler in Samplers) sampler.Dispose(); - foreach (var texture in Textures) + if (_surface.Handle != 0) { - texture.Release(); + SurfaceApi.DestroySurface(_instance.Instance, _surface, null); } - foreach (var sampler in Samplers) - { - sampler.Dispose(); - } - - SurfaceApi.DestroySurface(_instance.Instance, _surface, null); - Api.DestroyDevice(_device, null); _debugMessenger.Dispose(); diff --git a/src/Ryujinx.Graphics.Vulkan/Window.cs b/src/Ryujinx.Graphics.Vulkan/Window.cs index b3871ec09..271fffbfa 100644 --- a/src/Ryujinx.Graphics.Vulkan/Window.cs +++ b/src/Ryujinx.Graphics.Vulkan/Window.cs @@ -43,6 +43,9 @@ namespace Ryujinx.Graphics.Vulkan private ScalingFilter _currentScalingFilter; private bool _colorSpacePassthroughEnabled; + // Gate für alle vk*Surface*-Queries + private volatile bool _allowSurfaceQueries = true; + public unsafe Window(VulkanRenderer gd, SurfaceKHR surface, PhysicalDevice physicalDevice, Device device) { _gd = gd; @@ -50,160 +53,270 @@ namespace Ryujinx.Graphics.Vulkan _device = device; _surface = surface; - CreateSwapchain(); + if (_gd.PresentAllowed && _surface.Handle != 0) + { + CreateSwapchain(); + } + else + { + _swapchainIsDirty = true; + } + } + + public void SetSurfaceQueryAllowed(bool allowed) => _allowSurfaceQueries = allowed; + private bool CanQuerySurface() => _allowSurfaceQueries && _gd.PresentAllowed && _surface.Handle != 0; + + private unsafe bool TryGetSurfaceCapabilities(out SurfaceCapabilitiesKHR caps) + { + caps = default; + if (!CanQuerySurface()) return false; + var res = _gd.SurfaceApi.GetPhysicalDeviceSurfaceCapabilities(_physicalDevice, _surface, out caps); + return res == Result.Success; + } + + private unsafe bool TryGetSurfaceFormats(out SurfaceFormatKHR[] formats) + { + formats = Array.Empty(); + if (!CanQuerySurface()) return false; + + uint count = 0; + var res = _gd.SurfaceApi.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, &count, null); + if (res != Result.Success || count == 0) return false; + + formats = new SurfaceFormatKHR[count]; + fixed (SurfaceFormatKHR* p = formats) + { + if (_gd.SurfaceApi.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, &count, p) != Result.Success) + return false; + } + return true; + } + + private unsafe bool TryGetPresentModes(out PresentModeKHR[] modes) + { + modes = Array.Empty(); + if (!CanQuerySurface()) return false; + + uint count = 0; + var res = _gd.SurfaceApi.GetPhysicalDeviceSurfacePresentModes(_physicalDevice, _surface, &count, null); + if (res != Result.Success || count == 0) return false; + + modes = new PresentModeKHR[count]; + fixed (PresentModeKHR* p = modes) + { + if (_gd.SurfaceApi.GetPhysicalDeviceSurfacePresentModes(_physicalDevice, _surface, &count, p) != Result.Success) + return false; + } + return true; } private void RecreateSwapchain() { - var oldSwapchain = _swapchain; - _swapchainIsDirty = false; - - for (int i = 0; i < _swapchainImageViews.Length; i++) + if (!_gd.PresentAllowed || _surface.Handle == 0 || !CanQuerySurface()) { - _swapchainImageViews[i].Dispose(); + _swapchainIsDirty = true; + return; } - // Destroy old Swapchain. - - _gd.Api.DeviceWaitIdle(_device); - - unsafe + lock (_gd.SurfaceLock) { - for (int i = 0; i < _imageAvailableSemaphores.Length; i++) + var oldSwapchain = _swapchain; + _swapchainIsDirty = false; + + if (_swapchainImageViews != null) { - _gd.Api.DestroySemaphore(_device, _imageAvailableSemaphores[i], null); + for (int i = 0; i < _swapchainImageViews.Length; i++) + { + _swapchainImageViews[i]?.Dispose(); + } } - for (int i = 0; i < _renderFinishedSemaphores.Length; i++) + _gd.Api.DeviceWaitIdle(_device); + + unsafe { - _gd.Api.DestroySemaphore(_device, _renderFinishedSemaphores[i], null); + if (_imageAvailableSemaphores != null) + { + for (int i = 0; i < _imageAvailableSemaphores.Length; i++) + { + if (_imageAvailableSemaphores[i].Handle != 0) + { + _gd.Api.DestroySemaphore(_device, _imageAvailableSemaphores[i], null); + } + } + } + + if (_renderFinishedSemaphores != null) + { + for (int i = 0; i < _renderFinishedSemaphores.Length; i++) + { + if (_renderFinishedSemaphores[i].Handle != 0) + { + _gd.Api.DestroySemaphore(_device, _renderFinishedSemaphores[i], null); + } + } + } } + + if (oldSwapchain.Handle != 0) + { + _gd.SwapchainApi.DestroySwapchain(_device, oldSwapchain, Span.Empty); + } + + CreateSwapchain(); } - - _gd.SwapchainApi.DestroySwapchain(_device, oldSwapchain, Span.Empty); - - CreateSwapchain(); } internal void SetSurface(SurfaceKHR surface) { - _surface = surface; - RecreateSwapchain(); + lock (_gd.SurfaceLock) + { + _surface = surface; + + if (!_gd.PresentAllowed || _surface.Handle == 0) + { + _swapchainIsDirty = true; + return; + } + + SetSurfaceQueryAllowed(true); + RecreateSwapchain(); + } } private unsafe void CreateSwapchain() { - _gd.SurfaceApi.GetPhysicalDeviceSurfaceCapabilities(_physicalDevice, _surface, out var capabilities); - - uint surfaceFormatsCount; - - _gd.SurfaceApi.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, &surfaceFormatsCount, null); - - var surfaceFormats = new SurfaceFormatKHR[surfaceFormatsCount]; - - fixed (SurfaceFormatKHR* pSurfaceFormats = surfaceFormats) + if (!_gd.PresentAllowed || _surface.Handle == 0 || !CanQuerySurface()) { - _gd.SurfaceApi.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, &surfaceFormatsCount, pSurfaceFormats); + _swapchainIsDirty = true; + return; } - uint presentModesCount; - - _gd.SurfaceApi.GetPhysicalDeviceSurfacePresentModes(_physicalDevice, _surface, &presentModesCount, null); - - var presentModes = new PresentModeKHR[presentModesCount]; - - fixed (PresentModeKHR* pPresentModes = presentModes) + lock (_gd.SurfaceLock) { - _gd.SurfaceApi.GetPhysicalDeviceSurfacePresentModes(_physicalDevice, _surface, &presentModesCount, pPresentModes); - } + if (!TryGetSurfaceCapabilities(out var capabilities)) + { + _swapchainIsDirty = true; + return; + } - uint imageCount = capabilities.MinImageCount + 1; - if (capabilities.MaxImageCount > 0 && imageCount > capabilities.MaxImageCount) - { - imageCount = capabilities.MaxImageCount; - } + if (!TryGetSurfaceFormats(out var surfaceFormats)) + { + _swapchainIsDirty = true; + return; + } - var surfaceFormat = ChooseSwapSurfaceFormat(surfaceFormats, _colorSpacePassthroughEnabled); + if (!TryGetPresentModes(out var presentModes)) + { + _swapchainIsDirty = true; + return; + } - var extent = ChooseSwapExtent(capabilities); + uint imageCount = capabilities.MinImageCount + 1; + if (capabilities.MaxImageCount > 0 && imageCount > capabilities.MaxImageCount) + { + imageCount = capabilities.MaxImageCount; + } - _width = (int)extent.Width; - _height = (int)extent.Height; - _format = surfaceFormat.Format; + var surfaceFormat = ChooseSwapSurfaceFormat(surfaceFormats, _colorSpacePassthroughEnabled); + var extent = ChooseSwapExtent(capabilities); - var oldSwapchain = _swapchain; + // Guard gegen 0x0-Extent direkt nach Resume + if (extent.Width == 0 || extent.Height == 0) + { + _swapchainIsDirty = true; + return; + } - CurrentTransform = capabilities.CurrentTransform; + _width = (int)extent.Width; + _height = (int)extent.Height; + _format = surfaceFormat.Format; - var swapchainCreateInfo = new SwapchainCreateInfoKHR - { - SType = StructureType.SwapchainCreateInfoKhr, - Surface = _surface, - MinImageCount = imageCount, - ImageFormat = surfaceFormat.Format, - ImageColorSpace = surfaceFormat.ColorSpace, - ImageExtent = extent, - ImageUsage = ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferDstBit | (PlatformInfo.IsBionic ? 0 : ImageUsageFlags.StorageBit), - ImageSharingMode = SharingMode.Exclusive, - ImageArrayLayers = 1, - PreTransform = PlatformInfo.IsBionic ? SurfaceTransformFlagsKHR.IdentityBitKhr : capabilities.CurrentTransform, - CompositeAlpha = ChooseCompositeAlpha(capabilities.SupportedCompositeAlpha), - PresentMode = ChooseSwapPresentMode(presentModes, _vSyncMode), - Clipped = true, - }; + var oldSwapchain = _swapchain; - var textureCreateInfo = new TextureCreateInfo( - _width, - _height, - 1, - 1, - 1, - 1, - 1, - 1, - FormatTable.GetFormat(surfaceFormat.Format), - DepthStencilMode.Depth, - Target.Texture2D, - SwizzleComponent.Red, - SwizzleComponent.Green, - SwizzleComponent.Blue, - SwizzleComponent.Alpha); + CurrentTransform = capabilities.CurrentTransform; - _gd.SwapchainApi.CreateSwapchain(_device, in swapchainCreateInfo, null, out _swapchain).ThrowOnError(); + var usage = ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferDstBit; + if (!PlatformInfo.IsBionic) + { + usage |= ImageUsageFlags.StorageBit; // nur Desktop erlaubt Storage für swapchain + } - _gd.SwapchainApi.GetSwapchainImages(_device, _swapchain, &imageCount, null); + // Auf Android: Identity; sonst der vom Treiber empfohlene CurrentTransform + var preTransform = PlatformInfo.IsBionic + ? SurfaceTransformFlagsKHR.IdentityBitKhr + : capabilities.CurrentTransform; - _swapchainImages = new Image[imageCount]; + var swapchainCreateInfo = new SwapchainCreateInfoKHR + { + SType = StructureType.SwapchainCreateInfoKhr, + Surface = _surface, + MinImageCount = imageCount, + ImageFormat = surfaceFormat.Format, + ImageColorSpace = surfaceFormat.ColorSpace, + ImageExtent = extent, + ImageUsage = usage, + ImageSharingMode = SharingMode.Exclusive, + ImageArrayLayers = 1, + PreTransform = preTransform, + CompositeAlpha = ChooseCompositeAlpha(capabilities.SupportedCompositeAlpha), + PresentMode = ChooseSwapPresentMode(presentModes, _vSyncMode), + Clipped = true, + }; - fixed (Image* pSwapchainImages = _swapchainImages) - { - _gd.SwapchainApi.GetSwapchainImages(_device, _swapchain, &imageCount, pSwapchainImages); - } + var textureCreateInfo = new TextureCreateInfo( + _width, + _height, + 1, + 1, + 1, + 1, + 1, + 1, + FormatTable.GetFormat(surfaceFormat.Format), + DepthStencilMode.Depth, + Target.Texture2D, + SwizzleComponent.Red, + SwizzleComponent.Green, + SwizzleComponent.Blue, + SwizzleComponent.Alpha); - _swapchainImageViews = new TextureView[imageCount]; + _gd.SwapchainApi.CreateSwapchain(_device, in swapchainCreateInfo, null, out _swapchain).ThrowOnError(); - for (int i = 0; i < _swapchainImageViews.Length; i++) - { - _swapchainImageViews[i] = CreateSwapchainImageView(_swapchainImages[i], surfaceFormat.Format, textureCreateInfo); - } + _gd.SwapchainApi.GetSwapchainImages(_device, _swapchain, &imageCount, null); - var semaphoreCreateInfo = new SemaphoreCreateInfo - { - SType = StructureType.SemaphoreCreateInfo, - }; + _swapchainImages = new Image[imageCount]; - _imageAvailableSemaphores = new Semaphore[imageCount]; + fixed (Image* pSwapchainImages = _swapchainImages) + { + _gd.SwapchainApi.GetSwapchainImages(_device, _swapchain, &imageCount, pSwapchainImages); + } - for (int i = 0; i < _imageAvailableSemaphores.Length; i++) - { - _gd.Api.CreateSemaphore(_device, in semaphoreCreateInfo, null, out _imageAvailableSemaphores[i]).ThrowOnError(); - } + _swapchainImageViews = new TextureView[imageCount]; - _renderFinishedSemaphores = new Semaphore[imageCount]; + for (int i = 0; i < _swapchainImageViews.Length; i++) + { + _swapchainImageViews[i] = CreateSwapchainImageView(_swapchainImages[i], surfaceFormat.Format, textureCreateInfo); + } - for (int i = 0; i < _renderFinishedSemaphores.Length; i++) - { - _gd.Api.CreateSemaphore(_device, in semaphoreCreateInfo, null, out _renderFinishedSemaphores[i]).ThrowOnError(); + var semaphoreCreateInfo = new SemaphoreCreateInfo + { + SType = StructureType.SemaphoreCreateInfo, + }; + + _imageAvailableSemaphores = new Semaphore[imageCount]; + + for (int i = 0; i < _imageAvailableSemaphores.Length; i++) + { + _gd.Api.CreateSemaphore(_device, in semaphoreCreateInfo, null, out _imageAvailableSemaphores[i]).ThrowOnError(); + } + + _renderFinishedSemaphores = new Semaphore[imageCount]; + + for (int i = 0; i < _renderFinishedSemaphores.Length; i++) + { + _gd.Api.CreateSemaphore(_device, in semaphoreCreateInfo, null, out _renderFinishedSemaphores[i]).ThrowOnError(); + } } } @@ -236,40 +349,25 @@ namespace Ryujinx.Graphics.Vulkan private static SurfaceFormatKHR ChooseSwapSurfaceFormat(SurfaceFormatKHR[] availableFormats, bool colorSpacePassthroughEnabled) { + if (availableFormats == null || availableFormats.Length == 0) + { + return new SurfaceFormatKHR(VkFormat.B8G8R8A8Unorm, (ColorSpaceKHR)0); + } + if (availableFormats.Length == 1 && availableFormats[0].Format == VkFormat.Undefined) { - return new SurfaceFormatKHR(VkFormat.B8G8R8A8Unorm, ColorSpaceKHR.PaceSrgbNonlinearKhr); + return new SurfaceFormatKHR(VkFormat.B8G8R8A8Unorm, availableFormats[0].ColorSpace); } - var formatToReturn = availableFormats[0]; - if (colorSpacePassthroughEnabled) + foreach (var f in availableFormats) { - foreach (var format in availableFormats) + if (f.Format == VkFormat.B8G8R8A8Unorm) { - if (format.Format == VkFormat.B8G8R8A8Unorm && format.ColorSpace == ColorSpaceKHR.SpacePassThroughExt) - { - formatToReturn = format; - break; - } - else if (format.Format == VkFormat.B8G8R8A8Unorm && format.ColorSpace == ColorSpaceKHR.PaceSrgbNonlinearKhr) - { - formatToReturn = format; - } - } - } - else - { - foreach (var format in availableFormats) - { - if (format.Format == VkFormat.B8G8R8A8Unorm && format.ColorSpace == ColorSpaceKHR.PaceSrgbNonlinearKhr) - { - formatToReturn = format; - break; - } + return f; } } - return formatToReturn; + return availableFormats[0]; } private static CompositeAlphaFlagsKHR ChooseCompositeAlpha(CompositeAlphaFlagsKHR supportedFlags) @@ -278,13 +376,11 @@ namespace Ryujinx.Graphics.Vulkan { return CompositeAlphaFlagsKHR.OpaqueBitKhr; } - else if (supportedFlags.HasFlag(CompositeAlphaFlagsKHR.PreMultipliedBitKhr)) - { - return CompositeAlphaFlagsKHR.PreMultipliedBitKhr; - } else { - return CompositeAlphaFlagsKHR.InheritBitKhr; + return supportedFlags.HasFlag(CompositeAlphaFlagsKHR.PreMultipliedBitKhr) + ? CompositeAlphaFlagsKHR.PreMultipliedBitKhr + : CompositeAlphaFlagsKHR.InheritBitKhr; } } @@ -294,13 +390,9 @@ namespace Ryujinx.Graphics.Vulkan { return PresentModeKHR.ImmediateKhr; } - else if (availablePresentModes.Contains(PresentModeKHR.MailboxKhr)) - { - return PresentModeKHR.MailboxKhr; - } else { - return PresentModeKHR.FifoKhr; + return availablePresentModes.Contains(PresentModeKHR.MailboxKhr) ? PresentModeKHR.MailboxKhr : PresentModeKHR.FifoKhr; } } @@ -319,6 +411,37 @@ namespace Ryujinx.Graphics.Vulkan public unsafe override void Present(ITexture texture, ImageCrop crop, Action swapBuffersCallback) { + // Falls Surface bereits neu ist, Queries aber noch gesperrt → freigeben. + if (!_allowSurfaceQueries && _surface.Handle != 0) + { + _allowSurfaceQueries = true; + } + + if (!_gd.PresentAllowed || _surface.Handle == 0) + { + swapBuffersCallback?.Invoke(); + return; + } + + // Wenn Größe noch nicht da ist, Swapchain später neu aufbauen + if (_width <= 0 || _height <= 0) + { + RecreateSwapchain(); + swapBuffersCallback?.Invoke(); + return; + } + + // Lazy-Init/Recovery + if (_swapchain.Handle == 0 || _imageAvailableSemaphores == null || _renderFinishedSemaphores == null) + { + try { CreateSwapchain(); } catch { /* try again next frame */ } + if (_swapchain.Handle == 0 || _imageAvailableSemaphores == null || _renderFinishedSemaphores == null) + { + swapBuffersCallback?.Invoke(); + return; + } + } + _gd.PipelineInternal.AutoFlush.Present(); uint nextImage = 0; @@ -339,11 +462,21 @@ namespace Ryujinx.Graphics.Vulkan _swapchainIsDirty) { RecreateSwapchain(); + + if (_swapchain.Handle == 0 || _imageAvailableSemaphores == null) + { + swapBuffersCallback?.Invoke(); + return; + } + semaphoreIndex = (_frameIndex - 1) % _imageAvailableSemaphores.Length; } - else if(acquireResult == Result.ErrorSurfaceLostKhr) + else if (acquireResult == Result.ErrorSurfaceLostKhr) { - _gd.RecreateSurface(); + // Im Hintergrund nicht sofort neu erstellen – freigeben und zurück + _gd.ReleaseSurface(); + swapBuffersCallback?.Invoke(); + return; } else { @@ -358,13 +491,36 @@ namespace Ryujinx.Graphics.Vulkan var cbs = _gd.CommandBufferPool.Rent(); - Transition( - cbs.CommandBuffer, - swapchainImage, - 0, - AccessFlags.TransferWriteBit, - ImageLayout.Undefined, - ImageLayout.General); + // --- Layout/Stages je nach Pfad korrekt setzen --- + bool allowStorageDst = !PlatformInfo.IsBionic; // Android: kein Storage auf Swapchain + bool useComputeDst = allowStorageDst && _scalingFilter != null; + + if (useComputeDst) + { + // Compute schreibt in das Swapchain-Image → General + ShaderWrite + Transition( + cbs.CommandBuffer, + swapchainImage, + PipelineStageFlags.TopOfPipeBit, + PipelineStageFlags.ComputeShaderBit, + 0, + AccessFlags.ShaderWriteBit, + ImageLayout.Undefined, + ImageLayout.General); + } + else + { + // Renderpass schreibt in das Swapchain-Image → ColorAttachmentOptimal + Transition( + cbs.CommandBuffer, + swapchainImage, + PipelineStageFlags.TopOfPipeBit, + PipelineStageFlags.ColorAttachmentOutputBit, + 0, + AccessFlags.ColorAttachmentWriteBit, + ImageLayout.Undefined, + ImageLayout.ColorAttachmentOptimal); + } var view = (TextureView)texture; @@ -403,18 +559,19 @@ namespace Ryujinx.Graphics.Vulkan { if (_effect != null) { + var emptySems = Array.Empty(); + var waitStagesCO = new PipelineStageFlags[] { PipelineStageFlags.ColorAttachmentOutputBit }; _gd.CommandBufferPool.Return( cbs, - null, - stackalloc[] { PipelineStageFlags.ColorAttachmentOutputBit }, - null); + emptySems, + waitStagesCO, + emptySems); _gd.FlushAllCommands(); cbs.GetFence().Wait(); cbs = _gd.CommandBufferPool.Rent(); } CaptureFrame(view, srcX0, srcY0, srcX1 - srcX0, srcY1 - srcY0, view.Info.Format.IsBgr(), crop.FlipX, crop.FlipY); - ScreenCaptureRequested = false; } @@ -433,9 +590,9 @@ namespace Ryujinx.Graphics.Vulkan int dstY0 = crop.FlipY ? dstPaddingY : _height - dstPaddingY; int dstY1 = crop.FlipY ? _height - dstPaddingY : dstPaddingY; - if (_scalingFilter != null) + if (_scalingFilter != null && useComputeDst) { - _scalingFilter.Run( + _scalingFilter!.Run( view, cbs, _swapchainImageViews[nextImage].GetImageViewForAttachment(), @@ -444,7 +601,7 @@ namespace Ryujinx.Graphics.Vulkan _height, new Extents2D(srcX0, srcY0, srcX1, srcY1), new Extents2D(dstX0, dstY0, dstX1, dstY1) - ); + ); } else { @@ -459,44 +616,71 @@ namespace Ryujinx.Graphics.Vulkan true); } - Transition( - cbs.CommandBuffer, - swapchainImage, - 0, - 0, - ImageLayout.General, - ImageLayout.PresentSrcKhr); + // Transition zu Present – Stages/Access je nach vorherigem Pfad + if (useComputeDst) + { + Transition( + cbs.CommandBuffer, + swapchainImage, + PipelineStageFlags.ComputeShaderBit, + PipelineStageFlags.BottomOfPipeBit, + AccessFlags.ShaderWriteBit, + 0, + ImageLayout.General, + ImageLayout.PresentSrcKhr); + } + else + { + Transition( + cbs.CommandBuffer, + swapchainImage, + PipelineStageFlags.ColorAttachmentOutputBit, + PipelineStageFlags.BottomOfPipeBit, + AccessFlags.ColorAttachmentWriteBit, + 0, + ImageLayout.ColorAttachmentOptimal, + ImageLayout.PresentSrcKhr); + } - _gd.CommandBufferPool.Return( - cbs, - [_imageAvailableSemaphores[semaphoreIndex]], - [PipelineStageFlags.ColorAttachmentOutputBit], - [_renderFinishedSemaphores[semaphoreIndex]]); + var waitSems = new Silk.NET.Vulkan.Semaphore[] { _imageAvailableSemaphores[semaphoreIndex] }; + var waitStages = new PipelineStageFlags[] { PipelineStageFlags.ColorAttachmentOutputBit }; // wichtig auf Android + var signalSems = new Silk.NET.Vulkan.Semaphore[] { _renderFinishedSemaphores[semaphoreIndex] }; + _gd.CommandBufferPool.Return(cbs, waitSems, waitStages, signalSems); - // TODO: Present queue. - var semaphore = _renderFinishedSemaphores[semaphoreIndex]; - var swapchain = _swapchain; + PresentOne(_gd, _renderFinishedSemaphores[semaphoreIndex], _swapchain, nextImage); - Result result; + swapBuffersCallback?.Invoke(); + } + + private static unsafe void PresentOne( + VulkanRenderer gd, + Silk.NET.Vulkan.Semaphore signal, + SwapchainKHR swapchain, + uint imageIndex) + { + Silk.NET.Vulkan.Semaphore* pWait = stackalloc Silk.NET.Vulkan.Semaphore[1]; + SwapchainKHR* pSwap = stackalloc SwapchainKHR[1]; + uint* pImageIndex = stackalloc uint[1]; + + pWait[0] = signal; + pSwap[0] = swapchain; + pImageIndex[0] = imageIndex; var presentInfo = new PresentInfoKHR { SType = StructureType.PresentInfoKhr, WaitSemaphoreCount = 1, - PWaitSemaphores = &semaphore, + PWaitSemaphores = pWait, SwapchainCount = 1, - PSwapchains = &swapchain, - PImageIndices = &nextImage, - PResults = &result, + PSwapchains = pSwap, + PImageIndices = pImageIndex, + PResults = null }; - lock (_gd.QueueLock) + lock (gd.QueueLock) { - _gd.SwapchainApi.QueuePresent(_gd.Queue, in presentInfo); + gd.SwapchainApi.QueuePresent(gd.Queue, in presentInfo); } - - //While this does nothing in most cases, it's useful to notify the end of the frame, and is used to handle native window in Android. - swapBuffersCallback?.Invoke(); } public override void SetAntiAliasing(AntiAliasing effect) @@ -604,6 +788,8 @@ namespace Ryujinx.Graphics.Vulkan private unsafe void Transition( CommandBuffer commandBuffer, Image image, + PipelineStageFlags srcStage, + PipelineStageFlags dstStage, AccessFlags srcAccess, AccessFlags dstAccess, ImageLayout srcLayout, @@ -626,8 +812,8 @@ namespace Ryujinx.Graphics.Vulkan _gd.Api.CmdPipelineBarrier( commandBuffer, - PipelineStageFlags.TopOfPipeBit, - PipelineStageFlags.AllCommandsBit, + srcStage, + dstStage, 0, 0, null, @@ -648,6 +834,13 @@ namespace Ryujinx.Graphics.Vulkan { // We don't need to use width and height as we can get the size from the surface. _swapchainIsDirty = true; + + // Nach Resume sicherstellen, dass Surface-Queries wieder erlaubt sind, + // falls vorher OnSurfaceLost() das Gate geschlossen hat. + if (_surface.Handle != 0) + { + SetSurfaceQueryAllowed(true); + } } public override void ChangeVSyncMode(VSyncMode vSyncMode) @@ -661,24 +854,45 @@ namespace Ryujinx.Graphics.Vulkan { if (disposing) { - unsafe + lock (_gd.SurfaceLock) { - for (int i = 0; i < _swapchainImageViews.Length; i++) + unsafe { - _swapchainImageViews[i].Dispose(); - } + if (_swapchainImageViews != null) + { + for (int i = 0; i < _swapchainImageViews.Length; i++) + { + _swapchainImageViews[i]?.Dispose(); + } + } - for (int i = 0; i < _imageAvailableSemaphores.Length; i++) - { - _gd.Api.DestroySemaphore(_device, _imageAvailableSemaphores[i], null); - } + if (_imageAvailableSemaphores != null) + { + for (int i = 0; i < _imageAvailableSemaphores.Length; i++) + { + if (_imageAvailableSemaphores[i].Handle != 0) + { + _gd.Api.DestroySemaphore(_device, _imageAvailableSemaphores[i], null); + } + } + } - for (int i = 0; i < _renderFinishedSemaphores.Length; i++) - { - _gd.Api.DestroySemaphore(_device, _renderFinishedSemaphores[i], null); - } + if (_renderFinishedSemaphores != null) + { + for (int i = 0; i < _renderFinishedSemaphores.Length; i++) + { + if (_renderFinishedSemaphores[i].Handle != 0) + { + _gd.Api.DestroySemaphore(_device, _renderFinishedSemaphores[i], null); + } + } + } - _gd.SwapchainApi.DestroySwapchain(_device, _swapchain, null); + if (_swapchain.Handle != 0) + { + _gd.SwapchainApi.DestroySwapchain(_device, _swapchain, null); + } + } } _effect?.Dispose(); @@ -686,6 +900,63 @@ namespace Ryujinx.Graphics.Vulkan } } + public void OnSurfaceLost() + { + lock (_gd.SurfaceLock) + { + // harte Aufräumaktion, damit nach Resume nichts „altes“ übrig ist + _swapchainIsDirty = true; + SetSurfaceQueryAllowed(false); + + _gd.Api.DeviceWaitIdle(_device); + + unsafe + { + if (_imageAvailableSemaphores != null) + { + for (int i = 0; i < _imageAvailableSemaphores.Length; i++) + { + if (_imageAvailableSemaphores[i].Handle != 0) + { + _gd.Api.DestroySemaphore(_device, _imageAvailableSemaphores[i], null); + } + } + _imageAvailableSemaphores = null; + } + + if (_renderFinishedSemaphores != null) + { + for (int i = 0; i < _renderFinishedSemaphores.Length; i++) + { + if (_renderFinishedSemaphores[i].Handle != 0) + { + _gd.Api.DestroySemaphore(_device, _renderFinishedSemaphores[i], null); + } + } + _renderFinishedSemaphores = null; + } + } + + if (_swapchainImageViews != null) + { + for (int i = 0; i < _swapchainImageViews.Length; i++) + { + _swapchainImageViews[i]?.Dispose(); + } + _swapchainImageViews = null; + } + + if (_swapchain.Handle != 0) + { + _gd.SwapchainApi.DestroySwapchain(_device, _swapchain, Span.Empty); + _swapchain = default; + } + + _surface = new SurfaceKHR(0); + _width = _height = 0; // erzwingt späteren sauberen Recreate-Pfad + } + } + public override void Dispose() { Dispose(true); diff --git a/src/Ryujinx.HLE/HOS/Kernel/Threading/KScheduler.cs b/src/Ryujinx.HLE/HOS/Kernel/Threading/KScheduler.cs index 668111aeb..d452e01cb 100644 --- a/src/Ryujinx.HLE/HOS/Kernel/Threading/KScheduler.cs +++ b/src/Ryujinx.HLE/HOS/Kernel/Threading/KScheduler.cs @@ -296,11 +296,12 @@ namespace Ryujinx.HLE.HOS.Kernel.Threading KThread currentThread = KernelStatic.GetCurrentThread(); KThread selectedThread = _state.SelectedThread; - if (!currentThread.IsThreadNamed && currentThread.GetThreadName() != "") - { - currentThread.HostThread.Name = $"<{currentThread.GetThreadName()}>"; - currentThread.IsThreadNamed = true; - } + // TODO fix building error CS1061 + //if (!currentThread.IsThreadNamed && currentThread.GetThreadName() != "") + //{ + // currentThread.HostThread.Name = $"<{currentThread.GetThreadName()}>"; + // currentThread.IsThreadNamed = true; + //} // If the thread is already scheduled and running on the core, we have nothing to do. if (currentThread == selectedThread) diff --git a/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/INfp.cs b/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/INfp.cs index 6c03b759a..e5b9f0cc0 100644 --- a/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/INfp.cs +++ b/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/INfp.cs @@ -7,6 +7,9 @@ using Ryujinx.HLE.HOS.Services.Hid; using Ryujinx.HLE.HOS.Services.Hid.HidServer; using Ryujinx.HLE.HOS.Services.Nfc.Nfp.NfpManager; using Ryujinx.Horizon.Common; +using Ryujinx.HLE.Kenjinx; +using System.Security.Cryptography; +using Ryujinx.Common.Logging; using System; using System.Buffers.Binary; using System.Globalization; @@ -142,7 +145,28 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp { context.Device.System.NfpDevices[i].State = NfpDeviceState.SearchingForTag; + // ▼ NEU: Falls via Bridge bereits ein Tag injiziert wurde, sofort übernehmen. + if (KenjinxAmiiboShim.TryConsume(out var tagBytes)) + { + var dev = context.Device.System.NfpDevices[i]; + + if (TryGetFigureIdFrom1Dc(tagBytes, out var fid, out var raw)) + { + dev.AmiiboId = fid; + Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: raw@1DC={raw} → FigureID={fid}"); + } + else + { + dev.AmiiboId = MakeAmiiboIdFromBytes(tagBytes); // Fallback (SHA1) + Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: FigureID={dev.AmiiboId} (fallback, keine 1DC-Bytes)"); + } + + dev.UseRandomUuid = false; // stabile UUID über die Session + dev.State = NfpDeviceState.TagFound; + } + // ▲ NEU break; + } } _cancelTokenSource = new CancellationTokenSource(); @@ -155,7 +179,32 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp break; } + // ▼ NEU: Während der Suche nach neu injizierten Tags poll’en. + for (int d = 0; d < context.Device.System.NfpDevices.Count; d++) + { + var dev = context.Device.System.NfpDevices[d]; + + if (dev.State == NfpDeviceState.SearchingForTag && KenjinxAmiiboShim.TryConsume(out var tagBytesLoop)) + { + if (TryGetFigureIdFrom1Dc(tagBytesLoop, out var fid, out var raw)) + { + dev.AmiiboId = fid; + Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: raw@1DC={raw} → FigureID={fid}"); + } + else + { + dev.AmiiboId = MakeAmiiboIdFromBytes(tagBytesLoop); // Fallback (SHA1) + Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: FigureID={dev.AmiiboId} (fallback, keine 1DC-Bytes)"); + } + + dev.UseRandomUuid = false; + dev.State = NfpDeviceState.TagFound; + } + } + // ▲ NEU + for (int i = 0; i < context.Device.System.NfpDevices.Count; i++) + { if (context.Device.System.NfpDevices[i].State == NfpDeviceState.TagFound) { @@ -196,9 +245,62 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp break; } } + + // ▼ NEU: injizierten Tag verwerfen + KenjinxAmiiboShim.Clear(); + // ▲ NEU + return ResultCode.Success; } + // ▼▼▼ NEU: FigureID direkt aus 0x1DC (8 Bytes) ohne 16-Bit-Vertauschung + private static bool TryGetFigureIdFrom1Dc(byte[] data, out string figureId, out string debugRaw) + { + figureId = string.Empty; + debugRaw = string.Empty; + + const int off = 0x1DC; + const int len = 8; + + if (data == null || data.Length < off + len) + { + return false; + } + + var s = data.AsSpan(off, len); + + // Debug-String "AA-BB-..." in Großbuchstaben + debugRaw = $"{s[0]:X2}-{s[1]:X2}-{s[2]:X2}-{s[3]:X2}-{s[4]:X2}-{s[5]:X2}-{s[6]:X2}-{s[7]:X2}"; + + // FigureID als 16-stelliger lower-case Hex-String in **derselben Reihenfolge** + // Layout: [0..1]=CharId(BE), [2]=Variant, [3]=Type, [4..5]=Model(BE), [6]=Series, [7]=unk + figureId = + $"{s[0]:x2}{s[1]:x2}{s[2]:x2}{s[3]:x2}{s[4]:x2}{s[5]:x2}{s[6]:x2}{s[7]:x2}"; + + return true; + } + // ▲▲▲ NEU + + // Fallback: deterministische 16 Hex-Zeichen aus SHA1 (erste 8 Bytes) + private static string MakeAmiiboIdFromBytes(byte[] data) + { + if (data == null || data.Length == 0) return "0000000000000000"; + + using var sha = SHA1.Create(); + var hash = sha.ComputeHash(data); // 20 Bytes + char[] buf = new char[16]; + for (int i = 0, k = 0; i < 8; i++) + { + byte b = hash[i]; + buf[k++] = GetHex((b >> 4) & 0xF); + buf[k++] = GetHex(b & 0xF); + } + return new string(buf); + } + + private static char GetHex(int v) => (char)(v < 10 ? ('0' + v) : ('a' + (v - 10))); + + [CommandCmif(5)] // Mount(bytes<8, 4>, u32, u32) public ResultCode Mount(ServiceCtx context) diff --git a/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/KenjinxAmiiboShim.cs b/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/KenjinxAmiiboShim.cs new file mode 100644 index 000000000..d5f34a488 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Nfc/Nfp/NfpManager/KenjinxAmiiboShim.cs @@ -0,0 +1,51 @@ +#nullable enable +using System; +using System.Runtime.CompilerServices; + +namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp +{ + /// + /// Kleiner statischer Puffer + API, die wir aus der Android-Seite per Reflection ansprechen. + /// + public static class KenjinxAmiiboShim + { + private static byte[]? s_tag; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static bool InjectAmiibo(byte[] tagBytes) + { + if (tagBytes is null || tagBytes.Length == 0) return false; + s_tag = (byte[])tagBytes.Clone(); + System.Diagnostics.Debug.WriteLine($"[Kenjinx] KenjinxAmiiboShim.InjectAmiibo bytes={tagBytes.Length}"); + return true; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ClearAmiibo() + { + s_tag = null; + System.Diagnostics.Debug.WriteLine("[Kenjinx] KenjinxAmiiboShim.ClearAmiibo"); + } + + // ▼ NEU: von INfp genutzt – holt den Tag genau einmal ab und leert den Puffer + public static bool TryConsume(out byte[] data) + { + if (s_tag is null) + { + data = Array.Empty(); + return false; + } + data = s_tag; + s_tag = null; + return true; + } + + // ▼ NEU: bequemer Alias + public static void Clear() => ClearAmiibo(); + + public static bool HasInjectedAmiibo => s_tag is not null; + + public static ReadOnlySpan PeekInjectedAmiibo() + => s_tag is null ? ReadOnlySpan.Empty : new ReadOnlySpan(s_tag); + } +} diff --git a/src/Ryujinx.HLE/Kenjinx/AmiiboBridge.cs b/src/Ryujinx.HLE/Kenjinx/AmiiboBridge.cs new file mode 100644 index 000000000..e348919a6 --- /dev/null +++ b/src/Ryujinx.HLE/Kenjinx/AmiiboBridge.cs @@ -0,0 +1,157 @@ +#nullable enable +using System; +using System.Reflection; +using Ryujinx.HLE; + +namespace Ryujinx.HLE.Kenjinx +{ + public static class AmiiboBridge + { + public static bool TryLoadVirtualAmiibo(Switch device, byte[] data, out string message) + { + message = "NFP bridge not wired."; + if (device?.System is null) + { + message = "No System on Switch device."; + return false; + } + + // --- TRY 0: Unsere konfliktfreie Shim-Klasse zuerst (empfohlen) --- + // Typname inkl. Assembly: Ryujinx.HLE + var shimType = Type.GetType( + "Ryujinx.HLE.HOS.Services.Nfc.Nfp.KenjinxAmiiboShim, Ryujinx.HLE", + throwOnError: false + ); + if (shimType != null) + { + var m = shimType.GetMethod( + "InjectAmiibo", + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: new[] { typeof(byte[]) }, + modifiers: null + ); + + if (m != null) + { + var ok = (bool)(m.Invoke(null, new object[] { data }) ?? false); + if (ok) + { + message = "Injected via KenjinxAmiiboShim.InjectAmiibo"; + return true; + } + } + } + + // --- TRY 0.5: Falls vorhanden – statische API direkt auf NfpManager --- + // (Nur falls du später doch eine statische InjectAmiibo(...) in NfpManager ergänzt.) + var nfpManagerType = Type.GetType( + "Ryujinx.HLE.HOS.Services.Nfc.Nfp.NfpManager, Ryujinx.HLE", + throwOnError: false + ); + if (nfpManagerType != null) + { + var m = nfpManagerType.GetMethod( + "InjectAmiibo", + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: new[] { typeof(byte[]) }, + modifiers: null + ); + + if (m != null) + { + var ok = m.Invoke(null, new object[] { data }); + message = "Injected via NfpManager.InjectAmiibo(static)"; + return true; + } + } + + // --- TRY 1: Direkter Zugriff auf einen vorhandenen Manager im System-Objekt (dein bestehender Code) --- + var sys = device.System; + var nfpMgr = + GetField(sys, "NfpManager") ?? + GetField(sys, "_nfpManager") ?? + GetField(sys, "NfcManager") ?? + GetField(sys, "_nfcManager"); + + if (nfpMgr != null) + { + // Mögliche Methodenbezeichner je nach Baum: + var mi = + GetMethod(nfpMgr, "InjectAmiibo", new[] { typeof(byte[]) }) ?? + GetMethod(nfpMgr, "LoadAmiiboFromBytes", new[] { typeof(byte[]) }) ?? + GetMethod(nfpMgr, "LoadVirtualAmiibo", new[] { typeof(byte[]) }) ?? + GetMethod(nfpMgr, "ScanAmiiboFromBuffer", new[] { typeof(byte[]) }); + + if (mi != null) + { + mi.Invoke(nfpMgr, new object[] { data }); + message = $"Injected via {mi.DeclaringType?.Name}.{mi.Name}"; + return true; + } + } + + message = "No NFP manager API found. Add a fixed InjectAmiibo(byte[]) to your NFP layer (see TODO in AmiiboBridge comments)."; + return false; + } + + public static void ClearVirtualAmiibo(Switch device) + { + if (device?.System is null) return; + + // --- Clear über Shim (falls vorhanden) --- + var shimType = Type.GetType( + "Ryujinx.HLE.HOS.Services.Nfc.Nfp.KenjinxAmiiboShim, Ryujinx.HLE", + throwOnError: false + ); + shimType? + .GetMethod("ClearAmiibo", BindingFlags.Public | BindingFlags.Static, null, Type.EmptyTypes, null) + ?.Invoke(null, null); + + // --- Optional: statische Clear-API auf NfpManager (falls vorhanden) --- + var nfpManagerType = Type.GetType( + "Ryujinx.HLE.HOS.Services.Nfc.Nfp.NfpManager, Ryujinx.HLE", + throwOnError: false + ); + nfpManagerType? + .GetMethod("ClearAmiibo", BindingFlags.Public | BindingFlags.Static, null, Type.EmptyTypes, null) + ?.Invoke(null, null); + + // --- Dein bestehender Instanz-Fallback --- + var sys = device.System; + var nfpMgr = + GetField(sys, "NfpManager") ?? + GetField(sys, "_nfpManager") ?? + GetField(sys, "NfcManager") ?? + GetField(sys, "_nfcManager"); + + if (nfpMgr != null) + { + var mi = + GetMethod(nfpMgr, "ClearAmiibo", Type.EmptyTypes) ?? + GetMethod(nfpMgr, "ResetVirtualAmiibo", Type.EmptyTypes); + + mi?.Invoke(nfpMgr, Array.Empty()); + } + } + + private static object? GetField(object target, string name) + { + var t = target.GetType(); + var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy); + return f?.GetValue(target); + } + + private static MethodInfo? GetMethod(object target, string name, Type[]? sig) + { + var t = target.GetType(); + return t.GetMethod( + name, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy, + binder: null, + types: sig ?? Type.EmptyTypes, + modifiers: null); + } + } +}