From 6d5ace46f907276f9271d00870235d271a80a5c2 Mon Sep 17 00:00:00 2001 From: crudelios Date: Tue, 26 May 2026 14:36:15 +0100 Subject: [PATCH 1/4] Android: add support for `content://` directory traversal --- .../main/java/org/libsdl/app/SDLActivity.java | 315 ++++++++++++++- include/SDL3/SDL_dialog.h | 15 + src/core/android/SDL_android.c | 364 +++++++++++++++++- src/core/android/SDL_android.h | 2 + src/filesystem/posix/SDL_sysfsops.c | 4 + 5 files changed, 682 insertions(+), 18 deletions(-) diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java index 151224daad..df5d3bed02 100644 --- a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java +++ b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java @@ -14,6 +14,7 @@ import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.res.Configuration; +import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.PorterDuff; @@ -51,6 +52,9 @@ import android.widget.Toast; import java.io.FileNotFoundException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.Hashtable; import java.util.Locale; @@ -556,6 +560,13 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh Log.v(TAG, "onResume()"); super.onResume(); + /* + Clear the SAF cache as the user may have edited files while + app was paused. This is far from a perfect check, but good + enough for our use case. + */ + SAFDocument.requestCacheInvalidation(); + if (mHIDDeviceManager != null) { mHIDDeviceManager.setFrozen(false); } @@ -775,13 +786,18 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh } else { /* If the user selected a directory and the persistent permission hint has been set, make the permission persistable */ - if (mFileDialogState.type == SDL_FILEDIALOG_OPENFOLDER && mFileDialogState.persistable) { - mSingleton.getContentResolver().takePersistableUriPermission(singleFileUri, - Intent.FLAG_GRANT_READ_URI_PERMISSION | - Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + if (mFileDialogState.type == SDL_FILEDIALOG_OPENFOLDER) { + if (mFileDialogState.persistable) { + mSingleton.getContentResolver().takePersistableUriPermission(singleFileUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } + /* Add an empty fragment to the URI to mark it as generated by SDL */ + filelist = new String[]{singleFileUri + "#"}; + } else { + /* Only one file is selected. */ + filelist = new String[]{singleFileUri.toString()}; } - /* Only one file is selected. */ - filelist = new String[]{singleFileUri.toString()}; } } else { /* User cancelled the request. */ @@ -2092,21 +2108,283 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh return true; } + public static class SAFDocument { + static private final HashMap cache = new HashMap<>(); + static private boolean cacheInvalidationRequested = false; + static public void requestCacheInvalidation() { + cacheInvalidationRequested = true; + } + + private boolean dirty; + private final String id; + private final String mimeType; + private long lastModified; + private long size; + private final Uri uri; + private final Uri tree; + private HashMap children; + + private SAFDocument(Cursor cursor, Uri uri) { + this.id = cursor.getString(0); + this.mimeType = cursor.getString(2); + this.lastModified = cursor.getLong(3); + this.size = cursor.getLong(4); + this.tree = uri; + this.uri = uri; + this.dirty = false; + } + + private SAFDocument(Cursor cursor, SAFDocument parent) { + this.id = cursor.getString(0); + this.mimeType = cursor.getString(2); + this.lastModified = cursor.getLong(3); + this.size = cursor.getLong(4); + this.tree = parent.tree; + this.uri = DocumentsContract.buildDocumentUriUsingTree(this.tree, this.id); + this.dirty = false; + } + + private SAFDocument(Uri tree) { + this.id = DocumentsContract.getTreeDocumentId(tree); + this.mimeType = DocumentsContract.Document.MIME_TYPE_DIR; + this.tree = tree; + this.uri = DocumentsContract.buildDocumentUriUsingTree(this.tree, this.id); + this.dirty = true; + } + + private SAFDocument(Uri uri, Uri tree, String mimeType) + { + this.id = DocumentsContract.getDocumentId(uri); + this.mimeType = mimeType; + this.lastModified = System.currentTimeMillis(); + this.tree = tree; + this.uri = uri; + this.dirty = true; + } + + static private final String[] queryColumns = new String[] { + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_SIZE + }; + + static private final String newDocumentMimeType = "application/octet-stream"; + + private static SAFDocument getTree(Uri uri) { + Uri tree = uri.buildUpon().fragment(null).build(); + + if (cacheInvalidationRequested) { + cache.clear(); + cacheInvalidationRequested = false; + } + + SAFDocument treeInfo = cache.get(tree); + if (treeInfo == null) { + treeInfo = new SAFDocument(tree); + cache.put(tree, treeInfo); + } + return treeInfo; + } + + private static SAFDocument fromUri(Uri uri) throws FileNotFoundException { + /* Tree URIs get special handling */ + if (!DocumentsContract.isDocumentUri(mSingleton.getApplicationContext(), uri)) { + return new SAFDocument(uri); + } + + Cursor cursor = mSingleton.getContentResolver().query(uri, SAFDocument.queryColumns, + null, null, null); + + if (cursor == null) { + throw new FileNotFoundException("The URI " + uri + " is not valid"); + } + + cursor.moveToFirst(); + SAFDocument document = new SAFDocument(cursor, uri); + cursor.close(); + + return document; + } + + private static SAFDocument fromPath(Uri uri, String[] pathSegments) throws FileNotFoundException { + SAFDocument document = SAFDocument.getTree(uri); + + for (String segment : pathSegments) { + if (segment.isEmpty()) { + continue; + } + document = document.getChildren().get(segment); + if (document == null) { + throw new FileNotFoundException("Failed to resolve path segment \"" + segment + "\" in path \"" + String.join("/", pathSegments) + "\""); + } + } + + return document; + } + + public static SAFDocument get(Uri uri) throws FileNotFoundException { + /* Original "content://" URI, parse directly */ + if (uri.getFragment() == null) { + return SAFDocument.fromUri(uri); + } else { + /* SDL "content://" URI, with "#path" at the end, parse by directory */ + return SAFDocument.fromPath(uri, uri.getFragment().split("/")); + } + } + + public static SAFDocument create(Uri uri) throws IllegalArgumentException, FileNotFoundException { + if (uri.getFragment() == null) { + throw new IllegalArgumentException("Not an SDL generated URI to get a parent"); + } + + /* It's safe to assume this method is only called when the provided URI is confirmed as not existing */ + ArrayList path = new ArrayList<>(Arrays.asList(uri.getFragment().split("/"))); + path.removeAll(Collections.singleton("")); + if (path.isEmpty()) { + /* If original path is "" and it does not exist, something went very wrong */ + throw new IllegalArgumentException("Invalid path for document creation: " + uri.getFragment()); + } + + /* Separate the new file name and the parent path, then create the new file in the parent directory */ + String newFileName = path.get(path.size() - 1); + path.remove(path.size() - 1); + SAFDocument parent = SAFDocument.fromPath(uri, path.toArray(new String[0])); + + Uri newFileUri = DocumentsContract.createDocument(mSingleton.getContentResolver(), + parent.uri, SAFDocument.newDocumentMimeType, newFileName); + if (newFileUri == null) { + throw new IllegalArgumentException("Unable to create a new file for writing"); + } + + SAFDocument newFileInfo = new SAFDocument(newFileUri, parent.tree, SAFDocument.newDocumentMimeType); + parent.children.put(newFileName, newFileInfo); + + return newFileInfo; + } + + public HashMap getChildren() throws FileNotFoundException { + if (!this.isDirectory()) { + throw new FileNotFoundException(this.id + " is not a directory for uri: " + this.uri); + } + if (this.children != null) { + return this.children; + } + + Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(this.tree, this.id); + + Cursor cursor = mSingleton.getContentResolver().query(children, queryColumns, + null, null, null); + + if (cursor == null) { + throw new FileNotFoundException("The URI " + uri + " is not a valid"); + } + + this.children = new HashMap<>(); + + /* Get the directory contents */ + while (cursor.moveToNext()) { + SAFDocument document = new SAFDocument(cursor, this); + this.children.put(cursor.getString(1), document); + } + cursor.close(); + + return this.children; + } + + public void requestUpdate() { + this.dirty = true; + } + + private void update() { + Cursor cursor = mSingleton.getContentResolver().query(this.uri, SAFDocument.queryColumns, + null, null, null); + + if (cursor == null) { + return; + } + + cursor.moveToFirst(); + this.lastModified = cursor.getLong(3); + this.size = cursor.getLong(4); + cursor.close(); + + this.dirty = false; + } + + public boolean isDirectory() { + return this.mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); + } + + public long getLastModified() { + if (this.dirty) { + this.update(); + } + return this.lastModified; + } + + public long getSize() { + if (this.dirty) { + this.update(); + } + return this.size; + } + + public String getUri() { + return this.uri.toString(); + } + } + /** * This method is called by SDL using JNI. */ - public static int openFileDescriptor(String uri, String mode) throws Exception { + public static String[] getSAFDocumentChildrenNames(String uri) throws FileNotFoundException { if (mSingleton == null) { - return -1; + throw new IllegalStateException("SDLActivity is not initialized"); } - try { - ParcelFileDescriptor pfd = mSingleton.getContentResolver().openFileDescriptor(Uri.parse(uri), mode); - return pfd != null ? pfd.detachFd() : -1; - } catch (FileNotFoundException e) { - e.printStackTrace(); - return -1; + return SAFDocument.get(Uri.parse(uri)).getChildren().keySet().toArray(new String[0]); + } + + /** + * This method is called by SDL using JNI. + */ + public static SAFDocument getSAFDocument(String uri) throws FileNotFoundException { + if (mSingleton == null) { + throw new IllegalStateException("SDLActivity is not initialized"); } + + return SAFDocument.get(Uri.parse(uri)); + } + + /** + * This method is called by SDL using JNI. + */ + public static int openFileDescriptor(String uri, String mode) throws FileNotFoundException { + if (mSingleton == null) { + throw new IllegalStateException("SDLActivity is not initialized"); + } + + Uri contentUri = Uri.parse(uri); + if (contentUri.getFragment() != null) { + try { + SAFDocument document = SAFDocument.get(contentUri); + contentUri = document.uri; + if (mode.contains("w")) { + document.requestUpdate(); + } + } catch (FileNotFoundException e) { + if (!mode.contains("w")) { + throw e; + } + /* Maybe we want to create a new file and write to it? */ + contentUri = SAFDocument.create(contentUri).uri; + } + } + + ParcelFileDescriptor pfd = mSingleton.getContentResolver().openFileDescriptor(contentUri, mode); + return pfd != null ? pfd.detachFd() : -1; } /** @@ -2145,8 +2423,12 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh if (initialPath != null && !initialPath.isEmpty()) { try { initialPathUri = Uri.parse(initialPath); + if (initialPathUri.getFragment() != null) { + initialPathUri = SAFDocument.get(initialPathUri).uri; + } } catch (Exception e) { Log.e(TAG, "Failed to parse initial path URI, ignoring initial path", e); + initialPathUri = null; } } @@ -2196,10 +2478,13 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh intent.addFlags(intent_flags); } - if (initialPathUri != null) { + if (initialPathUri != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialPathUri); } + intent.putExtra("android.content.extra.FANCY", true); + intent.putExtra("android.content.extra.SHOW_ADVANCED", true); + /* Display the file/folder dialog */ try { mSingleton.startActivityForResult(intent, requestCode); diff --git a/include/SDL3/SDL_dialog.h b/include/SDL3/SDL_dialog.h index ab197311cf..85b32ffb25 100644 --- a/include/SDL3/SDL_dialog.h +++ b/include/SDL3/SDL_dialog.h @@ -232,6 +232,20 @@ extern SDL_DECLSPEC void SDLCALL SDL_ShowSaveFileDialog(SDL_DialogFileCallback c * requires an event-handling loop. Apps that do not use SDL to handle events * should add a call to SDL_PumpEvents in their main loop. * + * On Android, if folder selection is successful, the callback will receive a + * custom `content://` URI with a `#` at the end. These custom URI's can have + * a regular path, separated by either forward or backward slashes, after the + * `#`. + * + * This allows the user to treat the selected folder as a regular filesystem + * base directory, and access its contents with the usual SDL file/directory + * I/O functions. Internally, the path after the `#` will be properly converted + * to the corresponding child document URI when needed. + * + * Both regular `content://` URIs and the ones with a `#` at the end + * are supported by SDL_IOFromFile() with appropriate modes, as well as + * SDL_EnumerateDirectory() and SDL_GetPathInfo(). + * * \param callback a function pointer to be invoked when the user selects a * file and accepts, or cancels the dialog, or an error * occurs. @@ -249,6 +263,7 @@ extern SDL_DECLSPEC void SDLCALL SDL_ShowSaveFileDialog(SDL_DialogFileCallback c * different one, depending on the OS's constraints. * * \since This function is available since SDL 3.2.0. + * On Android, the function is implemented since SDL 3.6.0. * * \sa SDL_DialogFileCallback * \sa SDL_ShowOpenFileDialog diff --git a/src/core/android/SDL_android.c b/src/core/android/SDL_android.c index aada458481..5cba42aed0 100644 --- a/src/core/android/SDL_android.c +++ b/src/core/android/SDL_android.c @@ -398,6 +398,8 @@ static jmethodID midShowTextInput; static jmethodID midSupportsRelativeMouse; static jmethodID midOpenFileDescriptor; static jmethodID midShowFileDialog; +static jmethodID midGetDocumentChildrenNames; +static jmethodID midGetSAFDocument; static jmethodID midGetPreferredLocales; // audio manager @@ -434,6 +436,13 @@ static void Internal_Android_Destroy_AssetManager(void); static AAssetManager *asset_manager = NULL; static jobject javaAssetManagerRef = 0; +// Android content file handling +static jclass mSAFDocumentClass = NULL; +static jmethodID midIsDirectory = NULL; +static jmethodID midGetLastModified = NULL; +static jmethodID midGetSize = NULL; +static jmethodID midGetUri = NULL; + static SDL_Mutex *Android_ActivityMutex = NULL; static SDL_Mutex *Android_LifecycleMutex = NULL; static SDL_Semaphore *Android_LifecycleEventSem = NULL; @@ -689,6 +698,8 @@ JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(nativeSetupJNI)(JNIEnv *env, jclass cl midSupportsRelativeMouse = (*env)->GetStaticMethodID(env, mActivityClass, "supportsRelativeMouse", "()Z"); midOpenFileDescriptor = (*env)->GetStaticMethodID(env, mActivityClass, "openFileDescriptor", "(Ljava/lang/String;Ljava/lang/String;)I"); midShowFileDialog = (*env)->GetStaticMethodID(env, mActivityClass, "showFileDialog", "([Ljava/lang/String;ZILjava/lang/String;I)Z"); + midGetDocumentChildrenNames = (*env)->GetStaticMethodID(env, mActivityClass, "getSAFDocumentChildrenNames", "(Ljava/lang/String;)[Ljava/lang/String;"); + midGetSAFDocument = (*env)->GetStaticMethodID(env, mActivityClass, "getSAFDocument", "(Ljava/lang/String;)Lorg/libsdl/app/SDLActivity$SAFDocument;"); midGetPreferredLocales = (*env)->GetStaticMethodID(env, mActivityClass, "getPreferredLocales", "()Ljava/lang/String;"); if (!midClipboardGetText || @@ -721,6 +732,8 @@ JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(nativeSetupJNI)(JNIEnv *env, jclass cl !midSupportsRelativeMouse || !midOpenFileDescriptor || !midShowFileDialog || + !midGetDocumentChildrenNames || + !midGetSAFDocument || !midGetPreferredLocales) { __android_log_print(ANDROID_LOG_WARN, "SDL", "Missing some Java callbacks, do you have the latest version of SDLActivity.java?"); } @@ -2495,6 +2508,338 @@ bool Android_JNI_EnumerateAssetDirectory(const char *path, SDL_EnumerateDirector return (result != SDL_ENUM_FAILURE); } +static char *GetURIWithNormalizedPath(const char *uri, bool should_append_slash) +{ + if (!uri) { + return NULL; + } + + const char *uri_cursor = SDL_strchr(uri, '#'); + + // Don't try to normalize real URI's or empty paths + if (!uri_cursor || !uri_cursor[1]) { + char *normalized = SDL_strdup(uri); + if (!normalized) { + SDL_SetError("Out of memory"); + } + return normalized; + } + + uri_cursor++; + + // Final size needs to account for a slash at the end and a null char after it + char *normalized = SDL_malloc(SDL_strlen(uri) + should_append_slash + 1); + if (!normalized) { + SDL_SetError("Out of memory"); + return NULL; + } + + // Copy "content://#" to the normalized string + SDL_strlcpy(normalized, uri, uri_cursor - uri + 1); + char *path = SDL_strchr(normalized, '#') + 1; + char *path_cursor = path; + + while (*uri_cursor) { + if (*uri_cursor == '/' || *uri_cursor == '\\') { + uri_cursor++; + continue; + } + if (uri_cursor[0] == '.' && + (uri_cursor[1] == '\0' || uri_cursor[1] == '/' || uri_cursor[1] == '\\')) { + uri_cursor++; + continue; + } + if (uri_cursor[0] == '.' && uri_cursor[1] == '.' && + (uri_cursor[2] == '\0' || uri_cursor[2] == '/' || uri_cursor[2] == '\\')) { + if (path_cursor == path) { + SDL_SetError("Traversing over the root path is not allowed. Path: %s", + SDL_strchr(uri, '#') + 1); + SDL_free(normalized); + return NULL; + } + path_cursor--; + while (path_cursor != path && *(path_cursor - 1) != '/') { + *path_cursor = 0; + path_cursor--; + } + uri_cursor += 2; + continue; + } + while (*uri_cursor && *uri_cursor != '/' && *uri_cursor != '\\') { + *path_cursor++ = *uri_cursor++; + } + if (path_cursor != path) { + *path_cursor++ = '/'; + } + } + if (!should_append_slash && *(path_cursor -1) == '/') { + path_cursor--; + } + *path_cursor = '\0'; + + return normalized; +} + +static bool CreateSAFDocumentClass(JNIEnv *env, jobject jSAFDocument) +{ + if (mSAFDocumentClass && midIsDirectory && midGetLastModified && midGetSize && midGetUri) { + return true; + } + + if (!env || !jSAFDocument) { + return false; + } + + if (!mSAFDocumentClass) { + jclass jLocalSAFDocumentClass = (*env)->GetObjectClass(env, jSAFDocument); + if (Android_JNI_ExceptionOccurred(false)) { + return false; + } + + mSAFDocumentClass = (*env)->NewGlobalRef(env, jLocalSAFDocumentClass); + (*env)->DeleteLocalRef(env, jLocalSAFDocumentClass); + + if (Android_JNI_ExceptionOccurred(false)) { + return false; + } + } + + if (!midIsDirectory) { + midIsDirectory = (*env)->GetMethodID(env, mSAFDocumentClass, "isDirectory", "()Z"); + if (Android_JNI_ExceptionOccurred(false)) { + return false; + } + } + + if (!midGetLastModified) { + midGetLastModified = (*env)->GetMethodID(env, mSAFDocumentClass, "getLastModified", "()J"); + if (Android_JNI_ExceptionOccurred(false)) { + return false; + } + } + + if (!midGetSize) { + midGetSize = (*env)->GetMethodID(env, mSAFDocumentClass, "getSize", "()J"); + if (Android_JNI_ExceptionOccurred(false)) { + return false; + } + } + + if (!midGetUri) { + midGetUri = (*env)->GetMethodID(env, mSAFDocumentClass, "getUri", "()Ljava/lang/String;"); + if (Android_JNI_ExceptionOccurred(false)) { + return false; + } + } + + return true; +} + +static char *GetDocumentURIFromTreeURI(JNIEnv *env, const char *uri) +{ + // Set "uri" parameter for JNI call + jstring juri = (*env)->NewStringUTF(env, uri); + + if (Android_JNI_ExceptionOccurred(false)) { + return NULL; + } + + // Invoke JNI + jobject jSAFDocument = (*env)->CallStaticObjectMethod(env, mActivityClass, midGetSAFDocument, juri); + + (*env)->DeleteLocalRef(env, juri); + + if (Android_JNI_ExceptionOccurred(false)) { + return NULL; + } + + if (!CreateSAFDocumentClass(env, jSAFDocument)) { + return NULL; + } + + // Get the uri from the SAFDocument + jstring jdocument_uri = (*env)->CallObjectMethod(env, jSAFDocument, midGetUri); + if (Android_JNI_ExceptionOccurred(false)) { + return NULL; + } + + const char *document_uri = (*env)->GetStringUTFChars(env, jdocument_uri, NULL); + if (Android_JNI_ExceptionOccurred(false)) { + return NULL; + } + + // We need to reserve space for an encoded separator ("%2F") at the end + char *final_uri = SDL_calloc(SDL_strlen(document_uri) + 4, sizeof(char)); + if (!final_uri) { + return NULL; + } + size_t offset = SDL_strlcpy(final_uri, document_uri, SDL_strlen(document_uri) + 1); + SDL_strlcpy(final_uri + offset, "%2F", 4); + + (*env)->ReleaseStringUTFChars(env, jdocument_uri, document_uri); + (*env)->DeleteLocalRef(env, jdocument_uri); + (*env)->DeleteLocalRef(env, jSAFDocument); + + return final_uri; +} + +bool Android_JNI_EnumerateContentDirectory(const char *uri, SDL_EnumerateDirectoryCallback cb, void *userdata) +{ + SDL_assert(uri != NULL); + SDL_assert(SDL_strncmp(uri, "content://", 10) == 0); + + struct LocalReferenceHolder refs = LocalReferenceHolder_Setup(SDL_FUNCTION); + JNIEnv *env = Android_JNI_GetEnv(); + + if (!LocalReferenceHolder_Init(&refs, env)) { + LocalReferenceHolder_Cleanup(&refs); + return false; + } + + char *dirname = GetURIWithNormalizedPath(uri, true); + if (!dirname) { + LocalReferenceHolder_Cleanup(&refs); + return false; + } + + // Set "uri" parameter for JNI call + jstring juri = (*env)->NewStringUTF(env, dirname); + if (Android_JNI_ExceptionOccurred(false)) { + SDL_free(dirname); + LocalReferenceHolder_Cleanup(&refs); + return false; + } + + // Invoke JNI + jobjectArray jFileNamesArray = (*env)->CallStaticObjectMethod(env, mActivityClass, + midGetDocumentChildrenNames, juri); + + if (Android_JNI_ExceptionOccurred(false)) { + LocalReferenceHolder_Cleanup(&refs); + return false; + } + + jsize length = (*env)->GetArrayLength(env, jFileNamesArray); + bool success = true; + + // If we are traversing a genuine tree URI and not an SDL generated one, the "dirname" + // parameter on the enumeration callback must provide the document URI, not the tree URI. + // Because of that, if we're using a tree URI, we need to fetch the document URI from Java. + if (!SDL_strchr(dirname, '#')) { + SDL_free(dirname); + dirname = GetDocumentURIFromTreeURI(env, uri); + if (!dirname) { + LocalReferenceHolder_Cleanup(&refs); + return false; + } + } + + for (int i = 0; i < length; ++i) { + jstring jFileName = (*env)->GetObjectArrayElement(env, jFileNamesArray, i); + + if (Android_JNI_ExceptionOccurred(false)) { + success = false; + break; + } + + const char *filename = (*env)->GetStringUTFChars(env, jFileName, NULL); + + if (Android_JNI_ExceptionOccurred(false)) { + success = false; + break; + } + + SDL_EnumerationResult result = cb(userdata, dirname, filename); + + (*env)->ReleaseStringUTFChars(env, jFileName, filename); + (*env)->DeleteLocalRef(env, jFileName); + + if (result != SDL_ENUM_CONTINUE) { + success = result == SDL_ENUM_SUCCESS; + break; + } + } + + SDL_free(dirname); + LocalReferenceHolder_Cleanup(&refs); + + return success; +} + +bool Android_JNI_GetContentInfo(const char *uri, SDL_PathInfo *info) +{ + SDL_assert(uri != NULL); + SDL_assert(SDL_strncmp(uri, "content://", 10) == 0); + + struct LocalReferenceHolder refs = LocalReferenceHolder_Setup(SDL_FUNCTION); + JNIEnv *env = Android_JNI_GetEnv(); + + if (!LocalReferenceHolder_Init(&refs, env)) { + LocalReferenceHolder_Cleanup(&refs); + return false; + } + + char *normalizedUri = GetURIWithNormalizedPath(uri, false); + if (!normalizedUri) { + LocalReferenceHolder_Cleanup(&refs); + return false; + } + + // Set "uri" parameter for JNI call + jstring juri = (*env)->NewStringUTF(env, normalizedUri); + SDL_free(normalizedUri); + + if (Android_JNI_ExceptionOccurred(false)) { + LocalReferenceHolder_Cleanup(&refs); + return false; + } + + // Invoke JNI + jobject jSAFDocument = (*env)->CallStaticObjectMethod(env, mActivityClass, midGetSAFDocument, juri); + + if (Android_JNI_ExceptionOccurred(false)) { + LocalReferenceHolder_Cleanup(&refs); + return false; + } + + if (!CreateSAFDocumentClass(env, jSAFDocument)) { + LocalReferenceHolder_Cleanup(&refs); + return false; + } + + // Get the fields from the SAFDocument object + bool is_directory = (*env)->CallBooleanMethod(env, jSAFDocument, midIsDirectory); + if (Android_JNI_ExceptionOccurred(false)) { + LocalReferenceHolder_Cleanup(&refs); + return false; + } + + long last_modified = (*env)->CallLongMethod(env, jSAFDocument, midGetLastModified); + if (Android_JNI_ExceptionOccurred(false)) { + LocalReferenceHolder_Cleanup(&refs); + return false; + } + + long size = (*env)->CallLongMethod(env, jSAFDocument, midGetSize); + if (Android_JNI_ExceptionOccurred(false)) { + LocalReferenceHolder_Cleanup(&refs); + return false; + } + + // Populate the SDL_PathInfo structure + info->type = is_directory ? SDL_PATHTYPE_DIRECTORY : SDL_PATHTYPE_FILE; + info->modify_time = (SDL_Time)(last_modified / 1000); // Convert milliseconds to seconds + info->size = (Uint64)size; + + // Android doesn't provide separate create and access times, so we use modify time for those as well. + info->create_time = info->modify_time; + info->access_time = info->modify_time; + + LocalReferenceHolder_Cleanup(&refs); + + return true; +} + bool Android_JNI_GetAssetPathInfo(const char *path, SDL_PathInfo *info) { SDL_assert(path != NULL); @@ -3320,12 +3665,23 @@ int Android_JNI_OpenFileDescriptor(const char *uri, const char *mode) } JNIEnv *env = Android_JNI_GetEnv(); - jstring jstringUri = (*env)->NewStringUTF(env, uri); + char *normalizedUri = GetURIWithNormalizedPath(uri, false); + if (!normalizedUri) { + return -1; + } + + jstring jstringUri = (*env)->NewStringUTF(env, normalizedUri); jstring jstringMode = (*env)->NewStringUTF(env, contentResolverMode); jint fd = (*env)->CallStaticIntMethod(env, mActivityClass, midOpenFileDescriptor, jstringUri, jstringMode); + + SDL_free(normalizedUri); (*env)->DeleteLocalRef(env, jstringUri); (*env)->DeleteLocalRef(env, jstringMode); + if (Android_JNI_ExceptionOccurred(false)) { + return -1; + } + if (fd == -1) { SDL_SetError("Unspecified error in JNI"); } @@ -3458,8 +3814,10 @@ bool Android_JNI_ShowFileDialog( // Setup initial path jstring initialPathString = NULL; - if (initialPath && *initialPath) { - initialPathString = (*env)->NewStringUTF(env, initialPath); + char *normalizedInitialPath = GetURIWithNormalizedPath(initialPath, false); + if (normalizedInitialPath && *normalizedInitialPath) { + initialPathString = (*env)->NewStringUTF(env, normalizedInitialPath); + SDL_free(normalizedInitialPath); } // Setup data diff --git a/src/core/android/SDL_android.h b/src/core/android/SDL_android.h index ec9d1dc863..84bb4e7a37 100644 --- a/src/core/android/SDL_android.h +++ b/src/core/android/SDL_android.h @@ -89,6 +89,8 @@ size_t Android_JNI_FileWrite(void *userdata, const void *buffer, size_t size, SD bool Android_JNI_FileClose(void *userdata); bool Android_JNI_EnumerateAssetDirectory(const char *path, SDL_EnumerateDirectoryCallback cb, void *userdata); bool Android_JNI_GetAssetPathInfo(const char *path, SDL_PathInfo *info); +bool Android_JNI_EnumerateContentDirectory(const char *path, SDL_EnumerateDirectoryCallback cb, void *userdata); +bool Android_JNI_GetContentInfo(const char *uri, SDL_PathInfo *info); // Environment support void Android_JNI_GetManifestEnvironmentVariables(void); diff --git a/src/filesystem/posix/SDL_sysfsops.c b/src/filesystem/posix/SDL_sysfsops.c index 41dbf63344..d50ff79719 100644 --- a/src/filesystem/posix/SDL_sysfsops.c +++ b/src/filesystem/posix/SDL_sysfsops.c @@ -55,6 +55,8 @@ bool SDL_SYS_EnumerateDirectory(const char *path, SDL_EnumerateDirectoryCallback const bool retval = pathwithsep ? Android_JNI_EnumerateAssetDirectory(pathwithsep, cb, userdata) : false; SDL_free(pathwithsep); return retval; + } else if (SDL_strncmp(path, "content://", 10) == 0) { + return Android_JNI_EnumerateContentDirectory(path, cb, userdata); } SDL_asprintf(&apath, "%s/%s", SDL_GetAndroidInternalStoragePath(), path); #elif defined(SDL_PLATFORM_IOS) @@ -356,6 +358,8 @@ bool SDL_SYS_GetPathInfo(const char *path, SDL_PathInfo *info) rc = stat(path, &statbuf); } else if (SDL_strncmp(path, "assets://", 9) == 0) { return Android_JNI_GetAssetPathInfo(path, info); + } else if (SDL_strncmp(path, "content://", 10) == 0) { + return Android_JNI_GetContentInfo(path, info); } else { char *apath = NULL; SDL_asprintf(&apath, "%s/%s", SDL_GetAndroidInternalStoragePath(), path); From cb0fe0f77268b27a4bf1fefa168a6b57c7af6502 Mon Sep 17 00:00:00 2001 From: crudelios Date: Wed, 27 May 2026 17:11:57 +0100 Subject: [PATCH 2/4] Fix potential issue, add some code comments --- src/core/android/SDL_android.c | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/core/android/SDL_android.c b/src/core/android/SDL_android.c index 5cba42aed0..89b6673633 100644 --- a/src/core/android/SDL_android.c +++ b/src/core/android/SDL_android.c @@ -2540,15 +2540,19 @@ static char *GetURIWithNormalizedPath(const char *uri, bool should_append_slash) char *path_cursor = path; while (*uri_cursor) { + // Skip empty path segments (double slashes) if (*uri_cursor == '/' || *uri_cursor == '\\') { uri_cursor++; continue; } + // Single dot path segment, skip if (uri_cursor[0] == '.' && (uri_cursor[1] == '\0' || uri_cursor[1] == '/' || uri_cursor[1] == '\\')) { uri_cursor++; continue; } + // Double dot path segment, remove the previous segment from the normalized path or + // fail if there is no previous segment to remove (attempting to traverse over the root path) if (uri_cursor[0] == '.' && uri_cursor[1] == '.' && (uri_cursor[2] == '\0' || uri_cursor[2] == '/' || uri_cursor[2] == '\\')) { if (path_cursor == path) { @@ -2558,6 +2562,8 @@ static char *GetURIWithNormalizedPath(const char *uri, bool should_append_slash) return NULL; } path_cursor--; + // Move the path cursor back to the previous path segment, + // effectively removing the last segment from the normalized path while (path_cursor != path && *(path_cursor - 1) != '/') { *path_cursor = 0; path_cursor--; @@ -2565,13 +2571,16 @@ static char *GetURIWithNormalizedPath(const char *uri, bool should_append_slash) uri_cursor += 2; continue; } + // Copy the current path segment from the URI to the normalized path until the next slash or end of string while (*uri_cursor && *uri_cursor != '/' && *uri_cursor != '\\') { *path_cursor++ = *uri_cursor++; } + // Append a slash to the normalized path if we already have a path segment if (path_cursor != path) { *path_cursor++ = '/'; } } + // Remove the trailing slash if it was not requested if (!should_append_slash && *(path_cursor -1) == '/') { path_cursor--; } @@ -3664,28 +3673,31 @@ int Android_JNI_OpenFileDescriptor(const char *uri, const char *mode) contentResolverMode = modeupdate ? "rw" : "wa"; } - JNIEnv *env = Android_JNI_GetEnv(); char *normalizedUri = GetURIWithNormalizedPath(uri, false); if (!normalizedUri) { return -1; } + JNIEnv *env = Android_JNI_GetEnv(); + struct LocalReferenceHolder refs = LocalReferenceHolder_Setup(SDL_FUNCTION); + if (!LocalReferenceHolder_Init(&refs, env)) { + LocalReferenceHolder_Cleanup(&refs); + return -1; + } + jstring jstringUri = (*env)->NewStringUTF(env, normalizedUri); jstring jstringMode = (*env)->NewStringUTF(env, contentResolverMode); jint fd = (*env)->CallStaticIntMethod(env, mActivityClass, midOpenFileDescriptor, jstringUri, jstringMode); SDL_free(normalizedUri); - (*env)->DeleteLocalRef(env, jstringUri); - (*env)->DeleteLocalRef(env, jstringMode); if (Android_JNI_ExceptionOccurred(false)) { - return -1; - } - - if (fd == -1) { + fd = -1; + } else if (fd == -1) { SDL_SetError("Unspecified error in JNI"); } + LocalReferenceHolder_Cleanup(&refs); return fd; } From e0067df491dbd95697c85f602beaa89e81fcda61 Mon Sep 17 00:00:00 2001 From: crudelios Date: Thu, 28 May 2026 15:12:20 +0100 Subject: [PATCH 3/4] Slightly improve performance by changing JNI method calls to field retrievals --- .../main/java/org/libsdl/app/SDLActivity.java | 35 +++++++------------ src/core/android/SDL_android.c | 26 +++++++------- 2 files changed, 26 insertions(+), 35 deletions(-) diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java index df5d3bed02..be7506c22d 100644 --- a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java +++ b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java @@ -2118,8 +2118,9 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh private boolean dirty; private final String id; private final String mimeType; - private long lastModified; - private long size; + public final boolean isDirectory; + public long lastModified; + public long size; private final Uri uri; private final Uri tree; private HashMap children; @@ -2129,6 +2130,7 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh this.mimeType = cursor.getString(2); this.lastModified = cursor.getLong(3); this.size = cursor.getLong(4); + this.isDirectory = this.mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); this.tree = uri; this.uri = uri; this.dirty = false; @@ -2141,12 +2143,14 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh this.size = cursor.getLong(4); this.tree = parent.tree; this.uri = DocumentsContract.buildDocumentUriUsingTree(this.tree, this.id); + this.isDirectory = this.mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); this.dirty = false; } private SAFDocument(Uri tree) { this.id = DocumentsContract.getTreeDocumentId(tree); this.mimeType = DocumentsContract.Document.MIME_TYPE_DIR; + this.isDirectory = true; this.tree = tree; this.uri = DocumentsContract.buildDocumentUriUsingTree(this.tree, this.id); this.dirty = true; @@ -2156,6 +2160,7 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh { this.id = DocumentsContract.getDocumentId(uri); this.mimeType = mimeType; + this.isDirectory = this.mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); this.lastModified = System.currentTimeMillis(); this.tree = tree; this.uri = uri; @@ -2221,6 +2226,10 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh } } + if (document.dirty) { + document.update(); + } + return document; } @@ -2264,8 +2273,8 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh return newFileInfo; } - public HashMap getChildren() throws FileNotFoundException { - if (!this.isDirectory()) { + private HashMap getChildren() throws FileNotFoundException { + if (!this.isDirectory) { throw new FileNotFoundException(this.id + " is not a directory for uri: " + this.uri); } if (this.children != null) { @@ -2313,24 +2322,6 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh this.dirty = false; } - public boolean isDirectory() { - return this.mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); - } - - public long getLastModified() { - if (this.dirty) { - this.update(); - } - return this.lastModified; - } - - public long getSize() { - if (this.dirty) { - this.update(); - } - return this.size; - } - public String getUri() { return this.uri.toString(); } diff --git a/src/core/android/SDL_android.c b/src/core/android/SDL_android.c index 89b6673633..177229d76f 100644 --- a/src/core/android/SDL_android.c +++ b/src/core/android/SDL_android.c @@ -438,9 +438,9 @@ static jobject javaAssetManagerRef = 0; // Android content file handling static jclass mSAFDocumentClass = NULL; -static jmethodID midIsDirectory = NULL; -static jmethodID midGetLastModified = NULL; -static jmethodID midGetSize = NULL; +static jfieldID fidIsDirectory = NULL; +static jfieldID fidLastModified = NULL; +static jfieldID fidSize = NULL; static jmethodID midGetUri = NULL; static SDL_Mutex *Android_ActivityMutex = NULL; @@ -2591,7 +2591,7 @@ static char *GetURIWithNormalizedPath(const char *uri, bool should_append_slash) static bool CreateSAFDocumentClass(JNIEnv *env, jobject jSAFDocument) { - if (mSAFDocumentClass && midIsDirectory && midGetLastModified && midGetSize && midGetUri) { + if (mSAFDocumentClass && fidIsDirectory && fidLastModified && fidSize && midGetUri) { return true; } @@ -2613,22 +2613,22 @@ static bool CreateSAFDocumentClass(JNIEnv *env, jobject jSAFDocument) } } - if (!midIsDirectory) { - midIsDirectory = (*env)->GetMethodID(env, mSAFDocumentClass, "isDirectory", "()Z"); + if (!fidIsDirectory) { + fidIsDirectory = (*env)->GetFieldID(env, mSAFDocumentClass, "isDirectory", "Z"); if (Android_JNI_ExceptionOccurred(false)) { return false; } } - if (!midGetLastModified) { - midGetLastModified = (*env)->GetMethodID(env, mSAFDocumentClass, "getLastModified", "()J"); + if (!fidLastModified) { + fidLastModified = (*env)->GetFieldID(env, mSAFDocumentClass, "lastModified", "J"); if (Android_JNI_ExceptionOccurred(false)) { return false; } } - if (!midGetSize) { - midGetSize = (*env)->GetMethodID(env, mSAFDocumentClass, "getSize", "()J"); + if (!fidSize) { + fidSize = (*env)->GetFieldID(env, mSAFDocumentClass, "size", "J"); if (Android_JNI_ExceptionOccurred(false)) { return false; } @@ -2817,19 +2817,19 @@ bool Android_JNI_GetContentInfo(const char *uri, SDL_PathInfo *info) } // Get the fields from the SAFDocument object - bool is_directory = (*env)->CallBooleanMethod(env, jSAFDocument, midIsDirectory); + bool is_directory = (*env)->GetBooleanField(env, jSAFDocument, fidIsDirectory); if (Android_JNI_ExceptionOccurred(false)) { LocalReferenceHolder_Cleanup(&refs); return false; } - long last_modified = (*env)->CallLongMethod(env, jSAFDocument, midGetLastModified); + jlong last_modified = (*env)->GetLongField(env, jSAFDocument, fidLastModified); if (Android_JNI_ExceptionOccurred(false)) { LocalReferenceHolder_Cleanup(&refs); return false; } - long size = (*env)->CallLongMethod(env, jSAFDocument, midGetSize); + jlong size = (*env)->GetLongField(env, jSAFDocument, fidSize); if (Android_JNI_ExceptionOccurred(false)) { LocalReferenceHolder_Cleanup(&refs); return false; From 034234062138f4c5cb5ed921f0e81181415215c7 Mon Sep 17 00:00:00 2001 From: crudelios Date: Tue, 2 Jun 2026 16:33:56 +0100 Subject: [PATCH 4/4] Further performance and memory usage improvements - Cached custom URI fetches are about ~35% faster - About ~40% less memory usage for cache - about 600KB on the example above - Cached real URI's - I feel the speedup difference (more than 100x) is too dramatic to ignore - Improve `openFileDescriptor` support to better comply with SDL's `mode` parameter --- .../main/java/org/libsdl/app/SDLActivity.java | 217 ++++++++++++------ src/core/android/SDL_android.c | 37 +-- 2 files changed, 163 insertions(+), 91 deletions(-) diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java index be7506c22d..66106479cc 100644 --- a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java +++ b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java @@ -2109,7 +2109,7 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh } public static class SAFDocument { - static private final HashMap cache = new HashMap<>(); + static private final HashMap cache = new HashMap<>(); static private boolean cacheInvalidationRequested = false; static public void requestCacheInvalidation() { cacheInvalidationRequested = true; @@ -2117,53 +2117,34 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh private boolean dirty; private final String id; - private final String mimeType; public final boolean isDirectory; public long lastModified; public long size; - private final Uri uri; - private final Uri tree; - private HashMap children; + public final Uri uri; + private ArrayList children; - private SAFDocument(Cursor cursor, Uri uri) { + private SAFDocument(Cursor cursor, Uri uri, boolean uri_is_tree) { this.id = cursor.getString(0); - this.mimeType = cursor.getString(2); this.lastModified = cursor.getLong(3); this.size = cursor.getLong(4); - this.isDirectory = this.mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); - this.tree = uri; - this.uri = uri; - this.dirty = false; - } - - private SAFDocument(Cursor cursor, SAFDocument parent) { - this.id = cursor.getString(0); - this.mimeType = cursor.getString(2); - this.lastModified = cursor.getLong(3); - this.size = cursor.getLong(4); - this.tree = parent.tree; - this.uri = DocumentsContract.buildDocumentUriUsingTree(this.tree, this.id); - this.isDirectory = this.mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); + this.isDirectory = cursor.getString(2).equals(DocumentsContract.Document.MIME_TYPE_DIR); + this.uri = uri_is_tree ? DocumentsContract.buildDocumentUriUsingTree(uri, this.id) : uri; this.dirty = false; } private SAFDocument(Uri tree) { this.id = DocumentsContract.getTreeDocumentId(tree); - this.mimeType = DocumentsContract.Document.MIME_TYPE_DIR; this.isDirectory = true; - this.tree = tree; - this.uri = DocumentsContract.buildDocumentUriUsingTree(this.tree, this.id); + this.uri = DocumentsContract.buildDocumentUriUsingTree(tree, this.id); this.dirty = true; } private SAFDocument(Uri uri, Uri tree, String mimeType) { this.id = DocumentsContract.getDocumentId(uri); - this.mimeType = mimeType; - this.isDirectory = this.mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); + this.isDirectory = mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); this.lastModified = System.currentTimeMillis(); - this.tree = tree; - this.uri = uri; + this.uri = DocumentsContract.buildDocumentUriUsingTree(tree, this.id); this.dirty = true; } @@ -2180,23 +2161,43 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh private static SAFDocument getTree(Uri uri) { Uri tree = uri.buildUpon().fragment(null).build(); - if (cacheInvalidationRequested) { - cache.clear(); - cacheInvalidationRequested = false; + SAFDocument document = SAFDocument.cache.get(tree.toString()); + if (document == null) { + document = new SAFDocument(tree); + cache.put(tree.toString(), document); } - - SAFDocument treeInfo = cache.get(tree); - if (treeInfo == null) { - treeInfo = new SAFDocument(tree); - cache.put(tree, treeInfo); - } - return treeInfo; + return document; } private static SAFDocument fromUri(Uri uri) throws FileNotFoundException { - /* Tree URIs get special handling */ - if (!DocumentsContract.isDocumentUri(mSingleton.getApplicationContext(), uri)) { - return new SAFDocument(uri); + String document_id; + + try { + document_id = DocumentsContract.getDocumentId(uri); + } catch (IllegalArgumentException e) { + /* Tree URIs get special handling */ + return SAFDocument.getTree(uri); + } + + String tree_id; + + try { + tree_id = DocumentsContract.getTreeDocumentId(uri); + } catch (IllegalArgumentException e) { + tree_id = null; + } + + String cache_key; + + if (tree_id != null) { + cache_key = uri.getAuthority() + "#" + tree_id + "/" + document_id; + } else { + cache_key = uri.getAuthority() + "#" + document_id; + } + + SAFDocument document = SAFDocument.cache.get(cache_key); + if (document != null) { + return document; } Cursor cursor = mSingleton.getContentResolver().query(uri, SAFDocument.queryColumns, @@ -2207,20 +2208,41 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh } cursor.moveToFirst(); - SAFDocument document = new SAFDocument(cursor, uri); + document = new SAFDocument(cursor, uri, false); cursor.close(); + SAFDocument.cache.put(cache_key, document); + return document; } private static SAFDocument fromPath(Uri uri, String[] pathSegments) throws FileNotFoundException { - SAFDocument document = SAFDocument.getTree(uri); + SAFDocument document = SAFDocument.cache.get(uri.toString()); + + if (document != null) { + return document; + } + + document = SAFDocument.getTree(uri); + + String createdPath = document.getTree() + "#"; + int prefix = createdPath.length(); for (String segment : pathSegments) { if (segment.isEmpty()) { continue; } - document = document.getChildren().get(segment); + + document.getChildren(createdPath.substring(prefix)); + + if (createdPath.endsWith("#")) { + createdPath += segment; + } else { + createdPath += "/" + segment; + } + + document = SAFDocument.cache.get(createdPath); + if (document == null) { throw new FileNotFoundException("Failed to resolve path segment \"" + segment + "\" in path \"" + String.join("/", pathSegments) + "\""); } @@ -2234,13 +2256,20 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh } public static SAFDocument get(Uri uri) throws FileNotFoundException { - /* Original "content://" URI, parse directly */ - if (uri.getFragment() == null) { - return SAFDocument.fromUri(uri); - } else { - /* SDL "content://" URI, with "#path" at the end, parse by directory */ - return SAFDocument.fromPath(uri, uri.getFragment().split("/")); + if (cacheInvalidationRequested) { + cache.clear(); + cacheInvalidationRequested = false; } + + String fragment = uri.getFragment(); + + /* Original "content://" URI, parse directly */ + if (fragment == null) { + return SAFDocument.fromUri(uri); + } + + /* SDL "content://" URI, with "#path" at the end, parse by directory */ + return SAFDocument.fromPath(uri, fragment.split("/")); } public static SAFDocument create(Uri uri) throws IllegalArgumentException, FileNotFoundException { @@ -2261,47 +2290,79 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh path.remove(path.size() - 1); SAFDocument parent = SAFDocument.fromPath(uri, path.toArray(new String[0])); + Uri tree = parent.getTree(); + Uri newFileUri = DocumentsContract.createDocument(mSingleton.getContentResolver(), parent.uri, SAFDocument.newDocumentMimeType, newFileName); if (newFileUri == null) { throw new IllegalArgumentException("Unable to create a new file for writing"); } - SAFDocument newFileInfo = new SAFDocument(newFileUri, parent.tree, SAFDocument.newDocumentMimeType); - parent.children.put(newFileName, newFileInfo); + SAFDocument document = new SAFDocument(newFileUri, tree, SAFDocument.newDocumentMimeType); + parent.children.add(newFileName); + SAFDocument.cache.put(tree + "#" + uri.getFragment(), document); - return newFileInfo; + return document; } - private HashMap getChildren() throws FileNotFoundException { - if (!this.isDirectory) { + private ArrayList getChildren(String path) throws FileNotFoundException { + Uri tree = this.getTree(); + + if (!this.isDirectory || tree == null) { throw new FileNotFoundException(this.id + " is not a directory for uri: " + this.uri); } if (this.children != null) { return this.children; } - Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(this.tree, this.id); + Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, this.id); Cursor cursor = mSingleton.getContentResolver().query(children, queryColumns, null, null, null); if (cursor == null) { - throw new FileNotFoundException("The URI " + uri + " is not a valid"); + throw new FileNotFoundException("The URI " + uri + " is not valid"); } - this.children = new HashMap<>(); + String cache_key_prefix; + + if (path == null) { + cache_key_prefix = this.uri.getAuthority() + "#" + + DocumentsContract.getTreeDocumentId(tree) + "/"; + } else { + if (path.isEmpty()) { + cache_key_prefix = tree + "#"; + } else { + cache_key_prefix = tree + "#" + path + "/"; + } + } + + this.children = new ArrayList<>(); /* Get the directory contents */ while (cursor.moveToNext()) { - SAFDocument document = new SAFDocument(cursor, this); - this.children.put(cursor.getString(1), document); + SAFDocument document = new SAFDocument(cursor, tree, true); + String name = cursor.getString(1); + this.children.add(name); + + /* Cache the result */ + SAFDocument.cache.put(cache_key_prefix + + (path == null ? document.id : name), document); } cursor.close(); return this.children; } + private Uri getTree() { + try { + return DocumentsContract.buildTreeDocumentUri(this.uri.getAuthority(), + DocumentsContract.getTreeDocumentId(this.uri)); + } catch(Exception e) { + return null; + } + } + public void requestUpdate() { this.dirty = true; } @@ -2335,7 +2396,14 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh throw new IllegalStateException("SDLActivity is not initialized"); } - return SAFDocument.get(Uri.parse(uri)).getChildren().keySet().toArray(new String[0]); + if (uri.endsWith("/")) { + uri = uri.substring(0, uri.length() - 1); + } else if (uri.endsWith("%2F")) { + uri = uri.substring(0, uri.length() - 3); + } + + Uri parsedUri = Uri.parse(uri); + return SAFDocument.get(parsedUri).getChildren(parsedUri.getFragment()).toArray(new String[0]); } /** @@ -2352,26 +2420,27 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh /** * This method is called by SDL using JNI. */ - public static int openFileDescriptor(String uri, String mode) throws FileNotFoundException { + public static int openFileDescriptor(String uri, String mode, boolean no_overwrite) throws FileNotFoundException, IllegalAccessException { if (mSingleton == null) { throw new IllegalStateException("SDLActivity is not initialized"); } Uri contentUri = Uri.parse(uri); - if (contentUri.getFragment() != null) { - try { - SAFDocument document = SAFDocument.get(contentUri); - contentUri = document.uri; - if (mode.contains("w")) { - document.requestUpdate(); + try { + SAFDocument document = SAFDocument.get(contentUri); + contentUri = document.uri; + if (mode.contains("w")) { + if (no_overwrite) { + throw new IllegalAccessException("The requested file \"" + uri + "\" already exists."); } - } catch (FileNotFoundException e) { - if (!mode.contains("w")) { - throw e; - } - /* Maybe we want to create a new file and write to it? */ - contentUri = SAFDocument.create(contentUri).uri; + document.requestUpdate(); } + } catch (FileNotFoundException e) { + if (!mode.contains("w")) { + throw e; + } + /* Maybe we want to create a new file and write to it? */ + contentUri = SAFDocument.create(contentUri).uri; } ParcelFileDescriptor pfd = mSingleton.getContentResolver().openFileDescriptor(contentUri, mode); diff --git a/src/core/android/SDL_android.c b/src/core/android/SDL_android.c index 177229d76f..de35c1b6a1 100644 --- a/src/core/android/SDL_android.c +++ b/src/core/android/SDL_android.c @@ -696,7 +696,7 @@ JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(nativeSetupJNI)(JNIEnv *env, jclass cl midShouldMinimizeOnFocusLoss = (*env)->GetStaticMethodID(env, mActivityClass, "shouldMinimizeOnFocusLoss", "()Z"); midShowTextInput = (*env)->GetStaticMethodID(env, mActivityClass, "showTextInput", "(IIIII)Z"); midSupportsRelativeMouse = (*env)->GetStaticMethodID(env, mActivityClass, "supportsRelativeMouse", "()Z"); - midOpenFileDescriptor = (*env)->GetStaticMethodID(env, mActivityClass, "openFileDescriptor", "(Ljava/lang/String;Ljava/lang/String;)I"); + midOpenFileDescriptor = (*env)->GetStaticMethodID(env, mActivityClass, "openFileDescriptor", "(Ljava/lang/String;Ljava/lang/String;Z)I"); midShowFileDialog = (*env)->GetStaticMethodID(env, mActivityClass, "showFileDialog", "([Ljava/lang/String;ZILjava/lang/String;I)Z"); midGetDocumentChildrenNames = (*env)->GetStaticMethodID(env, mActivityClass, "getSAFDocumentChildrenNames", "(Ljava/lang/String;)[Ljava/lang/String;"); midGetSAFDocument = (*env)->GetStaticMethodID(env, mActivityClass, "getSAFDocument", "(Ljava/lang/String;)Lorg/libsdl/app/SDLActivity$SAFDocument;"); @@ -2705,7 +2705,17 @@ bool Android_JNI_EnumerateContentDirectory(const char *uri, SDL_EnumerateDirecto return false; } - char *dirname = GetURIWithNormalizedPath(uri, true); + char *dirname = NULL; + + // If we are traversing a genuine tree URI and not an SDL generated one, the "dirname" + // parameter on the enumeration callback must provide the document URI, not the tree URI. + // Because of that, if we're using a tree URI, we need to fetch the document URI from Java. + if (!SDL_strchr(uri, '#')) { + dirname = GetDocumentURIFromTreeURI(env, uri); + } else { + dirname = GetURIWithNormalizedPath(uri, true); + } + if (!dirname) { LocalReferenceHolder_Cleanup(&refs); return false; @@ -2720,10 +2730,10 @@ bool Android_JNI_EnumerateContentDirectory(const char *uri, SDL_EnumerateDirecto } // Invoke JNI - jobjectArray jFileNamesArray = (*env)->CallStaticObjectMethod(env, mActivityClass, - midGetDocumentChildrenNames, juri); + jobjectArray jFileNamesArray = (*env)->CallStaticObjectMethod(env, mActivityClass, midGetDocumentChildrenNames, juri); if (Android_JNI_ExceptionOccurred(false)) { + SDL_free(dirname); LocalReferenceHolder_Cleanup(&refs); return false; } @@ -2731,18 +2741,6 @@ bool Android_JNI_EnumerateContentDirectory(const char *uri, SDL_EnumerateDirecto jsize length = (*env)->GetArrayLength(env, jFileNamesArray); bool success = true; - // If we are traversing a genuine tree URI and not an SDL generated one, the "dirname" - // parameter on the enumeration callback must provide the document URI, not the tree URI. - // Because of that, if we're using a tree URI, we need to fetch the document URI from Java. - if (!SDL_strchr(dirname, '#')) { - SDL_free(dirname); - dirname = GetDocumentURIFromTreeURI(env, uri); - if (!dirname) { - LocalReferenceHolder_Cleanup(&refs); - return false; - } - } - for (int i = 0; i < length; ++i) { jstring jFileName = (*env)->GetObjectArrayElement(env, jFileNamesArray, i); @@ -3637,6 +3635,7 @@ int Android_JNI_OpenFileDescriptor(const char *uri, const char *mode) { // Get fopen-style modes int moderead = 0, modewrite = 0, modeappend = 0, modeupdate = 0; + jboolean no_overwrite = JNI_FALSE; for (const char *cmode = mode; *cmode; cmode++) { switch (*cmode) { @@ -3652,6 +3651,9 @@ int Android_JNI_OpenFileDescriptor(const char *uri, const char *mode) case '+': modeupdate = 1; break; + case 'x': + no_overwrite = JNI_TRUE; + break; default: break; } @@ -3681,13 +3683,14 @@ int Android_JNI_OpenFileDescriptor(const char *uri, const char *mode) JNIEnv *env = Android_JNI_GetEnv(); struct LocalReferenceHolder refs = LocalReferenceHolder_Setup(SDL_FUNCTION); if (!LocalReferenceHolder_Init(&refs, env)) { + SDL_free(normalizedUri); LocalReferenceHolder_Cleanup(&refs); return -1; } jstring jstringUri = (*env)->NewStringUTF(env, normalizedUri); jstring jstringMode = (*env)->NewStringUTF(env, contentResolverMode); - jint fd = (*env)->CallStaticIntMethod(env, mActivityClass, midOpenFileDescriptor, jstringUri, jstringMode); + jint fd = (*env)->CallStaticIntMethod(env, mActivityClass, midOpenFileDescriptor, jstringUri, jstringMode, no_overwrite); SDL_free(normalizedUri);