aboutsummaryrefslogtreecommitdiff
path: root/src/android
diff options
context:
space:
mode:
authorxXJSONDeruloXx <danielhimebauch@gmail.com>2026-02-28 16:05:06 +0100
committercrueter <crueter@eden-emu.dev>2026-02-28 16:05:06 +0100
commit7de5eb6884bd3c4b4f8912406e30dc26497e9fbc (patch)
treea9d23077f0535702772878d25e6e21e5248467e9 /src/android
parentac181b756f7c8e4abf67412cc2c61ba61f5e5301 (diff)
[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 <crueter@eden-emu.dev> Reviewed-by: CamilleLaVey <camillelavey99@gmail.com> Reviewed-by: DraVee <dravee@eden-emu.dev> Co-authored-by: xXJSONDeruloXx <danielhimebauch@gmail.com> Co-committed-by: xXJSONDeruloXx <danielhimebauch@gmail.com>
Diffstat (limited to 'src/android')
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt4
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt8
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt144
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt7
-rw-r--r--src/android/app/src/main/jni/emu_window/emu_window.cpp11
-rw-r--r--src/android/app/src/main/jni/native.cpp41
-rw-r--r--src/android/app/src/main/res/drawable/circle_white.xml4
-rw-r--r--src/android/app/src/main/res/layout/fragment_emulation.xml28
8 files changed, 220 insertions, 27 deletions
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) {
@@ -2091,6 +2168,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()
emulationThread = Thread({
@@ -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<InputOverlayDrawableButton> = HashSet()
private val overlayDpads: MutableSet<InputOverlayDrawableDpad> = 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<jboolean>(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<u8> linear(LinearWidth * LinearHeight * BytesPerPixel);
+ Tegra::Texture::UnswizzleTexture(linear, tiled, BytesPerPixel, LinearWidth, LinearHeight,
+ LinearDepth, BlockHeight, BlockDepth);
+
+ auto buffer = env->NewByteArray(static_cast<jsize>(linear.size()));
+ if (!buffer) {
+ return env->NewByteArray(0);
+ }
+
+ env->SetByteArrayRegion(buffer, 0, static_cast<jsize>(linear.size()),
+ reinterpret_cast<const jbyte*>(linear.data()));
+ return buffer;
+}
+
+jint Java_org_yuzu_yuzu_1emu_NativeLibrary_getAppletCaptureWidth(JNIEnv* env, jclass clazz) {
+ return static_cast<jint>(VideoCore::Capture::LinearWidth);
+}
+
+jint Java_org_yuzu_yuzu_1emu_NativeLibrary_getAppletCaptureHeight(JNIEnv* env, jclass clazz) {
+ return static_cast<jint>(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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
+ <solid android:color="#E6FFFFFF" />
+</shape>
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
@@ -109,6 +109,22 @@
</FrameLayout>
<FrameLayout
+ android:id="@+id/paused_frame_container"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:fitsSystemWindows="false">
+
+ <ImageView
+ android:id="@+id/paused_frame_image"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:contentDescription="@null"
+ android:scaleType="fitCenter"
+ android:visibility="gone" />
+
+ </FrameLayout>
+
+ <FrameLayout
android:id="@+id/input_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
@@ -142,6 +158,18 @@
android:layout_height="match_parent"
android:fitsSystemWindows="false">
+ <ImageView
+ android:id="@+id/paused_icon"
+ android:layout_width="64dp"
+ android:layout_height="64dp"
+ android:layout_gravity="center"
+ android:background="@drawable/circle_white"
+ android:contentDescription="@string/emulation_unpause"
+ android:padding="14dp"
+ android:src="@drawable/ic_play"
+ android:visibility="gone"
+ app:tint="@android:color/black" />
+
<com.google.android.material.textview.MaterialTextView
android:id="@+id/show_stats_overlay_text"
style="@style/TextAppearance.Material3.BodySmall"