From ad58640cdb01e18e242d3b2ae0b5349482ab5cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Wed, 11 Nov 2026 12:43:03 +0100 Subject: [PATCH] Frame dumps: Offer Share, Save or Continue where folders can't be shown On Android, Save picks a place with the system's save picker, through a new BROWSE_FOR_FILE_SAVE implementation (ACTION_CREATE_DOCUMENT). Co-Authored-By: Claude Opus 5.5 --- UI/DevScreens.cpp | 93 ++++++++++++++++++- android/jni/app-android.cpp | 4 + .../ppsspp/DocumentResultProxyActivity.java | 4 +- .../src/org/ppsspp/ppsspp/PpssppActivity.java | 24 +++++ 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/UI/DevScreens.cpp b/UI/DevScreens.cpp index e0e45a7425..7a5c8b7cba 100644 --- a/UI/DevScreens.cpp +++ b/UI/DevScreens.cpp @@ -33,6 +33,7 @@ #include "Common/Audio/AudioBackend.h" #include "Common/File/AndroidStorage.h" +#include "Common/File/DirListing.h" #include "Common/Data/Text/I18n.h" #include "Common/Data/Text/Parsers.h" #include "Common/Data/Encoding/Utf8.h" @@ -134,13 +135,101 @@ static void ShareFrameDump(const Path &dumpPath) { } } +static bool CopyFileContents(const Path &src, const Path &dest) { + FILE *in = File::OpenCFile(src, "rb"); + if (!in) { + return false; + } + // Content URIs can be written but not created this way, so this works on a document the picker made. + FILE *out = File::OpenCFile(dest, "wb"); + if (!out) { + fclose(in); + return false; + } + bool success = true; + char buffer[65536]; + size_t count; + while ((count = fread(buffer, 1, sizeof(buffer), in)) > 0) { + if (fwrite(buffer, 1, count, out) != count) { + success = false; + break; + } + } + if (ferror(in)) { + success = false; + } + fclose(in); + if (fclose(out) != 0) { + success = false; + } + return success; +} + +static bool CanSaveFileAs() { +#if PPSSPP_PLATFORM(ANDROID) + return System_GetPropertyBool(SYSPROP_HAS_FILE_BROWSER); +#else + return false; +#endif +} + +// Where a dump can't be shown in its folder: share it, save it somewhere the user picks, or neither. +class FrameDumpPopupScreen : public UI::PopupScreen { +public: + FrameDumpPopupScreen(const Path &dumpPath) : PopupScreen(dumpPath.GetFilename()), dumpPath_(dumpPath) {} + const char *tag() const override { return "FrameDumpPopup"; } + +protected: + bool ShowButtons() const override { return false; } + void CreatePopupContents(UI::ViewGroup *parent) override { + using namespace UI; + auto di = GetI18NCategory(I18NCat::DIALOG); + auto pa = GetI18NCategory(I18NCat::PAUSE); + if (CanShareFiles()) { + parent->Add(new Choice(di->T("Share"), ImageID("I_SHARE")))->OnClick.Add([this](UI::EventParams &e) { + ShareFrameDump(dumpPath_); + TriggerFinish(DR_OK); + }); + } + if (CanSaveFileAs()) { + parent->Add(new Choice(di->T("Save"), ImageID("I_FILE_SAVE")))->OnClick.Add([this, di](UI::EventParams &e) { + // Stays open until the picker answers, since the answer is dropped once the screen is gone. + System_BrowseForFileSave(GetRequesterToken(), di->T("Save"), dumpPath_.GetFilename(), BrowseFileType::ANY, [this](std::string_view value, int) { + auto sy = GetI18NCategory(I18NCat::SYSTEM); + const Path dest(value); + if (CopyFileContents(dumpPath_, dest)) { + // A picked document's URI ends in an ID, so ask for its name. + File::FileInfo info; + std::string name = dumpPath_.GetFilename(); + if (File::GetFileInfo(dest, &info) && !info.name.empty()) { + name = info.name; + } + g_OSD.Show(OSDType::MESSAGE_SUCCESS, name); + } else { + g_OSD.Show(OSDType::MESSAGE_ERROR, sy->T("Failed")); + } + TriggerFinish(DR_OK); + }); + }); + } + parent->Add(new Choice(pa->T("Continue")))->OnClick.Add([this](UI::EventParams &e) { + TriggerFinish(DR_CANCEL); + }); + } + +private: + Path dumpPath_; +}; + +extern ScreenManager *g_screenManager; + void OnFrameDumpCreated(const Path &dumpPath, bool openNow) { NOTICE_LOG(Log::System, "Frame dump created at '%s'", dumpPath.c_str()); std::function open; if (System_GetPropertyBool(SYSPROP_CAN_SHOW_FILE)) { open = [dumpPath]() { System_ShowFileInFolder(dumpPath); }; - } else if (CanShareFiles()) { - open = [dumpPath]() { ShareFrameDump(dumpPath); }; + } else if (CanShareFiles() || CanSaveFileAs()) { + open = [dumpPath]() { g_screenManager->push(new FrameDumpPopupScreen(dumpPath)); }; } g_OSD.Show(OSDType::MESSAGE_SUCCESS, GetFriendlyPath(dumpPath), 7.0f, "framedump"); if (!open) { diff --git a/android/jni/app-android.cpp b/android/jni/app-android.cpp index cd646f9f1e..08ea4b63f9 100644 --- a/android/jni/app-android.cpp +++ b/android/jni/app-android.cpp @@ -1197,6 +1197,10 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string } return true; } + case SystemRequestType::BROWSE_FOR_FILE_SAVE: + // The type isn't passed on, the picker keeps the suggested name's extension. + PushCommand("browse_file_save", StringFromFormat("%d:@:%s", requestId, param2.c_str()), requestId); + return true; case SystemRequestType::BROWSE_FOR_FOLDER: PushCommand("browse_folder", StringFromFormat("%d", requestId), requestId); return true; diff --git a/android/src/org/ppsspp/ppsspp/DocumentResultProxyActivity.java b/android/src/org/ppsspp/ppsspp/DocumentResultProxyActivity.java index 7ac345ff13..25d8b41be4 100644 --- a/android/src/org/ppsspp/ppsspp/DocumentResultProxyActivity.java +++ b/android/src/org/ppsspp/ppsspp/DocumentResultProxyActivity.java @@ -53,8 +53,10 @@ public class DocumentResultProxyActivity extends AppCompatActivity { if (uri != null) { Log.i(TAG, "DocumentResultProxy: Selected URI: " + uri); final boolean isTree = pickerIntent != null && Intent.ACTION_OPEN_DOCUMENT_TREE.equals(pickerIntent.getAction()); + // A created document gets written to after the picker is gone. + final boolean isCreate = pickerIntent != null && Intent.ACTION_CREATE_DOCUMENT.equals(pickerIntent.getAction()); try { - if (isTree) { + if (isTree || isCreate) { getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); } else { getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); diff --git a/android/src/org/ppsspp/ppsspp/PpssppActivity.java b/android/src/org/ppsspp/ppsspp/PpssppActivity.java index 682bddf66d..10a6cecb54 100644 --- a/android/src/org/ppsspp/ppsspp/PpssppActivity.java +++ b/android/src/org/ppsspp/ppsspp/PpssppActivity.java @@ -1808,6 +1808,30 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList } intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + Intent proxy = new Intent(this, DocumentResultProxyActivity.class); + proxy.putExtra("picker_intent", intent); + proxy.putExtra("request_id", requestId); + startActivity(proxy); + return true; + } catch (Exception e) { + NativeApp.reportException(e, params); + Log.e(TAG, e.toString()); + return false; + } + } else if (command.equals("browse_file_save")) { + try { + // requestId:@:suggested filename + String[] param = params.split(":@:", 2); + int requestId = Integer.parseInt(param[0]); + Log.i(TAG, "browse_file_save request ID: " + requestId); + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + // The generic type keeps the extension of the suggested name. + intent.setType("application/octet-stream"); + if (param.length > 1) { + intent.putExtra(Intent.EXTRA_TITLE, param[1]); + } + Intent proxy = new Intent(this, DocumentResultProxyActivity.class); proxy.putExtra("picker_intent", intent); proxy.putExtra("request_id", requestId);