From 666dfd2187d82c0d045f5450f53c692fdb813307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 6 Nov 2026 12:09:55 +0100 Subject: [PATCH] Android: Hold a client for the storage provider between calls Without one, every query and DocumentsContract call acquires the provider and releases it again, and when our reference count drops to zero, each of those is a binder call to the system as well. Holding an unstable ContentProviderClient keeps the count up, without tying our process to the provider's. Queries go through it, and a dead provider is dropped and acquired again. Storage test timings on an SD card, median of five runs before and after: exists 9.3 -> 6.7 ms, file info 10.4 -> 7.4, overwrite 21.8 -> 18.6, read 28.5 -> 26.6. Creating, deleting and listing are dominated by the provider's own work, and gain a few percent. Co-Authored-By: Claude Opus 5.5 --- android/src/org/ppsspp/ppsspp/ContentUri.java | 66 +++++++++++++++++-- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/android/src/org/ppsspp/ppsspp/ContentUri.java b/android/src/org/ppsspp/ppsspp/ContentUri.java index eb11d21129..b31b01e796 100644 --- a/android/src/org/ppsspp/ppsspp/ContentUri.java +++ b/android/src/org/ppsspp/ppsspp/ContentUri.java @@ -1,6 +1,7 @@ package org.ppsspp.ppsspp; import android.app.Activity; +import android.content.ContentProviderClient; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; @@ -44,6 +45,61 @@ public class ContentUri { private static final int STORAGE_ERROR_DISK_FULL = -3; private static final int STORAGE_ERROR_ALREADY_EXISTS = -4; + // Without a client of our own, every query and DocumentsContract call acquires the storage provider and + // releases it again, and when our reference count drops to zero, each of those is another binder call, + // to the system. Holding one keeps the count up. Unstable, so the provider dying doesn't take our process + // with it: queries that then fail drop it, and the next one acquires a new one. + private static final Object clientLock = new Object(); + private static ContentProviderClient client; + private static String clientAuthority; + + private static ContentProviderClient getClient(Context context, Uri uri) { + final String authority = uri.getAuthority(); + if (authority == null) { + return null; + } + synchronized (clientLock) { + if (client != null && authority.equals(clientAuthority)) { + return client; + } + dropClientLocked(); + // The application's resolver, so the client doesn't keep the activity alive. + client = context.getApplicationContext().getContentResolver().acquireUnstableContentProviderClient(authority); + clientAuthority = authority; + return client; + } + } + + private static void dropClientLocked() { + if (client == null) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + client.close(); + } else { + client.release(); + } + client = null; + clientAuthority = null; + } + + private static Cursor query(Context context, Uri uri, String[] projection) throws Exception { + final ContentProviderClient c = getClient(context, uri); + if (c != null) { + try { + return c.query(uri, projection, null, null, null); + } catch (android.os.DeadObjectException e) { + Log.w(TAG, "Storage provider died, acquiring it again"); + synchronized (clientLock) { + if (client == c) { + dropClientLocked(); + } + } + } + } + return context.getContentResolver().query(uri, projection, null, null, null); + } + @Keep @SuppressWarnings("unused") public static int openContentUri(Activity activity, String uriString, String mode) { @@ -110,10 +166,9 @@ public class ContentUri { DocumentsContract.Document.COLUMN_SIZE, DocumentsContract.Document.COLUMN_MIME_TYPE, // check for MIME_TYPE_DIR }; - final ContentResolver resolver = activity.getContentResolver(); final String documentId = DocumentsContract.getDocumentId(uri); final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, documentId); - c = resolver.query(childrenUri, columns, null, null, null); + c = query(activity, childrenUri, columns); long sizeSum = 0; // Buffer the URIs so we only have one cursor active at once. I don't trust the storage framework @@ -177,7 +232,6 @@ public class ContentUri { public static String[] listContentUriDir(Activity activity, String uriString, String prefix) { try { Uri uri = Uri.parse(uriString); - final ContentResolver resolver = activity.getContentResolver(); final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( uri, DocumentsContract.getDocumentId(uri)); final ArrayList listing = new ArrayList<>(); @@ -194,7 +248,7 @@ public class ContentUri { prefix = prefix.toLowerCase(Locale.ROOT); } - try (Cursor c = resolver.query(childrenUri, projection, null, null, null)) { + try (Cursor c = query(activity, childrenUri, projection)) { if (c == null) { return new String[]{"X"}; } @@ -355,7 +409,7 @@ public class ContentUri { Cursor c = null; try { Uri uri = Uri.parse(fileUri); - c = activity.getContentResolver().query(uri, new String[] { DocumentsContract.Document.COLUMN_DOCUMENT_ID }, null, null, null); + c = query(activity, uri, new String[] { DocumentsContract.Document.COLUMN_DOCUMENT_ID }); if (c != null) { return c.getCount() > 0; } else { @@ -380,7 +434,7 @@ public class ContentUri { DocumentsContract.Document.COLUMN_LAST_MODIFIED // index 4 }; - try (Cursor c = activity.getContentResolver().query(Uri.parse(fileName), projection, null, null, null)) { + try (Cursor c = query(activity, Uri.parse(fileName), projection)) { if (c != null && c.moveToFirst()) { return cursorToString(c, null); } else {