This commit is contained in:
Simonx22 2026-05-07 18:00:12 -04:00 committed by GitHub
commit 27950ce0e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 358 additions and 425 deletions

View File

@ -1,425 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.utils;
import android.content.ContentResolver;
import android.database.Cursor;
import android.net.Uri;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Document;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.dolphinemu.dolphinemu.DolphinApplication;
import java.io.FileNotFoundException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
/*
We use a lot of "catch (Exception e)" in this class. This is for two reasons:
1. We don't want any exceptions to escape to native code, as this leads to nasty crashes
that often don't have stack traces that make sense.
2. The sheer number of different exceptions, both documented and undocumented. These include:
- FileNotFoundException when a file doesn't exist
- FileNotFoundException when using an invalid open mode (according to the documentation)
- IllegalArgumentException when using an invalid open mode (in practice with FileProvider)
- IllegalArgumentException when providing a tree where a document was expected and vice versa
- SecurityException when trying to access something the user hasn't granted us permission to
- UnsupportedOperationException when a URI specifies a storage provider that doesn't exist
*/
public class ContentHandler
{
public static boolean isContentUri(@NonNull String pathOrUri)
{
return pathOrUri.startsWith("content://");
}
@Keep
public static int openFd(@NonNull String uri, @NonNull String mode)
{
try
{
return getContentResolver().openFileDescriptor(unmangle(uri), mode).detachFd();
}
catch (SecurityException e)
{
Log.error("Tried to open " + uri + " without permission");
}
catch (Exception ignored)
{
}
return -1;
}
@Keep
public static boolean delete(@NonNull String uri)
{
try
{
return DocumentsContract.deleteDocument(getContentResolver(), unmangle(uri));
}
catch (FileNotFoundException e)
{
// Return true because we care about the file not being there, not the actual delete.
return true;
}
catch (SecurityException e)
{
Log.error("Tried to delete " + uri + " without permission");
}
catch (Exception ignored)
{
}
return false;
}
public static boolean exists(@NonNull String uri)
{
try
{
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))
{
return cursor != null && cursor.getCount() > 0;
}
}
catch (SecurityException e)
{
Log.error("Tried to check if " + uri + " exists without permission");
}
catch (Exception ignored)
{
}
return false;
}
/**
* @return -1 if not found, -2 if directory, file size otherwise
*/
@Keep
public static long getSizeAndIsDirectory(@NonNull String uri)
{
try
{
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 (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 (Exception ignored)
{
}
return -1;
}
@Nullable @Keep
public static String getDisplayName(@NonNull String uri)
{
try
{
return getDisplayName(unmangle(uri));
}
catch (Exception ignored)
{
}
return null;
}
@Nullable
public static String getDisplayName(@NonNull Uri uri)
{
final String[] projection = new String[]{Document.COLUMN_DISPLAY_NAME};
Uri documentUri = treeToDocument(uri);
try (Cursor cursor = getContentResolver().query(documentUri, projection, null, null, null))
{
if (cursor != null && cursor.moveToFirst())
{
return cursor.getString(0);
}
}
catch (SecurityException e)
{
Log.error("Tried to get display name of " + uri + " without permission");
}
catch (Exception ignored)
{
}
return null;
}
@NonNull @Keep
public static String[] getChildNames(@NonNull String uri, boolean recursive)
{
try
{
return getChildNames(unmangle(uri), recursive);
}
catch (Exception ignored)
{
}
return new String[0];
}
@NonNull
public static String[] getChildNames(@NonNull Uri uri, boolean recursive)
{
ArrayList<String> result = new ArrayList<>();
ForEachChildCallback callback = new ForEachChildCallback()
{
@Override
public void run(String displayName, String documentId, boolean isDirectory)
{
if (recursive && isDirectory)
{
forEachChild(uri, documentId, this);
}
else
{
result.add(displayName);
}
}
};
forEachChild(uri, DocumentsContract.getDocumentId(treeToDocument(uri)), callback);
return result.toArray(new String[0]);
}
@NonNull @Keep
public static String[] doFileSearch(@NonNull String directory, @NonNull String[] extensions,
boolean recursive)
{
ArrayList<String> result = new ArrayList<>();
try
{
Uri uri = unmangle(directory);
String documentId = DocumentsContract.getDocumentId(treeToDocument(uri));
boolean acceptAll = extensions.length == 0;
Predicate<String> extensionCheck = (displayName) ->
{
String extension = FileBrowserHelper.getExtension(displayName, true);
return extension != null && Arrays.stream(extensions).anyMatch(extension::equalsIgnoreCase);
};
doFileSearch(uri, directory, documentId, recursive, result, acceptAll, extensionCheck);
}
catch (Exception ignored)
{
}
return result.toArray(new String[0]);
}
private static void doFileSearch(@NonNull Uri baseUri, @NonNull String path,
@NonNull String documentId, boolean recursive, @NonNull List<String> resultOut,
boolean acceptAll, @NonNull Predicate<String> extensionCheck)
{
forEachChild(baseUri, documentId, (displayName, childDocumentId, isDirectory) ->
{
String childPath = path + '/' + displayName;
if (acceptAll || (!isDirectory && extensionCheck.test(displayName)))
{
resultOut.add(childPath);
}
if (recursive && isDirectory)
{
doFileSearch(baseUri, childPath, childDocumentId, recursive, resultOut, acceptAll,
extensionCheck);
}
});
}
private interface ForEachChildCallback
{
void run(String displayName, String documentId, boolean isDirectory);
}
private static void forEachChild(@NonNull Uri uri, @NonNull String documentId,
@NonNull ForEachChildCallback callback)
{
try
{
Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, documentId);
final String[] projection = new String[]{Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE, Document.COLUMN_DOCUMENT_ID};
try (Cursor cursor = getContentResolver().query(childrenUri, projection, null, null, null))
{
if (cursor != null)
{
while (cursor.moveToNext())
{
callback.run(cursor.getString(0), cursor.getString(2),
Document.MIME_TYPE_DIR.equals(cursor.getString(1)));
}
}
}
}
catch (SecurityException e)
{
Log.error("Tried to get children of " + uri + " without permission");
}
catch (Exception ignored)
{
}
}
@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");
}
catch (Exception ignored)
{
}
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
public 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<String> pathSegments = uri.getPathSegments();
return pathSegments.size() == 2 && "tree".equals(pathSegments.get(0));
}
private static ContentResolver getContentResolver()
{
return DolphinApplication.getAppContext().getContentResolver();
}
}

