diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ContentHandler.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ContentHandler.java index 53e950ac41..5233c9dc59 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ContentHandler.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ContentHandler.java @@ -14,15 +14,16 @@ import androidx.annotation.Keep; import org.dolphinemu.dolphinemu.DolphinApplication; import java.io.FileNotFoundException; +import java.util.List; public class ContentHandler { @Keep - public static int openFd(String uri, String mode) + public static int openFd(@NonNull String uri, @NonNull String mode) { try { - return getContentResolver().openFileDescriptor(Uri.parse(uri), mode).detachFd(); + return getContentResolver().openFileDescriptor(unmangle(uri), mode).detachFd(); } catch (SecurityException e) { @@ -38,11 +39,11 @@ public class ContentHandler } @Keep - public static boolean delete(String uri) + public static boolean delete(@NonNull String uri) { try { - return DocumentsContract.deleteDocument(getContentResolver(), Uri.parse(uri)); + return DocumentsContract.deleteDocument(getContentResolver(), unmangle(uri)); } catch (SecurityException e) { @@ -60,8 +61,9 @@ public class ContentHandler { try { + Uri documentUri = treeToDocument(unmangle(uri)); final String[] projection = new String[]{Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE}; - try (Cursor cursor = getContentResolver().query(Uri.parse(uri), projection, null, null, null)) + try (Cursor cursor = getContentResolver().query(documentUri, projection, null, null, null)) { return cursor != null && cursor.getCount() > 0; } @@ -70,6 +72,9 @@ public class ContentHandler { Log.error("Tried to check if " + uri + " exists without permission"); } + catch (FileNotFoundException ignored) + { + } return false; } @@ -78,38 +83,53 @@ public class ContentHandler * @return -1 if not found, -2 if directory, file size otherwise */ @Keep - public static long getSizeAndIsDirectory(String uri) + public static long getSizeAndIsDirectory(@NonNull String uri) { - final String[] projection = new String[]{Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE}; - try (Cursor cursor = getContentResolver().query(Uri.parse(uri), projection, null, null, null)) + try { - if (cursor != null && cursor.moveToFirst()) + Uri documentUri = treeToDocument(unmangle(uri)); + final String[] projection = new String[]{Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE}; + try (Cursor cursor = getContentResolver().query(documentUri, projection, null, null, null)) { - if (Document.MIME_TYPE_DIR.equals(cursor.getString(0))) - return -2; - else - return cursor.isNull(1) ? 0 : cursor.getLong(1); + if (cursor != null && cursor.moveToFirst()) + { + if (Document.MIME_TYPE_DIR.equals(cursor.getString(0))) + return -2; + else + return cursor.isNull(1) ? 0 : cursor.getLong(1); + } } } catch (SecurityException e) { Log.error("Tried to get metadata for " + uri + " without permission"); } + catch (FileNotFoundException ignored) + { + } return -1; } @Nullable @Keep - public static String getDisplayName(String uri) + public static String getDisplayName(@NonNull String uri) { - return getDisplayName(Uri.parse(uri)); + try + { + return getDisplayName(unmangle(uri)); + } + catch (FileNotFoundException e) + { + return null; + } } @Nullable public static String getDisplayName(@NonNull Uri uri) { final String[] projection = new String[]{Document.COLUMN_DISPLAY_NAME}; - try (Cursor cursor = getContentResolver().query(uri, projection, null, null, null)) + Uri documentUri = treeToDocument(uri); + try (Cursor cursor = getContentResolver().query(documentUri, projection, null, null, null)) { if (cursor != null && cursor.moveToFirst()) { @@ -124,6 +144,163 @@ public class ContentHandler return null; } + @NonNull @Keep + public static String[] getChildNames(@NonNull String uri) + { + try + { + Uri unmangledUri = unmangle(uri); + String documentId = DocumentsContract.getDocumentId(treeToDocument(unmangledUri)); + Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(unmangledUri, documentId); + + final String[] projection = new String[]{Document.COLUMN_DISPLAY_NAME}; + try (Cursor cursor = getContentResolver().query(childrenUri, projection, null, null, null)) + { + if (cursor != null) + { + String[] result = new String[cursor.getCount()]; + for (int i = 0; i < result.length; i++) + { + cursor.moveToNext(); + result[i] = cursor.getString(0); + } + return result; + } + } + } + catch (SecurityException e) + { + Log.error("Tried to get children of " + uri + " without permission"); + } + catch (FileNotFoundException ignored) + { + } + + return new String[0]; + } + + @NonNull + private static Uri getChild(@NonNull Uri parentUri, @NonNull String childName) + throws FileNotFoundException, SecurityException + { + String parentId = DocumentsContract.getDocumentId(treeToDocument(parentUri)); + Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parentUri, parentId); + + final String[] projection = new String[]{Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_DOCUMENT_ID}; + final String selection = Document.COLUMN_DISPLAY_NAME + "=?"; + final String[] selectionArgs = new String[]{childName}; + try (Cursor cursor = getContentResolver().query(childrenUri, projection, selection, + selectionArgs, null)) + { + if (cursor != null) + { + while (cursor.moveToNext()) + { + // FileProvider seemingly doesn't support selections, so we have to manually filter here + if (childName.equals(cursor.getString(0))) + { + return DocumentsContract.buildDocumentUriUsingTree(parentUri, cursor.getString(1)); + } + } + } + } + catch (SecurityException e) + { + Log.error("Tried to get child " + childName + " of " + parentUri + " without permission"); + } + + throw new FileNotFoundException(parentUri + "/" + childName); + } + + /** + * Since our C++ code was written under the assumption that it would be running under a filesystem + * which supports normal paths, it appends a slash followed by a file name when it wants to access + * a file in a directory. This function translates that into the type of URI that SAF requires. + * + * In order to detect whether a URI is mangled or not, we make the assumption that an + * unmangled URI contains at least one % and does not contain any slashes after the last %. + * This seems to hold for all common storage providers, but it is theoretically for a storage + * provider to use URIs without any % characters. + */ + @NonNull + private static Uri unmangle(@NonNull String uri) throws FileNotFoundException, SecurityException + { + int lastComponentEnd = getLastComponentEnd(uri); + int lastComponentStart = getLastComponentStart(uri, lastComponentEnd); + + if (lastComponentStart == 0) + { + return Uri.parse(uri.substring(0, lastComponentEnd)); + } + else + { + Uri parentUri = unmangle(uri.substring(0, lastComponentStart)); + String childName = uri.substring(lastComponentStart, lastComponentEnd); + return getChild(parentUri, childName); + } + } + + /** + * Returns the last character which is not a slash. + */ + private static int getLastComponentEnd(@NonNull String uri) + { + int i = uri.length(); + while (i > 0 && uri.charAt(i - 1) == '/') + i--; + return i; + } + + /** + * Scans backwards starting from lastComponentEnd and returns the index after the first slash + * it finds, but only if there is a % before that slash and there is no % after it. + */ + private static int getLastComponentStart(@NonNull String uri, int lastComponentEnd) + { + int i = lastComponentEnd; + while (i > 0 && uri.charAt(i - 1) != '/') + { + i--; + if (uri.charAt(i) == '%') + return 0; + } + + int j = i; + while (j > 0) + { + j--; + if (uri.charAt(j) == '%') + return i; + } + + return 0; + } + + @NonNull + private static Uri treeToDocument(@NonNull Uri uri) + { + if (isTreeUri(uri)) + { + String documentId = DocumentsContract.getTreeDocumentId(uri); + return DocumentsContract.buildDocumentUriUsingTree(uri, documentId); + } + else + { + return uri; + } + } + + /** + * This is like DocumentsContract.isTreeUri, except it doesn't return true for URIs like + * content://com.example/tree/12/document/24/. We want to treat those as documents, not trees. + */ + private static boolean isTreeUri(@NonNull Uri uri) + { + final List pathSegments = uri.getPathSegments(); + return pathSegments.size() == 2 && "tree".equals(pathSegments.get(0)); + } + private static ContentResolver getContentResolver() { return DolphinApplication.getAppContext().getContentResolver(); diff --git a/Source/Android/jni/AndroidCommon/AndroidCommon.cpp b/Source/Android/jni/AndroidCommon/AndroidCommon.cpp index 373a4699cd..b6ecd41d48 100644 --- a/Source/Android/jni/AndroidCommon/AndroidCommon.cpp +++ b/Source/Android/jni/AndroidCommon/AndroidCommon.cpp @@ -121,6 +121,15 @@ std::string GetAndroidContentDisplayName(const std::string& uri) return display_name ? GetJString(env, reinterpret_cast(display_name)) : ""; } +std::vector GetAndroidContentChildNames(const std::string& uri) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + jobject children = + env->CallStaticObjectMethod(IDCache::GetContentHandlerClass(), + IDCache::GetContentHandlerGetChildNames(), ToJString(env, uri)); + return JStringArrayToVector(env, reinterpret_cast(children)); +} + int GetNetworkIpAddress() { JNIEnv* env = IDCache::GetEnvForThread(); diff --git a/Source/Android/jni/AndroidCommon/AndroidCommon.h b/Source/Android/jni/AndroidCommon/AndroidCommon.h index cb14d493b9..a1a1643b40 100644 --- a/Source/Android/jni/AndroidCommon/AndroidCommon.h +++ b/Source/Android/jni/AndroidCommon/AndroidCommon.h @@ -6,6 +6,7 @@ #include #include +#include #include @@ -34,6 +35,9 @@ jlong GetAndroidContentSizeAndIsDirectory(const std::string& uri); // An empty string will be returned for files which do not exist. std::string GetAndroidContentDisplayName(const std::string& uri); +// Returns the display names of all children of a directory. +std::vector GetAndroidContentChildNames(const std::string& uri); + int GetNetworkIpAddress(); int GetNetworkPrefixLength(); int GetNetworkGateway(); diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index baf44633f7..c79007baae 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -46,6 +46,7 @@ static jmethodID s_content_handler_open_fd; static jmethodID s_content_handler_delete; static jmethodID s_content_handler_get_size_and_is_directory; static jmethodID s_content_handler_get_display_name; +static jmethodID s_content_handler_get_child_names; static jclass s_network_helper_class; static jmethodID s_network_helper_get_network_ip_address; @@ -222,6 +223,11 @@ jmethodID GetContentHandlerGetDisplayName() return s_content_handler_get_display_name; } +jmethodID GetContentHandlerGetChildNames() +{ + return s_content_handler_get_child_names; +} + jclass GetNetworkHelperClass() { return s_network_helper_class; @@ -323,6 +329,8 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) s_content_handler_class, "getSizeAndIsDirectory", "(Ljava/lang/String;)J"); s_content_handler_get_display_name = env->GetStaticMethodID( s_content_handler_class, "getDisplayName", "(Ljava/lang/String;)Ljava/lang/String;"); + s_content_handler_get_child_names = env->GetStaticMethodID( + s_content_handler_class, "getChildNames", "(Ljava/lang/String;)[Ljava/lang/String;"); const jclass network_helper_class = env->FindClass("org/dolphinemu/dolphinemu/utils/NetworkHelper"); diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index 621a522a6c..3f568e3f73 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -46,6 +46,7 @@ jmethodID GetContentHandlerOpenFd(); jmethodID GetContentHandlerDelete(); jmethodID GetContentHandlerGetSizeAndIsDirectory(); jmethodID GetContentHandlerGetDisplayName(); +jmethodID GetContentHandlerGetChildNames(); jclass GetNetworkHelperClass(); jmethodID GetNetworkHelperGetNetworkIpAddress(); diff --git a/Source/Core/Common/FileUtil.cpp b/Source/Core/Common/FileUtil.cpp index 450eeea0a5..dc26662a2a 100644 --- a/Source/Core/Common/FileUtil.cpp +++ b/Source/Core/Common/FileUtil.cpp @@ -497,14 +497,47 @@ FSTEntry ScanDirectoryTree(const std::string& directory, bool recursive) { const std::string virtual_name(TStrToUTF8(ffd.cFileName)); #else - DIR* dirp = opendir(directory.c_str()); - if (!dirp) - return parent_entry; + DIR* dirp = nullptr; + +#ifdef ANDROID + std::vector child_names; + if (IsPathAndroidContent(directory)) + { + child_names = GetAndroidContentChildNames(directory); + } + else +#endif + { + dirp = opendir(directory.c_str()); + if (!dirp) + return parent_entry; + } + +#ifdef ANDROID + auto it = child_names.cbegin(); +#endif // non Windows loop - while (dirent* result = readdir(dirp)) + while (true) { - const std::string virtual_name(result->d_name); + std::string virtual_name; + +#ifdef ANDROID + if (!dirp) + { + if (it == child_names.cend()) + break; + virtual_name = *it; + ++it; + } + else +#endif + { + dirent* result = readdir(dirp); + if (!result) + break; + virtual_name = result->d_name; + } #endif if (virtual_name == "." || virtual_name == "..") continue; @@ -535,7 +568,8 @@ FSTEntry ScanDirectoryTree(const std::string& directory, bool recursive) FindClose(hFind); #else } - closedir(dirp); + if (dirp) + closedir(dirp); #endif return parent_entry;