From 7de5eb6884bd3c4b4f8912406e30dc26497e9fbc Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sat, 28 Feb 2026 16:05:06 +0100 Subject: [PATCH] [android] fix persist manual game pause after android sleep/wake (#3651) if user invokes the "pause game" option from the menu while in game, as expected this suspends the process till user manually hits resume.. except for one case: Android sleep/wake lifecycle. If user manually pauses a running game, then sleeps their device, then wakes their device; the game will self-resume without user pressing "resume game". Expected behavior IMO is that if user left the game process in manually paused state, app should respect this and persist the pause on system wake, so that user may manually press "resume game" to unfreeze the process. Simple fix is to have a few params for user initiated pause and resume, and update the pause and run methods to handle as described above. Please let me know if there is a cleaner way to implement! Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3651 Reviewed-by: crueter Reviewed-by: CamilleLaVey Reviewed-by: DraVee Co-authored-by: xXJSONDeruloXx Co-committed-by: xXJSONDeruloXx --- .../java/org/yuzu/yuzu_emu/NativeLibrary.kt | 4 + .../yuzu_emu/activities/EmulationActivity.kt | 8 +- .../yuzu_emu/fragments/EmulationFragment.kt | 144 +++++++++++++++--- .../org/yuzu/yuzu_emu/overlay/InputOverlay.kt | 7 +- .../src/main/jni/emu_window/emu_window.cpp | 11 ++ src/android/app/src/main/jni/native.cpp | 41 ++++- .../src/main/res/drawable/circle_white.xml | 4 + .../main/res/layout/fragment_emulation.xml | 28 ++++ 8 files changed, 220 insertions(+), 27 deletions(-) create mode 100644 src/android/app/src/main/res/drawable/circle_white.xml diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt index e49f466462..1f0acf2835 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt @@ -152,6 +152,10 @@ object NativeLibrary { external fun surfaceDestroyed() + external fun getAppletCaptureBuffer(): ByteArray + external fun getAppletCaptureWidth(): Int + external fun getAppletCaptureHeight(): Int + /** * Unpauses emulation from a paused state. */ diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt index 42d4f687f4..2764d7eac6 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2023 yuzu Emulator Project @@ -204,9 +204,9 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener, InputManager } override fun onPause() { - super.onPause() nfcReader.stopScanning() stopMotionSensorListener() + super.onPause() } override fun onDestroy() { @@ -339,6 +339,10 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener, InputManager } override fun onSensorChanged(event: SensorEvent) { + if (!NativeLibrary.isRunning() || NativeLibrary.isPaused()) { + return + } + val rotation = this.display?.rotation if (rotation == Surface.ROTATION_90) { flipMotionOrientation = true diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index e8739e2d23..435fe5fe2c 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -15,6 +15,7 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.ActivityInfo import android.content.res.Configuration +import android.graphics.Bitmap import android.net.Uri import android.os.BatteryManager import android.os.BatteryManager.* @@ -97,6 +98,7 @@ import org.yuzu.yuzu_emu.utils.collect import org.yuzu.yuzu_emu.utils.CustomSettingsHandler import java.io.ByteArrayOutputStream import java.io.File +import java.nio.ByteBuffer import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlin.or @@ -141,6 +143,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { private var wasInputOverlayAutoHidden = false private var overlayTouchActive = false + private var pausedFrameBitmap: Bitmap? = null var shouldUseCustom = false private var isQuickSettingsMenuOpen = false @@ -703,6 +706,12 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { binding.inGameMenu.menu.findItem(R.id.menu_quick_settings)?.isVisible = BooleanSetting.ENABLE_QUICK_SETTINGS.getBoolean() + binding.pausedIcon.setOnClickListener { + if (this::emulationState.isInitialized && emulationState.isPaused) { + resumeEmulationFromUi() + } + } + binding.inGameMenu.menu.findItem(R.id.menu_lock_drawer).apply { val lockMode = IntSetting.LOCK_DRAWER.getInt() val titleId = if (lockMode == DrawerLayout.LOCK_MODE_LOCKED_CLOSED) { @@ -728,11 +737,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { when (it.itemId) { R.id.menu_pause_emulation -> { if (emulationState.isPaused) { - emulationState.run(false) - updatePauseMenuEntry(false) + resumeEmulationFromUi() } else { - emulationState.pause() - updatePauseMenuEntry(true) + pauseEmulationAndCaptureFrame() } binding.inGameMenu.requestFocus() true @@ -826,6 +833,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } R.id.menu_exit -> { + clearPausedFrame() emulationState.stop() NativeConfig.reloadGlobalConfig() emulationViewModel.setIsEmulationStopping(true) @@ -1197,6 +1205,71 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } + private fun pauseEmulationAndCaptureFrame() { + emulationState.pause() + updatePauseMenuEntry(true) + capturePausedFrameFromCore() + updatePausedFrameVisibility() + } + + private fun capturePausedFrameFromCore() { + lifecycleScope.launch(Dispatchers.Default) { + val frameData = NativeLibrary.getAppletCaptureBuffer() + val width = NativeLibrary.getAppletCaptureWidth() + val height = NativeLibrary.getAppletCaptureHeight() + if (frameData.isEmpty() || width <= 0 || height <= 0) { + Log.warning( + "[EmulationFragment] Paused frame capture returned empty/invalid data. " + + "size=${frameData.size}, width=$width, height=$height" + ) + return@launch + } + + val expectedSize = width * height * 4 + if (frameData.size < expectedSize) { + Log.warning( + "[EmulationFragment] Paused frame buffer smaller than expected. " + + "size=${frameData.size}, expected=$expectedSize" + ) + return@launch + } + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(frameData, 0, expectedSize)) + + withContext(Dispatchers.Main) { + pausedFrameBitmap?.recycle() + pausedFrameBitmap = bitmap + updatePausedFrameVisibility() + } + } + } + + private fun updatePausedFrameVisibility() { + val b = _binding ?: return + val showPausedUi = this::emulationState.isInitialized && emulationState.isPaused + b.pausedIcon.setVisible(showPausedUi) + + val bitmap = if (showPausedUi) pausedFrameBitmap else null + b.pausedFrameImage.setImageBitmap(bitmap) + b.pausedFrameImage.setVisible(bitmap != null) + } + + private fun resumeEmulationFromUi() { + clearPausedFrame() + emulationState.resume() + updatePauseMenuEntry(emulationState.isPaused) + updatePausedFrameVisibility() + } + + private fun clearPausedFrame() { + val b = _binding + b?.pausedFrameImage?.setVisible(false) + b?.pausedFrameImage?.setImageDrawable(null) + pausedFrameBitmap?.recycle() + pausedFrameBitmap = null + } + private fun handleLoadAmiiboSelection(): Boolean { val binding = _binding ?: return true @@ -1290,8 +1363,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { override fun onPause() { if (this::emulationState.isInitialized) { if (emulationState.isRunning && emulationActivity?.isInPictureInPictureMode != true) { - emulationState.pause() - updatePauseMenuEntry(true) + pauseEmulationAndCaptureFrame() + } else { + updatePausedFrameVisibility() } } super.onPause() @@ -1301,6 +1375,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { super.onDestroyView() amiiboLoadJob?.cancel() amiiboLoadJob = null + clearPausedFrame() _binding?.surfaceInputOverlay?.touchEventListener = null _binding = null isAmiiboPickerOpen = false @@ -1321,6 +1396,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { b.inGameMenu.post { if (!this::emulationState.isInitialized || _binding == null) return@post updatePauseMenuEntry(emulationState.isPaused) + updatePausedFrameVisibility() } } @@ -1760,6 +1836,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { // Only update surface reference, don't trigger state changes emulationState.updateSurfaceReference(holder.surface) } + updatePausedFrameVisibility() } override fun surfaceDestroyed(holder: SurfaceHolder) { @@ -2090,6 +2167,29 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } + @Synchronized + fun resume() { + if (state != State.PAUSED) { + Log.warning("[EmulationFragment] Resume called while emulation is not paused.") + return + } + if (!emulationCanStart.invoke()) { + Log.warning("[EmulationFragment] Resume blocked by emulationCanStart check.") + return + } + val currentSurface = surface + if (currentSurface == null || !currentSurface.isValid) { + Log.debug("[EmulationFragment] Resume requested with invalid surface.") + return + } + + NativeLibrary.surfaceChanged(currentSurface) + Log.debug("[EmulationFragment] Resuming emulation.") + NativeLibrary.unpauseEmulation() + NativeLibrary.playTimeManagerStart() + state = State.RUNNING + } + @Synchronized fun changeProgram(programIndex: Int) { emulationThread.join() @@ -2111,7 +2211,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { @Synchronized fun updateSurface() { - if (surface != null) { + if (surface != null && state == State.RUNNING) { NativeLibrary.surfaceChanged(surface) } } @@ -2127,20 +2227,20 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { @Synchronized fun clearSurface() { if (surface == null) { - Log.warning("[EmulationFragment] clearSurface called, but surface already null.") + Log.debug("[EmulationFragment] clearSurface called, but surface already null.") } else { + if (state == State.RUNNING) { + pause() + } + NativeLibrary.surfaceDestroyed() surface = null Log.debug("[EmulationFragment] Surface destroyed.") when (state) { - State.RUNNING -> { - state = State.PAUSED - } - - State.PAUSED -> Log.warning( + State.PAUSED -> Log.debug( "[EmulationFragment] Surface cleared while emulation paused." ) - else -> Log.warning( + else -> Log.debug( "[EmulationFragment] Surface cleared while emulation stopped." ) } @@ -2148,29 +2248,35 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } private fun runWithValidSurface(programIndex: Int = 0) { - NativeLibrary.surfaceChanged(surface) if (!emulationCanStart.invoke()) { return } + val currentSurface = surface + if (currentSurface == null || !currentSurface.isValid) { + Log.debug("[EmulationFragment] runWithValidSurface called with invalid surface.") + return + } when (state) { State.STOPPED -> { + NativeLibrary.surfaceChanged(currentSurface) emulationThread = Thread({ Log.debug("[EmulationFragment] Starting emulation thread.") NativeLibrary.run(gamePath, programIndex, true) }, "NativeEmulation") emulationThread.start() + state = State.RUNNING } State.PAUSED -> { - Log.debug("[EmulationFragment] Resuming emulation.") - NativeLibrary.unpauseEmulation() - NativeLibrary.playTimeManagerStart() + Log.debug( + "[EmulationFragment] Surface restored while emulation paused; " + + "waiting for explicit resume." + ) } else -> Log.debug("[EmulationFragment] Bug, run called while already running.") } - state = State.RUNNING } private enum class State { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt index e18077c673..d3b5d86174 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.overlay @@ -20,7 +20,6 @@ import android.os.Looper import android.util.AttributeSet import android.view.HapticFeedbackConstants import android.view.MotionEvent -import android.view.SurfaceView import android.view.View import android.view.View.OnTouchListener import android.view.WindowInsets @@ -42,10 +41,10 @@ import org.yuzu.yuzu_emu.utils.NativeConfig /** * Draws the interactive input overlay on top of the - * [SurfaceView] that is rendering emulation. + * emulation rendering surface. */ class InputOverlay(context: Context, attrs: AttributeSet?) : - SurfaceView(context, attrs), + View(context, attrs), OnTouchListener { private val overlayButtons: MutableSet = HashSet() private val overlayDpads: MutableSet = HashSet() diff --git a/src/android/app/src/main/jni/emu_window/emu_window.cpp b/src/android/app/src/main/jni/emu_window/emu_window.cpp index 06db553691..4e90cad570 100644 --- a/src/android/app/src/main/jni/emu_window/emu_window.cpp +++ b/src/android/app/src/main/jni/emu_window/emu_window.cpp @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later @@ -14,6 +17,14 @@ #include "jni/native.h" void EmuWindow_Android::OnSurfaceChanged(ANativeWindow* surface) { + if (!surface) { + LOG_INFO(Frontend, "EmuWindow_Android::OnSurfaceChanged received null surface"); + m_window_width = 0; + m_window_height = 0; + window_info.render_surface = nullptr; + return; + } + m_window_width = ANativeWindow_getWidth(surface); m_window_height = ANativeWindow_getHeight(surface); diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index 2e50bb1069..c429f4a1e4 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -89,6 +89,8 @@ #include "jni/native.h" #include "video_core/renderer_base.h" #include "video_core/renderer_vulkan/renderer_vulkan.h" +#include "video_core/capture.h" +#include "video_core/textures/decoders.h" #include "video_core/vulkan_common/vulkan_instance.h" #include "video_core/vulkan_common/vulkan_surface.h" #include "video_core/shader_notify.h" @@ -780,9 +782,10 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_surfaceChanged(JNIEnv* env, jobject i } void Java_org_yuzu_yuzu_1emu_NativeLibrary_surfaceDestroyed(JNIEnv* env, jobject instance) { - ANativeWindow_release(EmulationSession::GetInstance().NativeWindow()); + if (auto* native_window = EmulationSession::GetInstance().NativeWindow(); native_window) { + ANativeWindow_release(native_window); + } EmulationSession::GetInstance().SetNativeWindow(nullptr); - EmulationSession::GetInstance().SurfaceChanged(); } void Java_org_yuzu_yuzu_1emu_NativeLibrary_setAppDirectory(JNIEnv* env, jobject instance, @@ -969,6 +972,40 @@ jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isPaused(JNIEnv* env, jclass claz return static_cast(EmulationSession::GetInstance().IsPaused()); } +jbyteArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getAppletCaptureBuffer(JNIEnv* env, jclass clazz) { + using namespace VideoCore::Capture; + + if (!EmulationSession::GetInstance().IsRunning()) { + return env->NewByteArray(0); + } + + const auto tiled = EmulationSession::GetInstance().System().GPU().GetAppletCaptureBuffer(); + if (tiled.size() < TiledSize) { + return env->NewByteArray(0); + } + + std::vector linear(LinearWidth * LinearHeight * BytesPerPixel); + Tegra::Texture::UnswizzleTexture(linear, tiled, BytesPerPixel, LinearWidth, LinearHeight, + LinearDepth, BlockHeight, BlockDepth); + + auto buffer = env->NewByteArray(static_cast(linear.size())); + if (!buffer) { + return env->NewByteArray(0); + } + + env->SetByteArrayRegion(buffer, 0, static_cast(linear.size()), + reinterpret_cast(linear.data())); + return buffer; +} + +jint Java_org_yuzu_yuzu_1emu_NativeLibrary_getAppletCaptureWidth(JNIEnv* env, jclass clazz) { + return static_cast(VideoCore::Capture::LinearWidth); +} + +jint Java_org_yuzu_yuzu_1emu_NativeLibrary_getAppletCaptureHeight(JNIEnv* env, jclass clazz) { + return static_cast(VideoCore::Capture::LinearHeight); +} + void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeSystem(JNIEnv* env, jclass clazz, jboolean reload) { // Initialize the emulated system. diff --git a/src/android/app/src/main/res/drawable/circle_white.xml b/src/android/app/src/main/res/drawable/circle_white.xml new file mode 100644 index 0000000000..c94e68679a --- /dev/null +++ b/src/android/app/src/main/res/drawable/circle_white.xml @@ -0,0 +1,4 @@ + + + + diff --git a/src/android/app/src/main/res/layout/fragment_emulation.xml b/src/android/app/src/main/res/layout/fragment_emulation.xml index 7f5f039d5e..db363bc723 100644 --- a/src/android/app/src/main/res/layout/fragment_emulation.xml +++ b/src/android/app/src/main/res/layout/fragment_emulation.xml @@ -108,6 +108,22 @@ + + + + + + + +