View File

@ -0,0 +1,358 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.utils
import android.content.ContentResolver
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document
import androidx.annotation.Keep
import androidx.core.net.toUri
import org.dolphinemu.dolphinemu.DolphinApplication
import java.io.FileNotFoundException
/*
We use a lot of "catch (Exception e)" in this class. This is for two reasons:
1. We don't want any exceptions to escape to native code, as this leads to nasty crashes
that often don't have stack traces that make sense.
2. The sheer number of different exceptions, both documented and undocumented. These include:
- FileNotFoundException when a file doesn't exist
- FileNotFoundException when using an invalid open mode (according to the documentation)
- IllegalArgumentException when using an invalid open mode (in practice with FileProvider)
- IllegalArgumentException when providing a tree where a document was expected and vice versa
- SecurityException when trying to access something the user hasn't granted us permission to
- UnsupportedOperationException when a URI specifies a storage provider that doesn't exist
*/
object ContentHandler {
@JvmStatic
fun isContentUri(pathOrUri: String): Boolean {
return pathOrUri.startsWith("content://")
}
@Keep
@JvmStatic
fun openFd(uri: String, mode: String): Int {
try {
return getContentResolver().openFileDescriptor(unmangle(uri), mode)!!.detachFd()
} catch (_: SecurityException) {
Log.error("Tried to open $uri without permission")
} catch (_: Exception) {
}
return -1
}
@Keep
@JvmStatic
fun delete(uri: String): Boolean {
try {
return DocumentsContract.deleteDocument(getContentResolver(), unmangle(uri))
} catch (_: FileNotFoundException) {
// Return true because we care about the file not being there, not the actual delete.
return true
} catch (_: SecurityException) {
Log.error("Tried to delete $uri without permission")
} catch (_: Exception) {
}
return false
}
@JvmStatic
fun exists(uri: String): Boolean {
try {
val documentUri = treeToDocument(unmangle(uri))
val projection = arrayOf(Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE)
getContentResolver().query(documentUri, projection, null, null, null)?.use { cursor ->
return cursor.count > 0
}
} catch (_: SecurityException) {
Log.error("Tried to check if $uri exists without permission")
} catch (_: Exception) {
}
return false
}
/**
* @return -1 if not found, -2 if directory, file size otherwise
*/
@Keep
@JvmStatic
fun getSizeAndIsDirectory(uri: String): Long {
try {
val documentUri = treeToDocument(unmangle(uri))
val projection = arrayOf(Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE)
getContentResolver().query(documentUri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
return if (Document.MIME_TYPE_DIR == cursor.getString(0)) {
-2
} else {
if (cursor.isNull(1)) 0 else cursor.getLong(1)
}
}
}
} catch (_: SecurityException) {
Log.error("Tried to get metadata for $uri without permission")
} catch (_: Exception) {
}
return -1
}
@Keep
@JvmStatic
fun getDisplayName(uri: String): String? {
try {
return getDisplayName(unmangle(uri))
} catch (_: Exception) {
}
return null
}
@JvmStatic
fun getDisplayName(uri: Uri): String? {
val projection = arrayOf(Document.COLUMN_DISPLAY_NAME)
val documentUri = treeToDocument(uri)
try {
getContentResolver().query(documentUri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
return cursor.getString(0)
}
}
} catch (_: SecurityException) {
Log.error("Tried to get display name of $uri without permission")
} catch (_: Exception) {
}
return null
}
@Keep
@JvmStatic
fun getChildNames(uri: String, recursive: Boolean): Array<String> {
try {
return getChildNames(unmangle(uri), recursive)
} catch (_: Exception) {
}
return emptyArray()
}
@JvmStatic
fun getChildNames(uri: Uri, recursive: Boolean): Array<String> {
val result = ArrayList<String>()
if (recursive) {
fun addChildren(documentId: String) {
forEachChild(uri, documentId) { displayName, childDocumentId, isDirectory ->
if (isDirectory) {
addChildren(childDocumentId)
} else {
result.add(displayName)
}
}
}
addChildren(DocumentsContract.getDocumentId(treeToDocument(uri)))
} else {
forEachChild(
uri, DocumentsContract.getDocumentId(treeToDocument(uri))
) { displayName, _, isDirectory ->
if (!isDirectory) {
result.add(displayName)
}
}
}
return result.toTypedArray()
}
@Keep
@JvmStatic
fun doFileSearch(
directory: String, extensions: Array<String>, recursive: Boolean
): Array<String> {
val result = ArrayList<String>()
try {
val uri = unmangle(directory)
val documentId = DocumentsContract.getDocumentId(treeToDocument(uri))
val acceptAll = extensions.isEmpty()
val extensionCheck: (String) -> Boolean = { displayName ->
val extension = FileBrowserHelper.getExtension(displayName, true)
extension != null && extensions.any { it.equals(extension, ignoreCase = true) }
}
if (recursive) {
doRecursiveFileSearch(uri, directory, documentId, result, acceptAll, extensionCheck)
} else {
forEachChild(uri, documentId) { displayName, _, isDirectory ->
val childPath = "$directory/$displayName"
if (acceptAll || (!isDirectory && extensionCheck(displayName))) {
result.add(childPath)
}
}
}
} catch (_: Exception) {
}
return result.toTypedArray()
}
private fun doRecursiveFileSearch(
baseUri: Uri,
path: String,
documentId: String,
resultOut: MutableList<String>,
acceptAll: Boolean,
extensionCheck: (String) -> Boolean
) {
forEachChild(baseUri, documentId) { displayName, childDocumentId, isDirectory ->
val childPath = "$path/$displayName"
if (acceptAll || (!isDirectory && extensionCheck(displayName))) {
resultOut.add(childPath)
}
if (isDirectory) {
doRecursiveFileSearch(
baseUri, childPath, childDocumentId, resultOut, acceptAll, extensionCheck
)
}
}
}
private fun forEachChild(
uri: Uri, documentId: String, callback: (String, String, Boolean) -> Unit
) {
try {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, documentId)
val projection = arrayOf(
Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE, Document.COLUMN_DOCUMENT_ID
)
getContentResolver().query(childrenUri, projection, null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
callback(
cursor.getString(0),
cursor.getString(2),
Document.MIME_TYPE_DIR == cursor.getString(1)
)
}
}
} catch (_: SecurityException) {
Log.error("Tried to get children of $uri without permission")
} catch (_: Exception) {
}
}
@Throws(FileNotFoundException::class, SecurityException::class)
private fun getChild(parentUri: Uri, childName: String): Uri {
val parentId = DocumentsContract.getDocumentId(treeToDocument(parentUri))
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parentUri, parentId)
val projection = arrayOf(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_DOCUMENT_ID)
val selection = Document.COLUMN_DISPLAY_NAME + "=?"
val selectionArgs = arrayOf(childName)
try {
getContentResolver().query(childrenUri, projection, selection, selectionArgs, null)
?.use { cursor ->
while (cursor.moveToNext()) {
// FileProvider seemingly doesn't support selections, so we have to manually filter here
if (childName == cursor.getString(0)) {
return DocumentsContract.buildDocumentUriUsingTree(
parentUri, cursor.getString(1)
)
}
}
}
} catch (_: SecurityException) {
Log.error("Tried to get child $childName of $parentUri without permission")
} catch (_: Exception) {
}
throw 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.
*/
@Throws(FileNotFoundException::class, SecurityException::class)
@JvmStatic
fun unmangle(uri: String): Uri {
val lastComponentEnd = getLastComponentEnd(uri)
val lastComponentStart = getLastComponentStart(uri, lastComponentEnd)
return if (lastComponentStart == 0) {
uri.substring(0, lastComponentEnd).toUri()
} else {
val parentUri = unmangle(uri.substring(0, lastComponentStart))
val childName = uri.substring(lastComponentStart, lastComponentEnd)
getChild(parentUri, childName)
}
}
/**
* Returns the last character which is not a slash.
*/
private fun getLastComponentEnd(uri: String): Int {
var i = uri.length
while (i > 0 && uri[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 fun getLastComponentStart(uri: String, lastComponentEnd: Int): Int {
var i = lastComponentEnd
while (i > 0 && uri[i - 1] != '/') {
i--
if (uri[i] == '%') {
return 0
}
}
var j = i
while (j > 0) {
j--
if (uri[j] == '%') {
return i
}
}
return 0
}
private fun treeToDocument(uri: Uri): Uri {
return if (isTreeUri(uri)) {
val documentId = DocumentsContract.getTreeDocumentId(uri)
DocumentsContract.buildDocumentUriUsingTree(uri, documentId)
} else {
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 fun isTreeUri(uri: Uri): Boolean {
val pathSegments = uri.pathSegments
return pathSegments.size == 2 && pathSegments[0] == "tree"
}
private fun getContentResolver(): ContentResolver {
return DolphinApplication.getAppContext().contentResolver
}
}