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/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/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/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/Android.mk b/android/jni/Android.mk index 2eb349aa07..e6cb709ae5 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -1042,6 +1042,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 \ 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); 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/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..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; @@ -68,6 +70,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 +80,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,14 +115,18 @@ 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. 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 +377,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 +396,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 +506,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 @@ -635,6 +647,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. @@ -656,7 +697,7 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList return; } - instance = ++latestInstance; + takeOver(); WindowCompat.setDecorFitsSystemWindows(getWindow(), false); @@ -690,6 +731,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) { @@ -879,8 +922,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(); } @@ -888,39 +931,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 @@ -946,15 +999,17 @@ 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 - // 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(); @@ -963,6 +1018,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."); } @@ -974,16 +1034,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"); + + // 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); + 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. + joinRenderLoopThread(); + + 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 (checkCurrent("pausing audio and location in onStop")) { + Log.i(TAG, "Calling NativeApp.pause..."); + NativeApp.pause(); + if (mLocationHelper != null) { + mLocationHelper.pause(); + } + } + Log.i(TAG, "onStop end"); } @Override @@ -993,27 +1101,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"); } @@ -1034,20 +1132,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"); } @@ -1424,7 +1508,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 +1536,7 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList } try { dlg.show(); + inputDialog = dlg; input.requestFocus(); } catch (Exception e) { NativeApp.reportException(e, "AlertDialog"); @@ -1578,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(); @@ -1798,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"); } @@ -1881,6 +2013,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 + "'"); } diff --git a/headless/Headless.cpp b/headless/Headless.cpp index a437bb0e60..15bb23ec55 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; }