From 87353cfd013939a4b239e954b2db2452120e86f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sat, 10 Oct 2026 15:31:24 +0200 Subject: [PATCH 1/7] Android: Fix activity state lost on recreate, and a destroyed activity kept alive - Initialize() runs for the first activity only, but set up per-activity fields: after any recreate, audioManager was null, so audio focus was never taken or given up again, and sustained performance mode was off. - The static camera helper kept the first activity's display. It now gets the application context. - An activity backed out of (or destroyed by the system) with the native side left running stayed referenced by it until the next one registered, and native code could still post commands to it. It now lets go, and a command arriving at a destroyed activity is dropped (an input box request answered as cancelled). - An open input box is dismissed in onDestroy, instead of its window leaking and its request never being answered. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/org/ppsspp/ppsspp/CameraHelper.java | 6 ++- .../src/org/ppsspp/ppsspp/PpssppActivity.java | 47 ++++++++++++++++--- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/android/src/org/ppsspp/ppsspp/CameraHelper.java b/android/src/org/ppsspp/ppsspp/CameraHelper.java index e0617d1dbf..1db8307477 100644 --- a/android/src/org/ppsspp/ppsspp/CameraHelper.java +++ b/android/src/org/ppsspp/ppsspp/CameraHelper.java @@ -6,11 +6,11 @@ import android.graphics.Rect; import android.graphics.SurfaceTexture; import android.graphics.YuvImage; import android.hardware.Camera; +import android.hardware.display.DisplayManager; import android.os.SystemClock; import android.util.Log; import android.view.Display; import android.view.Surface; -import android.view.WindowManager; import androidx.annotation.Keep; @@ -151,8 +151,10 @@ class CameraHelper { }; @SuppressWarnings("unused") + // Static in PpssppActivity, so this gets the application context: DisplayManager, unlike WindowManager, + // gives a display that follows the rotation without one. CameraHelper(final Context context) { - mDisplay = ((WindowManager)context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + mDisplay = ((DisplayManager)context.getSystemService(Context.DISPLAY_SERVICE)).getDisplay(Display.DEFAULT_DISPLAY); mSurfaceTexture = new SurfaceTexture(10); } diff --git a/android/src/org/ppsspp/ppsspp/PpssppActivity.java b/android/src/org/ppsspp/ppsspp/PpssppActivity.java index a8b159faf3..fa9f3a9859 100644 --- a/android/src/org/ppsspp/ppsspp/PpssppActivity.java +++ b/android/src/org/ppsspp/ppsspp/PpssppActivity.java @@ -116,6 +116,8 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList private final ArrayList inputPlayers = new ArrayList<>(); private PowerSaveModeReceiver mPowerSaveModeReceiver = null; + // The open input box, if any. + private AlertDialog inputDialog = null; private NativeSurfaceManager surfaceManager = null; private static LocationHelper mLocationHelper; private static InfraredHelper mInfraredHelper; @@ -366,9 +368,9 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList return info.reqGlEsVersion >= 0x30000; } - public void Initialize() { - // Initialize audio classes. Do this here since detectOptimalAudioSettings() - // needs audioManager + // What every activity needs for itself, not just the first one (Initialize). + private void initActivityServices() { + // detectOptimalAudioSettings() needs audioManager. this.audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); if (this.audioManager != null) { this.audioFocusChangeListener = new AudioFocusChangeListener(); @@ -385,7 +387,9 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList NativeApp.sendMessageFromJava("sustained_perf_supported", "1"); } } + } + public void Initialize() { // isLandscape is used to trigger GetAppInfo currently, we boolean landscape = NativeApp.isLandscape(); Log.d(TAG, "Landscape: " + landscape); @@ -493,8 +497,7 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList mInfraredHelper = null; Log.i(TAG, "InfraredHelper exception: " + e); } - // android.graphics.SurfaceTexture is not available before version 11. - mCameraHelper = new CameraHelper(this); + mCameraHelper = new CameraHelper(getApplicationContext()); } @NonNull @@ -690,6 +693,8 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList // whether to start at 1x or 2x. surfaceManager.updateDisplayMeasurements(); + initActivityServices(); + // On the first run, the shortcut parameter is passed to NativeApp.init in here. final boolean firstRun = !initialized; if (!initialized) { @@ -946,6 +951,11 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList mPowerSaveModeReceiver.destroy(this); mPowerSaveModeReceiver = null; } + // Otherwise its window leaks, and its request is never answered. Dismissing answers it as cancelled. + if (inputDialog != null) { + inputDialog.dismiss(); + inputDialog = null; + } if (instance != latestInstance) { // After finish(), Android is free to deliver onDestroy late, after it has already created @@ -963,6 +973,11 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList NativeApp.shutdown(); unregisterCallbacks(); initialized = false; + } else if (!isChangingConfigurations()) { + // Backed out of, or destroyed by the system, with the native side left running for the next + // activity. Until that one registers, the native side mustn't keep this one alive or call it. + Log.i(TAG, "in onDestroy, but not shutting down. Letting go of the activity."); + unregisterCallbacks(); } else { Log.i(TAG, "in onDestroy, but not shutting down."); } @@ -1424,7 +1439,13 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList Log.i(TAG, "input box dismissed"); // This will be ignored if we already sent a success. NativeApp.sendRequestResult(requestId, false, "", 0); - updateSystemUiVisibility(); + if (inputDialog == d) { + inputDialog = null; + } + // Also comes after onDestroy dismissed it. + if (!isDestroyed()) { + updateSystemUiVisibility(); + } }); AlertDialog dlg = builder.create(); @@ -1446,6 +1467,7 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList } try { dlg.show(); + inputDialog = dlg; input.requestFocus(); } catch (Exception e) { NativeApp.reportException(e, "AlertDialog"); @@ -1881,6 +1903,19 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList @SuppressWarnings("unused") public void postCommand(String command, String parameter) { runOnUiThread(() -> { + // Posted before this activity was destroyed, but runs after. + if (isDestroyed()) { + Log.w(TAG, "Dropping command '" + command + "' for a destroyed activity"); + if (command.equals("inputbox")) { + // The native side waits for an answer. + try { + NativeApp.sendRequestResult(Integer.parseInt(parameter.split(":@:", 2)[0]), false, "", 0); + } catch (NumberFormatException e) { + Log.e(TAG, "Bad inputbox parameter: " + parameter); + } + } + return; + } if (!processCommand(command, parameter)) { Log.e(TAG, "processCommand failed: cmd: '" + command + "' param: '" + parameter + "'"); } From 2123e7fdacd6a0d13fb7d346c1940755d040082e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sat, 10 Oct 2026 15:45:27 +0200 Subject: [PATCH 2/7] Android: Tie emulation and audio to visibility, not focus onPause used to join the render thread and stop the audio, so anything taking focus while the game stayed on screen (a permission dialog, a share sheet, the other app of a split screen before Android 10) tore down the whole graphics context and silenced the game. Emulation, audio, sensors and input listeners now follow onStart/onStop. The render loop itself already follows the surface; onStop joins it too, for devices that keep a stopped activity's surface. The camera stays on onPause/onResume, as it goes to the focused app. A finishing activity lets go of the render thread in onPause, since a quick relaunch creates the next activity before this one's onStop, and that onStop then leaves the audio and location updates alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/org/ppsspp/ppsspp/PpssppActivity.java | 90 ++++++++++++------- 1 file changed, 57 insertions(+), 33 deletions(-) diff --git a/android/src/org/ppsspp/ppsspp/PpssppActivity.java b/android/src/org/ppsspp/ppsspp/PpssppActivity.java index fa9f3a9859..73b27323ba 100644 --- a/android/src/org/ppsspp/ppsspp/PpssppActivity.java +++ b/android/src/org/ppsspp/ppsspp/PpssppActivity.java @@ -989,16 +989,64 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList Log.i(TAG, "onDestroy end"); } + // Emulation, audio and input follow visibility (onStart/onStop) rather than focus (onPause/onResume): a + // permission dialog on top, or the other app of a split screen having focus, leaves the game on screen, + // and it should keep running and playing. The render loop is really owned by the surface (notifySurface), + // which normally goes away with the visibility anyway. @Override protected void onStart() { super.onStart(); - Log.i(TAG, "onStart"); + if (m_hasNoNativeBinary) { + return; + } + Log.i(TAG, "onStart begin"); + + if (mLocationHelper != null) { + mLocationHelper.resume(); + } + + updateAudioFocus(this.audioManager, this.audioFocusChangeListener); + NativeApp.resume(); + mSensorManager.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_GAME); + + InputManager inputManager = (InputManager)getSystemService(Context.INPUT_SERVICE); + inputManager.registerInputDeviceListener(inputDeviceListener, null); + + // In case the surface outlived the stop. Otherwise this waits for surfaceCreated. + startRenderLoopThread(); + Log.i(TAG, "onStart end"); } @Override protected void onStop() { super.onStop(); - Log.i(TAG, "onStop"); + if (m_hasNoNativeBinary) { + return; + } + Log.i(TAG, "onStop begin"); + + InputManager inputManager = (InputManager)getSystemService(Context.INPUT_SERVICE); + inputManager.unregisterInputDeviceListener(inputDeviceListener); + + // Normally surfaceDestroyed has done this already, but not every device destroys the surface of a + // stopped activity, and an invisible game shouldn't keep running. + Log.i(TAG, "Joining render thread..."); + joinRenderLoopThread(); + Log.i(TAG, "Joined render thread"); + + mSensorManager.unregisterListener(this); + loseAudioFocus(this.audioManager, this.audioFocusChangeListener); + + // After a finish() and a quick relaunch, this comes after the new activity's onStart, and the audio and + // the location updates are its now. + if (instance == latestInstance) { + Log.i(TAG, "Calling NativeApp.pause..."); + NativeApp.pause(); + if (mLocationHelper != null) { + mLocationHelper.pause(); + } + } + Log.i(TAG, "onStop end"); } @Override @@ -1008,27 +1056,17 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList return; } Log.i(TAG, "onPause begin"); - - InputManager inputManager = (InputManager)getSystemService(Context.INPUT_SERVICE); - inputManager.unregisterInputDeviceListener(inputDeviceListener); - - Log.i(TAG, "Joining render thread..."); - joinRenderLoopThread(); - Log.i(TAG, "Joined render thread"); - - Log.i(TAG, "mSensorManager.unregisterListener..."); - mSensorManager.unregisterListener(this); - - loseAudioFocus(this.audioManager, this.audioFocusChangeListener); - Log.i(TAG, "Calling NativeApp.pause..."); - NativeApp.pause(); + if (isFinishing()) { + // Gone for good. A quick relaunch creates the next activity before this one's onStop, and the + // render thread has to be free for it by then. + Log.i(TAG, "Finishing, joining render thread..."); + joinRenderLoopThread(); + } + // The camera goes to whichever app has focus. if (mCameraHelper != null) { Log.i(TAG, "Calling mCameraHelper.pause"); mCameraHelper.pause(); } - if (mLocationHelper != null) { - mLocationHelper.pause(); - } Log.i(TAG, "onPause end"); } @@ -1049,20 +1087,6 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList if (mCameraHelper != null) { mCameraHelper.resume(); } - if (mLocationHelper != null) { - mLocationHelper.resume(); - } - - updateAudioFocus(this.audioManager, this.audioFocusChangeListener); - NativeApp.resume(); - mSensorManager.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_GAME); - - InputManager inputManager = - (InputManager)getSystemService(Context.INPUT_SERVICE); - inputManager.registerInputDeviceListener(inputDeviceListener, null); - - // Restart the render loop. - startRenderLoopThread(); Log.i(TAG, "onResume end"); } From 34da53de5a2ab4cd169b01ef6952f5c6ed1b3804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sat, 10 Oct 2026 15:45:27 +0200 Subject: [PATCH 3/7] Android GL: Support PresentMode::IMMEDIATE with eglSwapInterval Now that EGL is ours rather than GLSurfaceView's, the swap interval can be set, so turning vsync off (and fast-forward without skipping flips) works on Android GL too. Not in VR, where OpenXR presents, and not with a config whose minimum swap interval is 1. Co-Authored-By: Claude Opus 5.5 (1M context) --- Common/GPU/OpenGL/OpenGLGraphicsContext.cpp | 2 +- Common/GPU/OpenGL/OpenGLGraphicsContext.h | 3 +++ android/jni/AndroidEGLGraphicsContext.cpp | 25 +++++++++++++++++---- android/jni/AndroidEGLGraphicsContext.h | 3 +++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/Common/GPU/OpenGL/OpenGLGraphicsContext.cpp b/Common/GPU/OpenGL/OpenGLGraphicsContext.cpp index 796255d0b9..0cd10b5036 100644 --- a/Common/GPU/OpenGL/OpenGLGraphicsContext.cpp +++ b/Common/GPU/OpenGL/OpenGLGraphicsContext.cpp @@ -18,7 +18,7 @@ OpenGLGraphicsContext::OpenGLGraphicsContext() { bool OpenGLGraphicsContext::InitSurface(WindowSystem winsys, void *data1, void *data2, std::string *errorMessage) { CheckGLExtensions(); INFO_LOG(Log::G3D, "OpenGLGraphicsContext::InitSurface"); - draw_ = Draw::T3DCreateGLContext(false); // Can't fail + draw_ = Draw::T3DCreateGLContext(CanChangeSwapInterval()); // Can't fail renderManager_ = (GLRenderManager *)draw_->GetNativeObject(Draw::NativeObject::RENDER_MANAGER); // TODO: In VR mode, we don't want a lot of inflight frames. renderManager_->SetInflightFrames(g_Config.iInflightFrames); diff --git a/Common/GPU/OpenGL/OpenGLGraphicsContext.h b/Common/GPU/OpenGL/OpenGLGraphicsContext.h index 76895759e4..d39cecf286 100644 --- a/Common/GPU/OpenGL/OpenGLGraphicsContext.h +++ b/Common/GPU/OpenGL/OpenGLGraphicsContext.h @@ -40,6 +40,9 @@ public: } protected: + // Whether the subclass can set the swap interval (SetSwapIntervalFunction), which PresentMode::IMMEDIATE needs. + virtual bool CanChangeSwapInterval() const { return false; } + // Protected rather than private so a subclass that owns the real context (see // android/jni/AndroidEGLGraphicsContext.h) can hook up a swap function. Draw::DrawContext *draw_ = nullptr; diff --git a/android/jni/AndroidEGLGraphicsContext.cpp b/android/jni/AndroidEGLGraphicsContext.cpp index 962a093609..70d829203c 100644 --- a/android/jni/AndroidEGLGraphicsContext.cpp +++ b/android/jni/AndroidEGLGraphicsContext.cpp @@ -208,14 +208,31 @@ bool AndroidEGLGraphicsContext::InitSurface(WindowSystem winsys, void *data1, vo } }); - // NOTE: Deliberately *not* calling SetSwapIntervalFunction. GLSurfaceView gave us no way to - // change the interval, so PresentMode::IMMEDIATE was always a no-op on Android GL, and this - // class set out to behave identically. Now that it's the only path, eglSwapInterval(0) is - // there for the taking - but that's a behavior change, so it wants its own commit. + // For PresentMode::IMMEDIATE (vsync off, and fast-forward without skipping flips). + if (CanChangeSwapInterval()) { + renderManager_->SetSwapIntervalFunction([this](int interval) { + if (!eglSwapInterval(display_, interval)) { + WARN_LOG(Log::G3D, "%s", EGLFailure("eglSwapInterval").c_str()); + } + }); + } return true; } +bool AndroidEGLGraphicsContext::CanChangeSwapInterval() const { + // In VR, OpenXR presents, not eglSwapBuffers. + if (IsVREnabled() || display_ == EGL_NO_DISPLAY) { + return false; + } + // eglSwapInterval silently clamps to the config's minimum, which some configs have at 1. + EGLint minInterval = 1; + if (!eglGetConfigAttrib(display_, config_, EGL_MIN_SWAP_INTERVAL, &minInterval)) { + return false; + } + return minInterval == 0; +} + void AndroidEGLGraphicsContext::ShutdownSurface() { // Order matters: the render manager's teardown still issues GL calls, so the context has to // stay current until it's done. diff --git a/android/jni/AndroidEGLGraphicsContext.h b/android/jni/AndroidEGLGraphicsContext.h index dde60aad60..cc842a6f33 100644 --- a/android/jni/AndroidEGLGraphicsContext.h +++ b/android/jni/AndroidEGLGraphicsContext.h @@ -44,6 +44,9 @@ public: bool InitSurface(WindowSystem winsys, void *data1, void *data2, std::string *errorMessage) override; void ShutdownSurface() override; +protected: + bool CanChangeSwapInterval() const override; + private: // Picks a config, from the most to the least desirable. Fills in config_. bool ChooseConfig(bool wantGLES3, std::string *errorMessage); From 43bd05cd70396b8286013d4eb48f8c57135d11d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sat, 10 Oct 2026 15:35:47 +0200 Subject: [PATCH 4/7] Headless: Say so when a game fails to boot, and exit with an error Outside --compare, a boot failure printed nothing (only a GitHub Actions annotation) and exited 0, which looks like a run that did nothing. Seen as rare silent zero-output runs in headless_bench.py. Co-Authored-By: Claude Opus 5.5 (1M context) --- headless/Headless.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/headless/Headless.cpp b/headless/Headless.cpp index 981bb6bad8..dc7936fb69 100644 --- a/headless/Headless.cpp +++ b/headless/Headless.cpp @@ -91,6 +91,8 @@ static std::string g_debugOutputBuffer; // Set when a run was asked for a configuration that couldn't be honoured. That isn't a test result, // so it fails the process whether or not this run was comparing anything. static bool g_configRefused = false; +// Set when a test or game failed to boot. Like g_configRefused, this fails the process even when not comparing. +static bool g_bootFailed = false; static bool g_writeFailureScreenshot = true; static bool g_writeDebugOutput = true; // Whether the emulated program's stdout/stderr are forwarded to ours. On by default - just running @@ -445,7 +447,10 @@ static bool RunAutoTest(GraphicsContext *graphicsContext, CoreParameter &corePar } if (!PSP_IsInited()) { + // Without this, a failed boot outside --compare printed nothing and exited 0. + fprintf(stderr, "Failed to boot '%s': %s\n", coreParameter.fileToStart.c_str(), error_string.c_str()); GitHubActionsPrint("error", "Test init failed for %s", currentTestName.c_str()); + g_bootFailed = true; return false; } @@ -729,7 +734,7 @@ int RunTests(GraphicsContext *graphicsContext, CoreParameter &coreParameter, con } } - if (g_configRefused) { + if (g_configRefused || g_bootFailed) { return 1; } From d35cdf8fcf9fe607f346cd5069bf842dc5d95447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sat, 10 Oct 2026 15:59:16 +0200 Subject: [PATCH 5/7] Android: Track the activity that owns the native side by identity, and log changes Replaces the instance counter with a weak reference to the current activity, and says in the log when an activity takes over, and when a replaced one skips something because the native side isn't its anymore. The render loop state is static now, with the activity whose surface it runs on: there's one render thread. An activity only stops the loop if it runs on its own surface, and one that finds another activity's loop still running logs an error and stops it before starting its own. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/org/ppsspp/ppsspp/PpssppActivity.java | 147 +++++++++++------- 1 file changed, 95 insertions(+), 52 deletions(-) diff --git a/android/src/org/ppsspp/ppsspp/PpssppActivity.java b/android/src/org/ppsspp/ppsspp/PpssppActivity.java index 73b27323ba..a26b0307c3 100644 --- a/android/src/org/ppsspp/ppsspp/PpssppActivity.java +++ b/android/src/org/ppsspp/ppsspp/PpssppActivity.java @@ -68,6 +68,7 @@ import java.util.List; import java.util.Locale; import java.io.File; import java.io.InputStream; +import java.lang.ref.WeakReference; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -77,10 +78,14 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList // Allows us to skip a lot of initialization on secondary calls to onCreate. private static boolean initialized = false; - // Bumped by every onCreate. An activity whose number is no longer the latest has been replaced - // by a newer one, which now owns the native state. - private static int latestInstance = 0; - private int instance; + // The activity that owns the native side (the render thread, the audio). Usually the only one alive, but + // not always: after a finish() and a quick relaunch, Android creates and resumes the new activity before + // the old one's onStop and onDestroy, which then must leave the native side alone. Weak, so this alone + // doesn't keep a destroyed activity around. + private static WeakReference currentActivity = new WeakReference<>(null); + // Numbers the activities, for the log. + private static int activityCounter = 0; + private int activityNumber = 0; // The surface we hand to the native render loop thread, for both OpenGL and Vulkan. private NativeSurfaceView mSurfaceView; @@ -108,8 +113,10 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList // switched-away from or rotated etc. private boolean shuttingDown; - // surfaceChanged now also fires for rotations and resizes, where the thread just keeps going. - private boolean renderLoopRunning = false; + // There's one render loop thread. Whether it runs, and on which activity's surface (activityNumber). + // surfaceChanged also fires for rotations and resizes, where the thread just keeps going. + private static boolean renderLoopRunning = false; + private static int renderLoopActivity = 0; // Allow for multiple connected gamepads but just consider them the same for now. // Actually this is not entirely true, see the code. @@ -638,6 +645,35 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList surfaceManager.updateDisplayMeasurements(); } + // Makes this the activity that owns the native side. + private void takeOver() { + activityNumber = ++activityCounter; + PpssppActivity previous = currentActivity.get(); + currentActivity = new WeakReference<>(this); + if (previous == null) { + Log.i(TAG, "Activity #" + activityNumber + " created" + (initialized ? ", adopting the native side left running" : "")); + } else if (previous.isDestroyed()) { + Log.i(TAG, "Activity #" + activityNumber + " replaces #" + previous.activityNumber + ", which is destroyed"); + } else { + // The finish() and quick relaunch case. Fine, but the old one's lifecycle calls from here on are worth seeing. + Log.w(TAG, "Activity #" + activityNumber + " replaces #" + previous.activityNumber + ", which is still alive (finishing: " + previous.isFinishing() + ")"); + } + } + + private boolean isCurrent() { + return currentActivity.get() == this; + } + + // True if this activity owns the native side, otherwise logs that it skipped what. + private boolean checkCurrent(String what) { + if (isCurrent()) { + return true; + } + PpssppActivity current = currentActivity.get(); + Log.w(TAG, "Activity #" + activityNumber + ": skipping " + what + ", " + (current != null ? "#" + current.activityNumber : "no activity") + " owns the native side now"); + return false; + } + // Starts the native render loop thread, which owns the graphics context and its surface. public native boolean runRenderLoop(Surface surface); // Tells the render loop thread to exit, so we can restart it. @@ -659,7 +695,7 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList return; } - instance = ++latestInstance; + takeOver(); WindowCompat.setDecorFitsSystemWindows(getWindow(), false); @@ -884,8 +920,8 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList Log.i(TAG, "notifySurface: got surface, starting thread."); startRenderLoopThread(); } else { - // The surface must not be touched once surfaceDestroyed returns. Normally onPause has - // already joined the thread and this does nothing, but that order isn't guaranteed. + // The surface must not be touched once surfaceDestroyed returns. This is what normally stops the + // render loop; onStop does it too, for a stopped activity that keeps its surface. Log.i(TAG, "notifySurface: Surface is gone, making sure the render thread is too."); joinRenderLoopThread(); } @@ -893,39 +929,49 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList } // The render loop thread (EmuThread) is now spawned from the native side. - protected synchronized void startRenderLoopThread() { - if (mSurface == null) { - Log.w(TAG, "startRenderLoopThread - not starting thread, needs surface"); - return; - } - if (instance != latestInstance) { - Log.w(TAG, "startRenderLoopThread - not starting thread, a newer activity has taken over"); - return; - } + protected void startRenderLoopThread() { + synchronized (PpssppActivity.class) { + if (mSurface == null) { + Log.i(TAG, "Activity #" + activityNumber + ": not starting the render loop yet, no surface"); + return; + } + if (!checkCurrent("starting the render loop")) { + return; + } + if (renderLoopRunning) { + if (renderLoopActivity == activityNumber) { + Log.i(TAG, "Activity #" + activityNumber + ": render loop already running, surface changed in place"); + return; + } + // The previous activity should have stopped it when it finished or lost its surface. + Log.e(TAG, "Activity #" + activityNumber + ": the render loop is still running on #" + renderLoopActivity + "'s surface. Stopping it first."); + requestExitRenderLoop(); + renderLoopRunning = false; + } - if (renderLoopRunning) { - Log.i(TAG, "startRenderLoopThread: already running, surface changed in place"); - return; + Log.i(TAG, "Activity #" + activityNumber + ": starting the render loop"); + applyFrameRate(mSurface, 60.0f); + renderLoopRunning = runRenderLoop(mSurface); + renderLoopActivity = activityNumber; } - - Log.w(TAG, "startRenderLoopThread: Starting thread"); - - applyFrameRate(mSurface, 60.0f); - renderLoopRunning = runRenderLoop(mSurface); } - private synchronized void joinRenderLoopThread() { - if (instance != latestInstance) { - // There's only one render thread, and it's rendering to the newer activity's surface now. - // We get here when our surface goes away late, after a finish() and a quick relaunch. - Log.w(TAG, "joinRenderLoopThread - leaving the thread alone, a newer activity has taken over"); - return; + // Stops the render loop if it's running on this activity's surface. Waits until the thread has exited. + private void joinRenderLoopThread() { + synchronized (PpssppActivity.class) { + if (!renderLoopRunning) { + return; + } + if (renderLoopActivity != activityNumber) { + // After a finish() and a quick relaunch, the old activity's surface goes away late, with the + // loop already running on the new one's. + Log.i(TAG, "Activity #" + activityNumber + ": leaving the render loop alone, it's running on #" + renderLoopActivity + "'s surface"); + return; + } + Log.i(TAG, "Activity #" + activityNumber + ": stopping the render loop"); + requestExitRenderLoop(); + renderLoopRunning = false; } - - // This will wait until the thread has exited. - Log.i(TAG, "requestExitRenderLoop"); - requestExitRenderLoop(); - renderLoopRunning = false; } @Override @@ -957,14 +1003,11 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList inputDialog = null; } - if (instance != latestInstance) { - // After finish(), Android is free to deliver onDestroy late, after it has already created - // and resumed a new activity if the user was quick to relaunch. That one found initialized - // still set and adopted the native state, so shutting down here would pull the audio and - // the graphics context out from under it. It's not ours to shut down anymore. - // (recreate() is not affected, it destroys the old activity before creating the new one.) - Log.w(TAG, "in onDestroy, but a newer activity has taken over. Leaving the native side alone."); - } else { + // After finish(), Android is free to deliver onDestroy late, after it has already created and resumed a + // new activity if the user was quick to relaunch. That one found initialized still set and adopted the + // native state, so shutting down here would pull the audio and the graphics context out from under it. + // (recreate() is not affected, it destroys the old activity before creating the new one.) + if (checkCurrent("shutting down audio and the native side in onDestroy")) { // TODO: Can we ensure that the GL thread has stopped rendering here? // I've seen crashes that seem to indicate that sometimes it hasn't... NativeApp.audioShutdown(); @@ -1001,12 +1044,14 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList } Log.i(TAG, "onStart begin"); - if (mLocationHelper != null) { - mLocationHelper.resume(); + // A replaced activity shouldn't be started again, but if it is, the native side isn't its to resume. + if (checkCurrent("resuming audio and location in onStart")) { + if (mLocationHelper != null) { + mLocationHelper.resume(); + } + NativeApp.resume(); } - updateAudioFocus(this.audioManager, this.audioFocusChangeListener); - NativeApp.resume(); mSensorManager.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_GAME); InputManager inputManager = (InputManager)getSystemService(Context.INPUT_SERVICE); @@ -1030,16 +1075,14 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList // Normally surfaceDestroyed has done this already, but not every device destroys the surface of a // stopped activity, and an invisible game shouldn't keep running. - Log.i(TAG, "Joining render thread..."); joinRenderLoopThread(); - Log.i(TAG, "Joined render thread"); mSensorManager.unregisterListener(this); loseAudioFocus(this.audioManager, this.audioFocusChangeListener); // After a finish() and a quick relaunch, this comes after the new activity's onStart, and the audio and // the location updates are its now. - if (instance == latestInstance) { + if (checkCurrent("pausing audio and location in onStop")) { Log.i(TAG, "Calling NativeApp.pause..."); NativeApp.pause(); if (mLocationHelper != null) { From 77a6abfe6e6e4bd0227c7fce83d0ba18c1003d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sat, 10 Oct 2026 15:59:42 +0200 Subject: [PATCH 6/7] Add System_ShareFile, for handing a file to another app Offers a file through the system share sheet, for things like sending a zipped savestate to a friend. Android for now (SYSPROP_SUPPORTS_SHARE_FILE): content:// paths from a picked folder are shared as they are, and plain paths through a FileProvider. Either way the receiving app can read just that file. With no MIME type given, it's guessed from the extension. Co-Authored-By: Claude Opus 5.5 (1M context) --- Common/System/Request.cpp | 1 + Common/System/Request.h | 6 +++ Common/System/System.h | 2 + android/AndroidManifest.xml | 10 +++++ android/jni/app-android.cpp | 5 +++ android/res/xml/file_paths.xml | 10 +++++ .../src/org/ppsspp/ppsspp/PpssppActivity.java | 43 +++++++++++++++++++ 7 files changed, 77 insertions(+) create mode 100644 android/res/xml/file_paths.xml diff --git a/Common/System/Request.cpp b/Common/System/Request.cpp index 4dcbe98b9c..a37ffebed1 100644 --- a/Common/System/Request.cpp +++ b/Common/System/Request.cpp @@ -34,6 +34,7 @@ const char *RequestTypeAsString(SystemRequestType type) { case SystemRequestType::RECREATE_ACTIVITY: return "RECREATE_ACTIVITY"; case SystemRequestType::COPY_TO_CLIPBOARD: return "COPY_TO_CLIPBOARD"; case SystemRequestType::SHARE_TEXT: return "SHARE_TEXT"; + case SystemRequestType::SHARE_FILE: return "SHARE_FILE"; case SystemRequestType::SET_WINDOW_TITLE: return "SET_WINDOW_TITLE"; case SystemRequestType::APPLY_FULLSCREEN_STATE: return "SET_FULLSCREEN_STATE"; case SystemRequestType::GRAPHICS_BACKEND_FAILED_ALERT: return "GRAPHICS_BACKEND_FAILED_ALERT"; diff --git a/Common/System/Request.h b/Common/System/Request.h index 9ba9bb5b61..aec7d1d4b7 100644 --- a/Common/System/Request.h +++ b/Common/System/Request.h @@ -176,6 +176,12 @@ inline void System_ShareText(std::string_view text) { g_requestManager.MakeSystemRequest(SystemRequestType::SHARE_TEXT, NO_REQUESTER_TOKEN, nullptr, nullptr, text, "", 0); } +// Offers a file to other apps (mail, chat, cloud storage) through the system's share sheet, if +// SYSPROP_SUPPORTS_SHARE_FILE. With an empty mimeType, it's guessed from the extension. +inline void System_ShareFile(const Path &path, std::string_view mimeType = "") { + g_requestManager.MakeSystemRequest(SystemRequestType::SHARE_FILE, NO_REQUESTER_TOKEN, nullptr, nullptr, path.ToString(), mimeType, 0); +} + inline void System_NotifyUIEvent(UIEventNotification notification) { g_requestManager.MakeSystemRequest(SystemRequestType::NOTIFY_UI_EVENT, NO_REQUESTER_TOKEN, nullptr, nullptr, "", "", (int64_t)notification, 0); } diff --git a/Common/System/System.h b/Common/System/System.h index 25c8813791..a485b41fd1 100644 --- a/Common/System/System.h +++ b/Common/System/System.h @@ -80,6 +80,7 @@ enum class SystemRequestType { RECREATE_ACTIVITY, // Android COPY_TO_CLIPBOARD, SHARE_TEXT, + SHARE_FILE, SET_WINDOW_TITLE, APPLY_FULLSCREEN_STATE, GRAPHICS_BACKEND_FAILED_ALERT, @@ -239,6 +240,7 @@ enum SystemProperty { SYSPROP_USE_IAP, SYSPROP_USE_APP_STORE, SYSPROP_SUPPORTS_SHARE_TEXT, + SYSPROP_SUPPORTS_SHARE_FILE, SYSPROP_CAN_RESTRICT_ORIENTATION, diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index f715a4c4da..8767a91c47 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -126,6 +126,16 @@ + + + + diff --git a/android/jni/app-android.cpp b/android/jni/app-android.cpp index f1d81bd676..46ad677ea7 100644 --- a/android/jni/app-android.cpp +++ b/android/jni/app-android.cpp @@ -404,6 +404,7 @@ bool System_GetPropertyBool(SystemProperty prop) { case SYSPROP_SUPPORTS_OPEN_FILE_IN_EDITOR: return false; // Update if we add support in FileUtil.cpp: OpenFileInEditor case SYSPROP_SUPPORTS_SHARE_TEXT: + case SYSPROP_SUPPORTS_SHARE_FILE: return true; case SYSPROP_APP_GOLD: #ifdef GOLD @@ -1068,6 +1069,10 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string case SystemRequestType::SHARE_TEXT: PushCommand("share_text", param1); return true; + case SystemRequestType::SHARE_FILE: + // The MIME type first, as a path could contain the separator. + PushCommand("share_file", param2 + ":@:" + param1); + return true; case SystemRequestType::SET_KEEP_SCREEN_BRIGHT: PushCommand("set_keep_screen_bright", param3 ? "on" : "off"); return true; diff --git a/android/res/xml/file_paths.xml b/android/res/xml/file_paths.xml new file mode 100644 index 0000000000..920dbc7e7c --- /dev/null +++ b/android/res/xml/file_paths.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/android/src/org/ppsspp/ppsspp/PpssppActivity.java b/android/src/org/ppsspp/ppsspp/PpssppActivity.java index a26b0307c3..303fbbc3ff 100644 --- a/android/src/org/ppsspp/ppsspp/PpssppActivity.java +++ b/android/src/org/ppsspp/ppsspp/PpssppActivity.java @@ -46,6 +46,7 @@ import android.view.Window; import android.view.WindowManager; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; +import android.webkit.MimeTypeMap; import android.widget.EditText; import android.widget.FrameLayout; import android.widget.Toast; @@ -54,6 +55,7 @@ import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.FileProvider; import androidx.core.graphics.Insets; import androidx.core.view.DisplayCutoutCompat; import androidx.core.view.ViewCompat; @@ -1667,6 +1669,13 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList Log.e(TAG, e.toString()); return false; } + } else if (command.equals("share_file")) { + String[] parts = params.split(":@:", 2); + if (parts.length != 2) { + Log.e(TAG, "share_file: bad parameter '" + params + "'"); + return false; + } + return shareFile(parts[1], parts[0]); } else if (command.equals("toast")) { Toast toast = Toast.makeText(this, params, Toast.LENGTH_LONG); toast.show(); @@ -1887,6 +1896,40 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList return false; } + // Offers a file to other apps through the share sheet. The receiver gets read access to this file only. + private boolean shareFile(String path, String mimeType) { + try { + Uri uri; + if (path.startsWith("content://")) { + // In a folder picked through the storage access framework. Our access to it can be passed on. + uri = Uri.parse(path); + } else { + uri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", new File(path)); + } + if (mimeType.isEmpty()) { + // Document URIs have the path encoded, so decode before looking for the extension. + String decoded = Uri.decode(path); + String extension = MimeTypeMap.getFileExtensionFromUrl(Uri.encode(decoded.substring(decoded.lastIndexOf('/') + 1))); + String guessed = extension.isEmpty() ? null : MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase(Locale.ROOT)); + mimeType = guessed != null ? guessed : "application/octet-stream"; + } + Log.i(TAG, "Sharing " + uri + " as " + mimeType); + Intent send = new Intent(Intent.ACTION_SEND); + send.setType(mimeType); + send.putExtra(Intent.EXTRA_STREAM, uri); + // Through a chooser, the read grant only reaches the chosen app via the ClipData. + send.setClipData(ClipData.newRawUri("", uri)); + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(send, null)); + return true; + } catch (Exception e) { + // IllegalArgumentException from FileProvider: a path outside file_paths.xml. + Log.e(TAG, "shareFile failed for " + path + ": " + e); + NativeApp.reportException(e, path); + return false; + } + } + public static boolean isVRDevice() { return BuildConfig.FLAVOR.startsWith("vr"); } From b9a706432a47a152446163449292a8421055e1a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sat, 10 Oct 2026 16:06:55 +0200 Subject: [PATCH 7/7] Add ShareFilesAsZip, for sharing several files as one zip Zips the given files (content:// paths included) into a folder of their own in the app's cache and offers the zip through System_ShareFile. The receiving app reads it whenever it likes, so it can't be deleted right away: each share deletes the folders of earlier shares once they're an hour old. Co-Authored-By: Claude Opus 5.5 (1M context) --- UI/CMakeLists.txt | 2 + UI/ShareFiles.cpp | 130 ++++++++++++++++++++++++++++++ UI/ShareFiles.h | 41 ++++++++++ UI/UI.vcxproj | 2 + UI/UI.vcxproj.filters | 2 + UWP/UI_UWP/UI_UWP.vcxproj | 2 + UWP/UI_UWP/UI_UWP.vcxproj.filters | 2 + android/jni/Android.mk | 1 + 8 files changed, 182 insertions(+) create mode 100644 UI/ShareFiles.cpp create mode 100644 UI/ShareFiles.h diff --git a/UI/CMakeLists.txt b/UI/CMakeLists.txt index d163777075..5a65eb21f8 100644 --- a/UI/CMakeLists.txt +++ b/UI/CMakeLists.txt @@ -91,6 +91,8 @@ list(APPEND UISource ReportScreen.cpp SavedataScreen.h SavedataScreen.cpp + ShareFiles.h + ShareFiles.cpp SystemInfoScreen.h SystemInfoScreen.cpp Store.h diff --git a/UI/ShareFiles.cpp b/UI/ShareFiles.cpp new file mode 100644 index 0000000000..2b62f65bef --- /dev/null +++ b/UI/ShareFiles.cpp @@ -0,0 +1,130 @@ +// Copyright (c) 2012- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#ifdef SHARED_LIBZIP +#include +#else +#include "ext/libzip/zip.h" +#endif + +#include + +#include "Common/File/DirListing.h" +#include "Common/File/FileUtil.h" +#include "Common/Log.h" +#include "Common/StringUtils.h" +#include "Common/System/Request.h" +#include "Common/System/System.h" +#include "Common/TimeUtil.h" +#include "Core/Config.h" +#include "UI/ShareFiles.h" + +// How long a shared zip is kept for the receiving app to read. +static constexpr int64_t SHARE_KEEP_SECONDS = 60 * 60; + +static Path ShareDirectory() { + return g_Config.appCacheDirectory / "share"; +} + +// Deletes the folders of earlier shares (named by their time) once they're old enough. +static void CleanUpOldShares(int64_t now) { + std::vector folders; + File::GetFilesInDir(ShareDirectory(), &folders); + for (const File::FileInfo &folder : folders) { + if (!folder.isDirectory) { + continue; + } + const int64_t created = strtoll(folder.name.c_str(), nullptr, 10); + if (created <= 0 || now - created > SHARE_KEEP_SECONDS) { + INFO_LOG(Log::System, "Deleting old shared files in %s", folder.fullName.c_str()); + File::DeleteDirRecursively(folder.fullName); + } + } +} + +bool CanShareFiles() { + return System_GetPropertyBool(SYSPROP_SUPPORTS_SHARE_FILE) && !g_Config.appCacheDirectory.empty(); +} + +bool ShareFilesAsZip(std::string_view zipName, const std::vector &files) { + if (!CanShareFiles() || files.empty()) { + return false; + } + + const int64_t now = (int64_t)time_now_unix_utc(); + CleanUpOldShares(now); + + std::string name = SanitizeString(zipName, StringRestriction::FileName); + if (!endsWithNoCase(name, ".zip")) { + name += ".zip"; + } + // A folder per share, so a share can't overwrite a zip that an app is still reading. + Path folder = ShareDirectory() / StringFromFormat("%lld", (long long)now); + for (int i = 1; File::Exists(folder); i++) { + folder = ShareDirectory() / StringFromFormat("%lld_%d", (long long)now, i); + } + if (!File::CreateFullPath(folder)) { + ERROR_LOG(Log::System, "ShareFilesAsZip: Couldn't create %s", folder.c_str()); + return false; + } + const Path zipPath = folder / name; + + // libzip reads the buffers when the archive is closed, so they have to live until then. + std::vector contents(files.size()); + int error = 0; + zip_t *zip = zip_open(zipPath.c_str(), ZIP_CREATE | ZIP_TRUNCATE, &error); + if (!zip) { + ERROR_LOG(Log::System, "ShareFilesAsZip: Couldn't create %s (libzip error %d)", zipPath.c_str(), error); + File::DeleteDirRecursively(folder); + return false; + } + bool success = true; + for (size_t i = 0; i < files.size(); i++) { + const ShareZipEntry &entry = files[i]; + const std::string entryName = entry.nameInZip.empty() ? entry.source.GetFilename() : entry.nameInZip; + if (!File::ReadBinaryFileToString(entry.source, &contents[i])) { + ERROR_LOG(Log::System, "ShareFilesAsZip: Couldn't read %s", entry.source.c_str()); + success = false; + break; + } + zip_source_t *source = zip_source_buffer(zip, contents[i].data(), contents[i].size(), 0); + // Fails on a name that's already in the zip, too. + if (!source || zip_file_add(zip, entryName.c_str(), source, ZIP_FL_ENC_UTF_8) < 0) { + ERROR_LOG(Log::System, "ShareFilesAsZip: Couldn't add %s as '%s': %s", entry.source.c_str(), entryName.c_str(), zip_strerror(zip)); + if (source) { + zip_source_free(source); + } + success = false; + break; + } + } + if (!success) { + zip_discard(zip); + File::DeleteDirRecursively(folder); + return false; + } + if (zip_close(zip) != 0) { + ERROR_LOG(Log::System, "ShareFilesAsZip: Couldn't write %s: %s", zipPath.c_str(), zip_strerror(zip)); + zip_discard(zip); + File::DeleteDirRecursively(folder); + return false; + } + + INFO_LOG(Log::System, "ShareFilesAsZip: Sharing %s, %d files", zipPath.c_str(), (int)files.size()); + System_ShareFile(zipPath, "application/zip"); + return true; +} diff --git a/UI/ShareFiles.h b/UI/ShareFiles.h new file mode 100644 index 0000000000..37ecfeca65 --- /dev/null +++ b/UI/ShareFiles.h @@ -0,0 +1,41 @@ +// Copyright (c) 2012- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#pragma once + +#include +#include +#include + +#include "Common/File/Path.h" + +struct ShareZipEntry { + Path source; // Any file we can read, content:// URIs included. + std::string nameInZip; // Empty for the source's own filename. +}; + +// Whether ShareFilesAsZip can work here (the platform can share files). +bool CanShareFiles(); + +// Zips the files up and offers the zip to other apps through the system share sheet (System_ShareFile), +// for example a savestate and its screenshot, to send to a friend. zipName is what the receiver sees, and is +// sanitized. False if the zip couldn't be made, or the platform can't share files. +// +// The zip is written to a folder of its own in the app's cache, since the receiving app reads it whenever +// it gets around to it, and deleted by a later call once it's an hour old. The files are read into memory +// and compressed on the calling thread, which is fine for savestates but not for whole ISOs. +bool ShareFilesAsZip(std::string_view zipName, const std::vector &files); diff --git a/UI/UI.vcxproj b/UI/UI.vcxproj index 13d1600cd5..ac51073540 100644 --- a/UI/UI.vcxproj +++ b/UI/UI.vcxproj @@ -71,6 +71,7 @@ + @@ -133,6 +134,7 @@ + diff --git a/UI/UI.vcxproj.filters b/UI/UI.vcxproj.filters index 1d4f450d53..c217d3340e 100644 --- a/UI/UI.vcxproj.filters +++ b/UI/UI.vcxproj.filters @@ -53,6 +53,7 @@ Screens + Screens @@ -213,6 +214,7 @@ Screens + Screens diff --git a/UWP/UI_UWP/UI_UWP.vcxproj b/UWP/UI_UWP/UI_UWP.vcxproj index de59f9088d..e24117cb7a 100644 --- a/UWP/UI_UWP/UI_UWP.vcxproj +++ b/UWP/UI_UWP/UI_UWP.vcxproj @@ -136,6 +136,7 @@ + @@ -199,6 +200,7 @@ + diff --git a/UWP/UI_UWP/UI_UWP.vcxproj.filters b/UWP/UI_UWP/UI_UWP.vcxproj.filters index a2cc4904b0..020da9187b 100644 --- a/UWP/UI_UWP/UI_UWP.vcxproj.filters +++ b/UWP/UI_UWP/UI_UWP.vcxproj.filters @@ -3,6 +3,7 @@ + @@ -156,6 +157,7 @@ + diff --git a/android/jni/Android.mk b/android/jni/Android.mk index b07221a926..1599ce8826 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -1045,6 +1045,7 @@ LOCAL_SRC_FILES := \ $(SRC)/UI/ReportScreen.cpp \ $(SRC)/UI/PauseScreen.cpp \ $(SRC)/UI/SavedataScreen.cpp \ + $(SRC)/UI/ShareFiles.cpp \ $(SRC)/UI/Store.cpp \ $(SRC)/UI/SystemInfoScreen.cpp \ $(SRC)/UI/GamepadEmu.cpp \