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;