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/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(); } diff --git a/android/jni/app-android.cpp b/android/jni/app-android.cpp index 5b317acfe9..6b7e1538c8 100644 --- a/android/jni/app-android.cpp +++ b/android/jni/app-android.cpp @@ -8,6 +8,7 @@ #if PPSSPP_PLATFORM(ANDROID) +#include #include #include @@ -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 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 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__) diff --git a/android/src/org/ppsspp/ppsspp/ContentUri.java b/android/src/org/ppsspp/ppsspp/ContentUri.java index 86ba5b1b9a..4dbb5bd5d2 100644 --- a/android/src/org/ppsspp/ppsspp/ContentUri.java +++ b/android/src/org/ppsspp/ppsspp/ContentUri.java @@ -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 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 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<>(); + 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()) || 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 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 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; + } + 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 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 81f77856b0..8484ee5c35 100644 --- a/android/src/org/ppsspp/ppsspp/NativeApp.java +++ b/android/src/org/ppsspp/ppsspp/NativeApp.java @@ -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(); diff --git a/android/src/org/ppsspp/ppsspp/PpssppActivity.java b/android/src/org/ppsspp/ppsspp/PpssppActivity.java index 37d7feeed4..7835a075eb 100644 --- a/android/src/org/ppsspp/ppsspp/PpssppActivity.java +++ b/android/src/org/ppsspp/ppsspp/PpssppActivity.java @@ -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. 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;