From d7bac55348301724d46c18508d4d719d29439d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 21 Sep 2026 18:25:50 -0600 Subject: [PATCH 1/4] Android: Release old unused file grants before the system starts dropping ours An app can hold 512 persisted URI grants (128 before Android 11). Over that, the system drops the oldest, and ours is usually the memory stick folder. We take a grant for every file picked one at a time - games loaded through the file picker, shortcut targets, sound effects - and never released any. At startup, log what we hold, and past half the limit release the oldest single-file grants that neither the recent list nor a setting points at. Folder grants are always kept. Home screen shortcuts need their file's grant and can't be enumerated, which is why this only acts when it has to and goes oldest first: the files it takes are ones the system would have taken too, after the folder. Co-Authored-By: Claude Fable 5.1 --- android/jni/app-android.cpp | 23 +++++ android/src/org/ppsspp/ppsspp/ContentUri.java | 95 +++++++++++++++++++ android/src/org/ppsspp/ppsspp/NativeApp.java | 2 + .../src/org/ppsspp/ppsspp/PpssppActivity.java | 3 + 4 files changed, 123 insertions(+) diff --git a/android/jni/app-android.cpp b/android/jni/app-android.cpp index 5b317acfe9..d5e5af1b01 100644 --- a/android/jni/app-android.cpp +++ b/android/jni/app-android.cpp @@ -8,6 +8,7 @@ #if PPSSPP_PLATFORM(ANDROID) +#include #include #include @@ -102,6 +103,7 @@ struct JNIEnv {}; #include "Core/ConfigValues.h" #include "Core/Loaders.h" #include "Core/KeyMap.h" +#include "Core/Util/RecentFiles.h" #include "Core/System.h" #include "Core/EmuThread.h" #include "Core/HLE/sceUsbCam.h" @@ -609,6 +611,27 @@ extern "C" jstring Java_org_ppsspp_ppsspp_NativeApp_queryConfig return jresult; } +// For ContentUri.pruneUriGrants. Anything that stores the URI of a single picked file should be checked +// here, or the grant for it may eventually be released. Folders don't need to be, they're always kept. +extern "C" jobjectArray Java_org_ppsspp_ppsspp_NativeApp_getReferencedUris(JNIEnv *env, jclass) { + std::vector uris = g_recentFiles.GetRecentFiles(); + uris.push_back(g_Config.sAchievementsUnlockAudioFile); + uris.push_back(g_Config.sAchievementsLeaderboardSubmitAudioFile); + uris.erase(std::remove_if(uris.begin(), uris.end(), [](const std::string &uri) { + return !Android_IsContentUri(uri); + }), uris.end()); + + jclass stringClass = env->FindClass("java/lang/String"); + jobjectArray result = env->NewObjectArray((jsize)uris.size(), stringClass, nullptr); + for (size_t i = 0; i < uris.size(); i++) { + jstring juri = env->NewStringUTF(uris[i].c_str()); + env->SetObjectArrayElement(result, (jsize)i, juri); + env->DeleteLocalRef(juri); + } + env->DeleteLocalRef(stringClass); + return result; +} + // Need to use raw Android logging before NativeInit. #define EARLY_LOG(...) __android_log_print(ANDROID_LOG_INFO, "PPSSPP", __VA_ARGS__) diff --git a/android/src/org/ppsspp/ppsspp/ContentUri.java b/android/src/org/ppsspp/ppsspp/ContentUri.java index 86ba5b1b9a..1001100fe1 100644 --- a/android/src/org/ppsspp/ppsspp/ContentUri.java +++ b/android/src/org/ppsspp/ppsspp/ContentUri.java @@ -2,6 +2,9 @@ package org.ppsspp.ppsspp; import android.app.Activity; import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.UriPermission; import android.database.Cursor; import android.net.Uri; import android.os.Build; @@ -18,7 +21,13 @@ import androidx.documentfile.provider.DocumentFile; import java.io.File; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.List; import java.util.Locale; +import java.util.Set; public class ContentUri { private static final String TAG = "PpssppActivity"; // don't want to add yet another tag @@ -416,6 +425,92 @@ public class ContentUri { return -1; } + // Android only lets an app keep so many persisted URI grants (512, or 128 before Android 11). When it + // goes over, the system drops the oldest ones, whatever they are - and our oldest is usually the + // folder the user set up as the memory stick. We take one for every single file picked (games loaded + // through the file picker, shortcuts, sound effects) and never gave any back. + // + // So stay well clear of the limit: past half of it, let go of the oldest single-file grants that + // nothing points at anymore. Folder grants are never touched. We can't tell which files a home screen + // shortcut still needs, which is why this waits until it has to, and then takes the oldest first - + // the same ones the system would have taken, minus the folders. + private static final String GRANT_TAG = "PPSSPPUriGrants"; + + private static boolean isTreeUri(Uri uri) { + // Same check as DocumentsContract.isTreeUri, which is API 24+. + List segments = uri.getPathSegments(); + return segments.size() >= 2 && "tree".equals(segments.get(0)); + } + + // Returns the grants to release to get down to maxGrants, oldest first. Doesn't release anything. + static ArrayList chooseGrantsToRelease(List grants, int maxGrants, Set referencedUris) { + ArrayList toRelease = new ArrayList<>(); + int excess = grants.size() - maxGrants; + if (excess <= 0) { + return toRelease; + } + ArrayList oldestFirst = new ArrayList<>(grants); + Collections.sort(oldestFirst, (a, b) -> Long.compare(a.getPersistedTime(), b.getPersistedTime())); + for (UriPermission grant : oldestFirst) { + if (toRelease.size() >= excess) { + break; + } + if (isTreeUri(grant.getUri()) || referencedUris.contains(grant.getUri().toString())) { + continue; + } + toRelease.add(grant); + } + return toRelease; + } + + // Call once at startup, after the native side has loaded the config. + public static void pruneUriGrants(Context context) { + try { + final ContentResolver resolver = context.getContentResolver(); + final List grants = resolver.getPersistedUriPermissions(); + final int systemLimit = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? 512 : 128; + final int maxGrants = systemLimit / 2; + + int folders = 0; + for (UriPermission grant : grants) { + if (isTreeUri(grant.getUri())) { + folders++; + Log.i(GRANT_TAG, "Folder grant (always kept): " + grant.getUri() + " taken " + new Date(grant.getPersistedTime())); + } + } + Log.i(GRANT_TAG, "Holding " + grants.size() + " persisted URI grants: " + folders + " folders, " + (grants.size() - folders) + + " files. The system drops the oldest above " + systemLimit + ", we start releasing old files above " + maxGrants + "."); + + if (grants.size() <= maxGrants) { + Log.i(GRANT_TAG, "Nothing to release."); + return; + } + + // Only ask the native side when there's something to decide. + final Set referencedUris = new HashSet<>(Arrays.asList(NativeApp.getReferencedUris())); + Log.i(GRANT_TAG, "The recent list and the settings reference " + referencedUris.size() + " URIs."); + ArrayList toRelease = chooseGrantsToRelease(grants, maxGrants, referencedUris); + if (toRelease.isEmpty()) { + Log.w(GRANT_TAG, "Over our limit, but everything is a folder or still in use. Nothing to release."); + return; + } + for (UriPermission grant : toRelease) { + Log.i(GRANT_TAG, "Releasing unused file grant: " + grant.getUri() + " taken " + new Date(grant.getPersistedTime())); + int flags = 0; + if (grant.isReadPermission()) { + flags |= Intent.FLAG_GRANT_READ_URI_PERMISSION; + } + if (grant.isWritePermission()) { + flags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + } + resolver.releasePersistableUriPermission(grant.getUri(), flags); + } + Log.i(GRANT_TAG, "Released " + toRelease.size() + " file grants, " + (grants.size() - toRelease.size()) + " remain."); + } catch (Exception e) { + Log.e(GRANT_TAG, "Exception while pruning URI grants: " + e); + } + } + @Keep @SuppressWarnings("unused") public static boolean isExternalStoragePreservedLegacy() { diff --git a/android/src/org/ppsspp/ppsspp/NativeApp.java b/android/src/org/ppsspp/ppsspp/NativeApp.java index 81f77856b0..9c710bd89c 100644 --- a/android/src/org/ppsspp/ppsspp/NativeApp.java +++ b/android/src/org/ppsspp/ppsspp/NativeApp.java @@ -82,6 +82,8 @@ public class NativeApp { public static native void setAchievementsHostOverride(String host); public static native void clearAchievementsHostOverride(); public static native String queryConfig(String queryName); + // The content URIs that the recent list and the settings still point at. + public static native String[] getReferencedUris(); public static native int getSelectedCamera(); public static native int getDisplayFramerateMode(); diff --git a/android/src/org/ppsspp/ppsspp/PpssppActivity.java b/android/src/org/ppsspp/ppsspp/PpssppActivity.java index 37d7feeed4..47f6bdd3bf 100644 --- a/android/src/org/ppsspp/ppsspp/PpssppActivity.java +++ b/android/src/org/ppsspp/ppsspp/PpssppActivity.java @@ -512,6 +512,9 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList NativeApp.audioConfig(optimalFramesPerBuffer, optimalSampleRate); NativeApp.init(model, deviceType, languageRegion, apkFilePath, dataDir, extStorageDir, externalFilesDir, nativeLibDir, additionalStorageDirs, cacheDir, shortcut, installerName, Build.VERSION.SDK_INT, Build.BOARD); + // Needs the config, to know which files are still in use. + ContentUri.pruneUriGrants(this); + sendInitialGrants(); // OK, config should be initialized, we can query for screen rotation. From 020e39baa98c44b93d068f0432d422744a6912da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 23 Oct 2026 11:52:52 +0200 Subject: [PATCH 2/4] RecentFiles: Make the loaded list visible right away Load() only queued the list for the worker thread, so reading it right after loading the config could still see an empty list. Android's grant pruning does exactly that, and would then treat recent games as unreferenced, release their grants, and the queued clean would drop them from the list as missing. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/Util/RecentFiles.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Core/Util/RecentFiles.cpp b/Core/Util/RecentFiles.cpp index eb06d673e6..785a537e73 100644 --- a/Core/Util/RecentFiles.cpp +++ b/Core/Util/RecentFiles.cpp @@ -101,6 +101,12 @@ void RecentFilesManager::Load(const Section *recent, int maxRecent) { // Just zapping any pending command, since they won't matter after this. // TODO: Maybe we should let adds through... WipePendingCommandsUnderLock(); + // Visible right away, for callers that read the list straight after loading the config (Android's + // URI grant pruning). The queued ReplaceAll still runs, in case a command was mid-flight. + { + std::lock_guard recentGuard(recentLock_); + recentFiles_ = newRecent; + } cmds_.push(RecentCommand{ RecentCmd::ReplaceAll, std::make_unique>(newRecent) }); cmdCondVar_.notify_one(); } From 8e146f4c24d4b199a3a539b6d8d4fd34d92fba4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 23 Oct 2026 12:51:57 +0200 Subject: [PATCH 3/4] Android: Keep folder and shortcut grants, re-take the folders we use Pruning now only runs when the config was really read: when the memory stick isn't reachable at startup (an unmounted SD card, or right after boot), NativeInit falls back to defaults and the recent list is empty, so every recent game's grant looked unused and was released. Grants for the memory stick, the Games tab folder and pinned folders are re-taken at every startup. That moves their persisted time up, so they're the last the system drops if we ever reach its limit. The new time is written out with the next grant change, which is also the only time the system prunes. Home screen shortcuts can't be enumerated, so the 64 most recently created or launched are remembered in a new [Shortcuts] section of ppsspp.ini, and their grants are kept. ShortcutActivity runs without the config loaded, so it queues them in SharedPreferences until the next startup saves them. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/Config.cpp | 40 +++++- Core/Config.h | 9 ++ android/jni/app-android.cpp | 37 ++++- android/src/org/ppsspp/ppsspp/ContentUri.java | 135 ++++++++++++++---- android/src/org/ppsspp/ppsspp/NativeApp.java | 6 +- .../src/org/ppsspp/ppsspp/PpssppActivity.java | 8 +- .../org/ppsspp/ppsspp/ShortcutActivity.java | 2 + 7 files changed, 205 insertions(+), 32 deletions(-) diff --git a/Core/Config.cpp b/Core/Config.cpp index edcb81421d..5076c7069f 100644 --- a/Core/Config.cpp +++ b/Core/Config.cpp @@ -1397,7 +1397,8 @@ void Config::Load(const char *iniFileName, const char *controllerIniFilename) { bSaveSettings = true; IniFile iniFile; - if (!iniFile.Load(iniFilename_)) { + loadedFromFile_ = iniFile.Load(iniFilename_); + if (!loadedFromFile_) { WARN_LOG(Log::Config, "Failed to read '%s'. Setting main config to default.", iniFilename_.c_str()); // Continue anyway to initialize the config. } @@ -1449,6 +1450,18 @@ void Config::Load(const char *iniFileName, const char *controllerIniFilename) { } } + vShortcutUris.clear(); + if (const Section *shortcuts = iniFile.GetSection("Shortcuts")) { + for (int i = 0; i < MAX_SHORTCUT_URIS; i++) { + char keyName[64]; + snprintf(keyName, sizeof(keyName), "Uri%d", i); + std::string uri; + if (shortcuts->Get(keyName, &uri) && !uri.empty()) { + vShortcutUris.push_back(uri); + } + } + } + // Default values for post process shaders bool postShadersInitialized = iniFile.HasSection("PostShaderList"); Section *postShaderChain = iniFile.GetOrCreateSection("PostShaderList"); @@ -1573,6 +1586,18 @@ bool Config::Save(const char *saveReason) { pinnedPaths->Set(keyName, vPinnedPaths[i]); } + if (vShortcutUris.empty()) { + iniFile.DeleteSection("Shortcuts"); + } else { + Section *shortcuts = iniFile.GetOrCreateSection("Shortcuts"); + shortcuts->Clear(); + for (size_t i = 0; i < vShortcutUris.size(); ++i) { + char keyName[64]; + snprintf(keyName, sizeof(keyName), "Uri%d", (int)i); + shortcuts->Set(keyName, vShortcutUris[i]); + } + } + if (!IsGameSpecific()) { // These settings can be game specific, and so are handled in SaveGameConfig(). @@ -1841,6 +1866,19 @@ void Config::DismissUpgrade() { sUpgradeMessage.clear(); } +bool Config::AddShortcutUri(std::string_view uri) { + auto iter = std::find(vShortcutUris.begin(), vShortcutUris.end(), uri); + const bool added = iter == vShortcutUris.end(); + if (!added) { + vShortcutUris.erase(iter); + } + vShortcutUris.insert(vShortcutUris.begin(), std::string(uri)); + if ((int)vShortcutUris.size() > MAX_SHORTCUT_URIS) { + vShortcutUris.resize(MAX_SHORTCUT_URIS); + } + return added; +} + void Config::SetSearchPath(const Path &searchPath) { searchPath_ = searchPath; } diff --git a/Core/Config.h b/Core/Config.h index 569c9fd0e1..675b3be1ee 100644 --- a/Core/Config.h +++ b/Core/Config.h @@ -280,6 +280,12 @@ public: std::string sReportHost; std::vector vPinnedPaths; + // Android: the content URIs that home screen shortcuts were most recently created for or launched with, newest + // first. Their file grants are never released (see ContentUri.manageUriGrants). + std::vector vShortcutUris; + static constexpr int MAX_SHORTCUT_URIS = 64; // An eighth of Android's limit on persisted URI grants. + // Moves the URI to the front. Returns true if it wasn't in the list before. + bool AddShortcutUri(std::string_view uri); std::string sLanguageIni; std::string sIgnoreCompatSettings; @@ -764,6 +770,8 @@ public: bool HasGameConfig(std::string_view gameId); bool IsGameSpecific() const { return !gameId_.empty(); } + // False if the last Load() couldn't read the ini (missing, or the memory stick isn't reachable) and fell back to defaults. + bool LoadedFromFile() const { return loadedFromFile_; } void SetSearchPath(const Path &path); @@ -830,6 +838,7 @@ private: // Always the paths to the main configs, doesn't change with game-specific overlay. Path iniFilename_; Path controllerIniFilename_; + bool loadedFromFile_ = false; Path searchPath_; Path appendedConfigFileName_; diff --git a/android/jni/app-android.cpp b/android/jni/app-android.cpp index d5e5af1b01..479ddafb83 100644 --- a/android/jni/app-android.cpp +++ b/android/jni/app-android.cpp @@ -611,10 +611,19 @@ extern "C" jstring Java_org_ppsspp_ppsspp_NativeApp_queryConfig return jresult; } -// For ContentUri.pruneUriGrants. Anything that stores the URI of a single picked file should be checked -// here, or the grant for it may eventually be released. Folders don't need to be, they're always kept. +// For ContentUri.manageUriGrants. Anything that stores the URI of a picked file or folder should be listed +// here, or the grant for it may eventually be released. A grant is kept if it's one of these, or a folder +// one of these is in. Returns null if the config couldn't be read (say the memory stick is on an SD card +// that isn't mounted), since then we don't know what's in use. extern "C" jobjectArray Java_org_ppsspp_ppsspp_NativeApp_getReferencedUris(JNIEnv *env, jclass) { + if (!g_Config.LoadedFromFile() || g_Config.memStickDirectory.empty()) { + return nullptr; + } std::vector uris = g_recentFiles.GetRecentFiles(); + uris.push_back(g_Config.memStickDirectory.ToString()); + uris.push_back(g_Config.currentDirectory.ToString()); + uris.insert(uris.end(), g_Config.vPinnedPaths.begin(), g_Config.vPinnedPaths.end()); + uris.insert(uris.end(), g_Config.vShortcutUris.begin(), g_Config.vShortcutUris.end()); uris.push_back(g_Config.sAchievementsUnlockAudioFile); uris.push_back(g_Config.sAchievementsLeaderboardSubmitAudioFile); uris.erase(std::remove_if(uris.begin(), uris.end(), [](const std::string &uri) { @@ -632,6 +641,30 @@ extern "C" jobjectArray Java_org_ppsspp_ppsspp_NativeApp_getReferencedUris(JNIEn return result; } +// Only called from PpssppActivity.Initialize, before the render thread runs, so g_Config is ours to change. +// Returns false if they couldn't be saved, so the Java side keeps them for next time. +extern "C" jboolean Java_org_ppsspp_ppsspp_NativeApp_addShortcutUris(JNIEnv *env, jclass, jobjectArray juris) { + if (!g_Config.LoadedFromFile() || g_Config.memStickDirectory.empty()) { + return false; + } + bool added = false; + const jsize count = env->GetArrayLength(juris); + // Oldest first, so the newest ends up at the front. + for (jsize i = 0; i < count; i++) { + jstring juri = (jstring)env->GetObjectArrayElement(juris, i); + std::string uri = GetJavaString(env, juri); + env->DeleteLocalRef(juri); + if (Android_IsContentUri(uri) && g_Config.AddShortcutUri(uri)) { + INFO_LOG(Log::System, "New home screen shortcut target: %s", uri.c_str()); + added = true; + } + } + if (added) { + return g_Config.Save("ShortcutUrisAdded"); + } + return true; +} + // Need to use raw Android logging before NativeInit. #define EARLY_LOG(...) __android_log_print(ANDROID_LOG_INFO, "PPSSPP", __VA_ARGS__) diff --git a/android/src/org/ppsspp/ppsspp/ContentUri.java b/android/src/org/ppsspp/ppsspp/ContentUri.java index 1001100fe1..affb499b02 100644 --- a/android/src/org/ppsspp/ppsspp/ContentUri.java +++ b/android/src/org/ppsspp/ppsspp/ContentUri.java @@ -4,6 +4,7 @@ import android.app.Activity; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.UriPermission; import android.database.Cursor; import android.net.Uri; @@ -13,6 +14,7 @@ import android.os.ParcelFileDescriptor; import android.provider.DocumentsContract; import android.system.Os; import android.system.StructStatVfs; +import android.text.TextUtils; import android.util.Log; import androidx.annotation.Keep; @@ -426,14 +428,16 @@ public class ContentUri { } // Android only lets an app keep so many persisted URI grants (512, or 128 before Android 11). When it - // goes over, the system drops the oldest ones, whatever they are - and our oldest is usually the - // folder the user set up as the memory stick. We take one for every single file picked (games loaded - // through the file picker, shortcuts, sound effects) and never gave any back. + // goes over, the system drops the oldest ones, whatever they are. We take one for every single file + // picked (games loaded through the file picker, home screen shortcuts, sound effects) and never gave any + // back, so sooner or later the one dropped would be the folder the user set up as the memory stick. // - // So stay well clear of the limit: past half of it, let go of the oldest single-file grants that - // nothing points at anymore. Folder grants are never touched. We can't tell which files a home screen - // shortcut still needs, which is why this waits until it has to, and then takes the oldest first - - // the same ones the system would have taken, minus the folders. + // Two things at startup keep that from happening: + // * Re-take the grants for the folders we use (the memory stick, the Games tab, pinned folders). That + // makes them the newest, so they're the last the system would drop. + // * Past half the limit, release the oldest single-file grants that nothing points at anymore. Folder + // grants are never released. Home screen shortcuts can't be enumerated, so ppsspp.ini remembers the + // ones most recently created or launched (Config::MAX_SHORTCUT_URIS), and those are spared too. private static final String GRANT_TAG = "PPSSPPUriGrants"; private static boolean isTreeUri(Uri uri) { @@ -442,6 +446,32 @@ public class ContentUri { return segments.size() >= 2 && "tree".equals(segments.get(0)); } + // True if something points at the grant's URI, or at something inside it (if it's a folder). + static boolean isReferenced(Uri grantUri, Set referencedUris) { + final String uri = grantUri.toString(); + if (referencedUris.contains(uri)) { + return true; + } + final String prefix = uri + "/"; + for (String referenced : referencedUris) { + if (referenced.startsWith(prefix)) { + return true; + } + } + return false; + } + + private static int grantFlags(UriPermission grant) { + int flags = 0; + if (grant.isReadPermission()) { + flags |= Intent.FLAG_GRANT_READ_URI_PERMISSION; + } + if (grant.isWritePermission()) { + flags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + } + return flags; + } + // Returns the grants to release to get down to maxGrants, oldest first. Doesn't release anything. static ArrayList chooseGrantsToRelease(List grants, int maxGrants, Set referencedUris) { ArrayList toRelease = new ArrayList<>(); @@ -455,7 +485,7 @@ public class ContentUri { if (toRelease.size() >= excess) { break; } - if (isTreeUri(grant.getUri()) || referencedUris.contains(grant.getUri().toString())) { + if (isTreeUri(grant.getUri()) || isReferenced(grant.getUri(), referencedUris)) { continue; } toRelease.add(grant); @@ -463,32 +493,47 @@ public class ContentUri { return toRelease; } - // Call once at startup, after the native side has loaded the config. - public static void pruneUriGrants(Context context) { + // Call once at startup, after the native side has loaded the config and been handed the queued shortcut URIs. + public static void manageUriGrants(Context context) { try { final ContentResolver resolver = context.getContentResolver(); final List grants = resolver.getPersistedUriPermissions(); final int systemLimit = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? 512 : 128; final int maxGrants = systemLimit / 2; + final String[] referenced = NativeApp.getReferencedUris(); + if (referenced == null) { + // We can't tell what's in use, so leave everything alone. + Log.w(GRANT_TAG, "The config couldn't be loaded, not touching any of our " + grants.size() + " URI grants."); + return; + } + final Set referencedUris = new HashSet<>(Arrays.asList(referenced)); int folders = 0; for (UriPermission grant : grants) { - if (isTreeUri(grant.getUri())) { - folders++; - Log.i(GRANT_TAG, "Folder grant (always kept): " + grant.getUri() + " taken " + new Date(grant.getPersistedTime())); + if (!isTreeUri(grant.getUri())) { + continue; + } + folders++; + if (!isReferenced(grant.getUri(), referencedUris)) { + Log.i(GRANT_TAG, "Folder grant (kept, not in use): " + grant.getUri() + " taken " + new Date(grant.getPersistedTime())); + continue; + } + // Taking a grant we already hold just moves its time up. + try { + resolver.takePersistableUriPermission(grant.getUri(), grantFlags(grant)); + Log.i(GRANT_TAG, "Folder grant (in use, re-taken): " + grant.getUri() + " taken " + new Date(grant.getPersistedTime())); + } catch (Exception e) { + Log.w(GRANT_TAG, "Couldn't re-take folder grant " + grant.getUri() + ": " + e); } } Log.i(GRANT_TAG, "Holding " + grants.size() + " persisted URI grants: " + folders + " folders, " + (grants.size() - folders) + " files. The system drops the oldest above " + systemLimit + ", we start releasing old files above " + maxGrants + "."); if (grants.size() <= maxGrants) { - Log.i(GRANT_TAG, "Nothing to release."); return; } - // Only ask the native side when there's something to decide. - final Set referencedUris = new HashSet<>(Arrays.asList(NativeApp.getReferencedUris())); - Log.i(GRANT_TAG, "The recent list and the settings reference " + referencedUris.size() + " URIs."); + Log.i(GRANT_TAG, "The recent list, shortcuts and settings reference " + referencedUris.size() + " URIs."); ArrayList toRelease = chooseGrantsToRelease(grants, maxGrants, referencedUris); if (toRelease.isEmpty()) { Log.w(GRANT_TAG, "Over our limit, but everything is a folder or still in use. Nothing to release."); @@ -496,21 +541,59 @@ public class ContentUri { } for (UriPermission grant : toRelease) { Log.i(GRANT_TAG, "Releasing unused file grant: " + grant.getUri() + " taken " + new Date(grant.getPersistedTime())); - int flags = 0; - if (grant.isReadPermission()) { - flags |= Intent.FLAG_GRANT_READ_URI_PERMISSION; - } - if (grant.isWritePermission()) { - flags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION; - } - resolver.releasePersistableUriPermission(grant.getUri(), flags); + resolver.releasePersistableUriPermission(grant.getUri(), grantFlags(grant)); } Log.i(GRANT_TAG, "Released " + toRelease.size() + " file grants, " + (grants.size() - toRelease.size()) + " remain."); } catch (Exception e) { - Log.e(GRANT_TAG, "Exception while pruning URI grants: " + e); + Log.e(GRANT_TAG, "Exception while managing URI grants: " + e); } } + // ShortcutActivity can't write ppsspp.ini (the config isn't loaded there, and a running PpssppActivity would + // overwrite it), so the URIs of shortcuts created or launched wait here until the next startup hands them over. + private static final String SHORTCUT_PREFS = "PendingShortcutUris"; + private static final String SHORTCUT_PREFS_KEY = "uris"; + private static final int MAX_PENDING_SHORTCUT_URIS = 64; + + private static ArrayList readPendingShortcutUris(SharedPreferences prefs) { + ArrayList uris = new ArrayList<>(); + for (String uri : prefs.getString(SHORTCUT_PREFS_KEY, "").split("\n")) { + if (!uri.isEmpty()) { + uris.add(uri); + } + } + return uris; + } + + public static void queueShortcutUri(Context context, String uri) { + if (uri == null || !uri.startsWith("content://")) { + return; + } + SharedPreferences prefs = context.getSharedPreferences(SHORTCUT_PREFS, Context.MODE_PRIVATE); + ArrayList uris = readPendingShortcutUris(prefs); + uris.remove(uri); + uris.add(uri); // Newest last. + while (uris.size() > MAX_PENDING_SHORTCUT_URIS) { + uris.remove(0); + } + prefs.edit().putString(SHORTCUT_PREFS_KEY, TextUtils.join("\n", uris)).commit(); + } + + // Call once at startup, after the native side has loaded the config. + public static void flushShortcutUris(Context context) { + SharedPreferences prefs = context.getSharedPreferences(SHORTCUT_PREFS, Context.MODE_PRIVATE); + ArrayList uris = readPendingShortcutUris(prefs); + if (uris.isEmpty()) { + return; + } + if (!NativeApp.addShortcutUris(uris.toArray(new String[0]))) { + Log.w(GRANT_TAG, "Couldn't save " + uris.size() + " shortcut URIs to the config, keeping them for next time."); + return; + } + Log.i(GRANT_TAG, "Saved " + uris.size() + " shortcut URIs to the config."); + prefs.edit().remove(SHORTCUT_PREFS_KEY).commit(); + } + @Keep @SuppressWarnings("unused") public static boolean isExternalStoragePreservedLegacy() { diff --git a/android/src/org/ppsspp/ppsspp/NativeApp.java b/android/src/org/ppsspp/ppsspp/NativeApp.java index 9c710bd89c..6f2a056833 100644 --- a/android/src/org/ppsspp/ppsspp/NativeApp.java +++ b/android/src/org/ppsspp/ppsspp/NativeApp.java @@ -82,8 +82,12 @@ public class NativeApp { public static native void setAchievementsHostOverride(String host); public static native void clearAchievementsHostOverride(); public static native String queryConfig(String queryName); - // The content URIs that the recent list and the settings still point at. + // The content URIs that the recent list, the remembered shortcuts and the settings still point at. + // Null if the config couldn't be loaded. public static native String[] getReferencedUris(); + // Remembers these as home screen shortcut targets, oldest first, and saves the config. Only call during + // Initialize. False if that didn't work out. + public static native boolean addShortcutUris(String[] uris); public static native int getSelectedCamera(); public static native int getDisplayFramerateMode(); diff --git a/android/src/org/ppsspp/ppsspp/PpssppActivity.java b/android/src/org/ppsspp/ppsspp/PpssppActivity.java index 47f6bdd3bf..7835a075eb 100644 --- a/android/src/org/ppsspp/ppsspp/PpssppActivity.java +++ b/android/src/org/ppsspp/ppsspp/PpssppActivity.java @@ -512,8 +512,9 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList NativeApp.audioConfig(optimalFramesPerBuffer, optimalSampleRate); NativeApp.init(model, deviceType, languageRegion, apkFilePath, dataDir, extStorageDir, externalFilesDir, nativeLibDir, additionalStorageDirs, cacheDir, shortcut, installerName, Build.VERSION.SDK_INT, Build.BOARD); - // Needs the config, to know which files are still in use. - ContentUri.pruneUriGrants(this); + // Both need the config. + ContentUri.flushShortcutUris(this); + ContentUri.manageUriGrants(this); sendInitialGrants(); @@ -746,6 +747,8 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList Log.i(TAG, "Found Shortcut Parameter in data, passing on: " + shortcutParam); setShortcutParam(shortcutParam); } + // So its file grant is kept (see ContentUri.manageUriGrants). + ContentUri.queueShortcutUri(this, getIntent().getStringExtra(SHORTCUT_EXTRA_KEY)); String achievementsHostOverride = AchievementsHostOverrideReceiver.getAchievementsHostOverride(this); if (achievementsHostOverride != null && !achievementsHostOverride.isEmpty()) { @@ -2167,6 +2170,7 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList } else { // Someone launched a shortcut while we were running.... setIntent(intent); + ContentUri.queueShortcutUri(this, intent.getStringExtra(SHORTCUT_EXTRA_KEY)); String value = parseIntent(intent); if (value != null) { // TODO: Actually send a command to the native code to launch the new game. diff --git a/android/src/org/ppsspp/ppsspp/ShortcutActivity.java b/android/src/org/ppsspp/ppsspp/ShortcutActivity.java index bf4d5f31aa..7d59bb0fba 100644 --- a/android/src/org/ppsspp/ppsspp/ShortcutActivity.java +++ b/android/src/org/ppsspp/ppsspp/ShortcutActivity.java @@ -73,6 +73,8 @@ public class ShortcutActivity extends Activity { // Some pickers don't hand out a persistable grant. Log.w(TAG, "Couldn't take URI permission: " + e); } + // So its grant is kept (see ContentUri.manageUriGrants). + ContentUri.queueShortcutUri(this, selectedFile.toString()); Log.i(TAG, "Browse file finished:" + selectedFile); respondToShortcutRequest(selectedFile); // finishes. return; From 0829c442d072fc1c7ee7a401a92596c392652a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 23 Oct 2026 15:29:26 +0200 Subject: [PATCH 4/4] Android: Don't prune grants from a fallback config, keep shortcut order NativeInit falls back to the default memory stick when the one in memstick_dir.txt can't be read (on Android 9 and older, /sdcard), and an old ppsspp.ini there would make the user's grants look unused. Also release before re-taking folders, since a take can make the system prune, keep going when one release fails, save the shortcut list when only its order changed, and re-take the Remote ISO shared folder. Co-Authored-By: Claude Opus 5.5 (1M context) --- android/jni/app-android.cpp | 30 ++++++++--- android/src/org/ppsspp/ppsspp/ContentUri.java | 54 +++++++++++-------- android/src/org/ppsspp/ppsspp/NativeApp.java | 2 +- 3 files changed, 56 insertions(+), 30 deletions(-) diff --git a/android/jni/app-android.cpp b/android/jni/app-android.cpp index 479ddafb83..6b7e1538c8 100644 --- a/android/jni/app-android.cpp +++ b/android/jni/app-android.cpp @@ -78,6 +78,7 @@ struct JNIEnv {}; #include "Common/System/Request.h" #include "Common/Thread/ThreadUtil.h" #include "Common/File/Path.h" +#include "Common/File/FileUtil.h" #include "Common/File/DirListing.h" #include "Common/File/VFS/VFS.h" #include "Common/File/VFS/DirectoryReader.h" @@ -611,17 +612,32 @@ extern "C" jstring Java_org_ppsspp_ppsspp_NativeApp_queryConfig return jresult; } +// False if the config in memory may not be the user's: the ini couldn't be read (say the memory stick is on an +// SD card that isn't mounted), or NativeInit couldn't reach the memory stick in memstick_dir.txt and fell +// back to the default one, where an old ppsspp.ini may be lying around. +static bool HaveUsersConfig() { + if (!g_Config.LoadedFromFile() || g_Config.memStickDirectory.empty()) { + return false; + } + const Path memstickDirFile = g_Config.internalDataDirectory / "memstick_dir.txt"; + if (!File::Exists(memstickDirFile)) { + return true; + } + std::string memstickDir; + return File::ReadTextFileToString(memstickDirFile, &memstickDir) && Path(memstickDir) == g_Config.memStickDirectory; +} + // For ContentUri.manageUriGrants. Anything that stores the URI of a picked file or folder should be listed // here, or the grant for it may eventually be released. A grant is kept if it's one of these, or a folder -// one of these is in. Returns null if the config couldn't be read (say the memory stick is on an SD card -// that isn't mounted), since then we don't know what's in use. +// one of these is in. Returns null if we don't have the user's config, since then we don't know what's in use. extern "C" jobjectArray Java_org_ppsspp_ppsspp_NativeApp_getReferencedUris(JNIEnv *env, jclass) { - if (!g_Config.LoadedFromFile() || g_Config.memStickDirectory.empty()) { + if (!HaveUsersConfig()) { return nullptr; } std::vector uris = g_recentFiles.GetRecentFiles(); uris.push_back(g_Config.memStickDirectory.ToString()); uris.push_back(g_Config.currentDirectory.ToString()); + uris.push_back(g_Config.sRemoteISOSharedDir); uris.insert(uris.end(), g_Config.vPinnedPaths.begin(), g_Config.vPinnedPaths.end()); uris.insert(uris.end(), g_Config.vShortcutUris.begin(), g_Config.vShortcutUris.end()); uris.push_back(g_Config.sAchievementsUnlockAudioFile); @@ -644,10 +660,10 @@ extern "C" jobjectArray Java_org_ppsspp_ppsspp_NativeApp_getReferencedUris(JNIEn // Only called from PpssppActivity.Initialize, before the render thread runs, so g_Config is ours to change. // Returns false if they couldn't be saved, so the Java side keeps them for next time. extern "C" jboolean Java_org_ppsspp_ppsspp_NativeApp_addShortcutUris(JNIEnv *env, jclass, jobjectArray juris) { - if (!g_Config.LoadedFromFile() || g_Config.memStickDirectory.empty()) { + if (!HaveUsersConfig()) { return false; } - bool added = false; + const std::vector before = g_Config.vShortcutUris; const jsize count = env->GetArrayLength(juris); // Oldest first, so the newest ends up at the front. for (jsize i = 0; i < count; i++) { @@ -656,10 +672,10 @@ extern "C" jboolean Java_org_ppsspp_ppsspp_NativeApp_addShortcutUris(JNIEnv *env env->DeleteLocalRef(juri); if (Android_IsContentUri(uri) && g_Config.AddShortcutUri(uri)) { INFO_LOG(Log::System, "New home screen shortcut target: %s", uri.c_str()); - added = true; } } - if (added) { + // Also when only the order changed, so the ones launched lately aren't the first to go. + if (g_Config.vShortcutUris != before) { return g_Config.Save("ShortcutUrisAdded"); } return true; diff --git a/android/src/org/ppsspp/ppsspp/ContentUri.java b/android/src/org/ppsspp/ppsspp/ContentUri.java index affb499b02..4dbb5bd5d2 100644 --- a/android/src/org/ppsspp/ppsspp/ContentUri.java +++ b/android/src/org/ppsspp/ppsspp/ContentUri.java @@ -433,8 +433,8 @@ public class ContentUri { // back, so sooner or later the one dropped would be the folder the user set up as the memory stick. // // Two things at startup keep that from happening: - // * Re-take the grants for the folders we use (the memory stick, the Games tab, pinned folders). That - // makes them the newest, so they're the last the system would drop. + // * Re-take the grants for the folders we use (the memory stick, the Games tab, pinned folders, the Remote + // ISO shared folder). That makes them the newest, so they're the last the system would drop. // * Past half the limit, release the oldest single-file grants that nothing points at anymore. Folder // grants are never released. Home screen shortcuts can't be enumerated, so ppsspp.ini remembers the // ones most recently created or launched (Config::MAX_SHORTCUT_URIS), and those are spared too. @@ -503,17 +503,45 @@ public class ContentUri { final String[] referenced = NativeApp.getReferencedUris(); if (referenced == null) { // We can't tell what's in use, so leave everything alone. - Log.w(GRANT_TAG, "The config couldn't be loaded, not touching any of our " + grants.size() + " URI grants."); + Log.w(GRANT_TAG, "Don't have the user's config, not touching any of our " + grants.size() + " URI grants."); return; } final Set referencedUris = new HashSet<>(Arrays.asList(referenced)); int folders = 0; + for (UriPermission grant : grants) { + if (isTreeUri(grant.getUri())) { + folders++; + } + } + Log.i(GRANT_TAG, "Holding " + grants.size() + " persisted URI grants: " + folders + " folders, " + (grants.size() - folders) + + " files. The system drops the oldest above " + systemLimit + ", we start releasing old files above " + maxGrants + "."); + + // Release before re-taking: a take can make the system prune, which would leave our list out of date. + if (grants.size() > maxGrants) { + Log.i(GRANT_TAG, "The recent list, shortcuts and settings reference " + referencedUris.size() + " URIs."); + ArrayList toRelease = chooseGrantsToRelease(grants, maxGrants, referencedUris); + int released = 0; + for (UriPermission grant : toRelease) { + Log.i(GRANT_TAG, "Releasing unused file grant: " + grant.getUri() + " taken " + new Date(grant.getPersistedTime())); + try { + resolver.releasePersistableUriPermission(grant.getUri(), grantFlags(grant)); + released++; + } catch (Exception e) { + Log.w(GRANT_TAG, "Couldn't release " + grant.getUri() + ": " + e); + } + } + if (toRelease.isEmpty()) { + Log.w(GRANT_TAG, "Over our limit, but everything is a folder or still in use. Nothing to release."); + } else { + Log.i(GRANT_TAG, "Released " + released + " file grants, " + (grants.size() - released) + " remain."); + } + } + for (UriPermission grant : grants) { if (!isTreeUri(grant.getUri())) { continue; } - folders++; if (!isReferenced(grant.getUri(), referencedUris)) { Log.i(GRANT_TAG, "Folder grant (kept, not in use): " + grant.getUri() + " taken " + new Date(grant.getPersistedTime())); continue; @@ -526,24 +554,6 @@ public class ContentUri { Log.w(GRANT_TAG, "Couldn't re-take folder grant " + grant.getUri() + ": " + e); } } - Log.i(GRANT_TAG, "Holding " + grants.size() + " persisted URI grants: " + folders + " folders, " + (grants.size() - folders) - + " files. The system drops the oldest above " + systemLimit + ", we start releasing old files above " + maxGrants + "."); - - if (grants.size() <= maxGrants) { - return; - } - - Log.i(GRANT_TAG, "The recent list, shortcuts and settings reference " + referencedUris.size() + " URIs."); - ArrayList toRelease = chooseGrantsToRelease(grants, maxGrants, referencedUris); - if (toRelease.isEmpty()) { - Log.w(GRANT_TAG, "Over our limit, but everything is a folder or still in use. Nothing to release."); - return; - } - for (UriPermission grant : toRelease) { - Log.i(GRANT_TAG, "Releasing unused file grant: " + grant.getUri() + " taken " + new Date(grant.getPersistedTime())); - resolver.releasePersistableUriPermission(grant.getUri(), grantFlags(grant)); - } - Log.i(GRANT_TAG, "Released " + toRelease.size() + " file grants, " + (grants.size() - toRelease.size()) + " remain."); } catch (Exception e) { Log.e(GRANT_TAG, "Exception while managing URI grants: " + e); } diff --git a/android/src/org/ppsspp/ppsspp/NativeApp.java b/android/src/org/ppsspp/ppsspp/NativeApp.java index 6f2a056833..8484ee5c35 100644 --- a/android/src/org/ppsspp/ppsspp/NativeApp.java +++ b/android/src/org/ppsspp/ppsspp/NativeApp.java @@ -83,7 +83,7 @@ public class NativeApp { public static native void clearAchievementsHostOverride(); public static native String queryConfig(String queryName); // The content URIs that the recent list, the remembered shortcuts and the settings still point at. - // Null if the config couldn't be loaded. + // Null if the config in memory may not be the user's (see HaveUsersConfig). public static native String[] getReferencedUris(); // Remembers these as home screen shortcut targets, oldest first, and saves the config. Only call during // Initialize. False if that didn't work out.