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] 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;