Merge pull request #22745 from hrydgard/android-file-grants

Android: Manage our persisted URI grants before the system drops them
This commit is contained in:
Henrik Rydgård authored and GitHub committed 2026-10-23 16:51:23 +02:00
commit c57f8c993d
8 files changed
+329 -1

No files matched your search

+39 -1
View File
@@ -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;
}
+9
View File
@@ -280,6 +280,12 @@ public:
std::string sReportHost;
std::vector<std::string> 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<std::string> 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_;
+6
View File
@@ -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<std::mutex> recentGuard(recentLock_);
recentFiles_ = newRecent;
}
cmds_.push(RecentCommand{ RecentCmd::ReplaceAll, std::make_unique<std::vector<std::string>>(newRecent) });
cmdCondVar_.notify_one();
}
+72
View File
@@ -8,6 +8,7 @@
#if PPSSPP_PLATFORM(ANDROID)
#include <algorithm>
#include <cstdlib>
#include <cstdint>
@@ -77,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"
@@ -102,6 +104,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 +612,75 @@ 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 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 (!HaveUsersConfig()) {
return nullptr;
}
std::vector<std::string> 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);
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;
}
// 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 (!HaveUsersConfig()) {
return false;
}
const std::vector<std::string> 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++) {
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());
}
}
// 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;
}
// Need to use raw Android logging before NativeInit.
#define EARLY_LOG(...) __android_log_print(ANDROID_LOG_INFO, "PPSSPP", __VA_ARGS__)
@@ -2,6 +2,10 @@ package org.ppsspp.ppsspp;
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;
import android.os.Build;
@@ -10,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;
@@ -18,7 +23,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 +427,183 @@ 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. 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.
//
// 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, 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.
private static final String GRANT_TAG = "PPSSPPUriGrants";
private static boolean isTreeUri(Uri uri) {
// Same check as DocumentsContract.isTreeUri, which is API 24+.
List<String> segments = uri.getPathSegments();
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<String> 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<UriPermission> chooseGrantsToRelease(List<UriPermission> grants, int maxGrants, Set<String> referencedUris) {
ArrayList<UriPermission> toRelease = new ArrayList<>();
int excess = grants.size() - maxGrants;
if (excess <= 0) {
return toRelease;
}
ArrayList<UriPermission> 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()) || isReferenced(grant.getUri(), referencedUris)) {
continue;
}
toRelease.add(grant);
}
return toRelease;
}
// 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<UriPermission> 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, "Don't have the user's config, not touching any of our " + grants.size() + " URI grants.");
return;
}
final Set<String> 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<UriPermission> 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;
}
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);
}
}
} catch (Exception 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<String> readPendingShortcutUris(SharedPreferences prefs) {
ArrayList<String> 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<String> 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<String> 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() {
@@ -82,6 +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, the remembered shortcuts and the settings still point at.
// 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.
public static native boolean addShortcutUris(String[] uris);
public static native int getSelectedCamera();
public static native int getDisplayFramerateMode();
@@ -512,6 +512,10 @@ 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);
// Both need the config.
ContentUri.flushShortcutUris(this);
ContentUri.manageUriGrants(this);
sendInitialGrants();
// OK, config should be initialized, we can query for screen rotation.
@@ -743,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()) {
@@ -2164,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.
@@ -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;