android: Use case insensitivity in DocumentsTree (#7115)

* android: Unify DocumentNode's `key` and `name`

They're effectively the same data, just obtained in different ways.

* android: Remove getFilenameWithExtensions method

After the previous commit, there's only one remaining use of
getFilenameWithExtensions. Let's get rid of that one in favor of
DocumentFile.getName so we no longer need to do manual URI parsing.

* android: Use case insensitivity in DocumentsTree

External storage on Android is case insensitive. This is still the case
when accessing it through SAF. (Of course, SAF makes no guarantees about
whether the storage location picked by the user is backed by external
storage or whether it's case insensitive, but I'm just going to ignore
that for now because I am *so tired of SAF*)

Because the underlying file system is case insensitive, Citra's caching
layer that had to be implemented because SAF's performance is atrocious
also needs to be case insensitive. Otherwise, we get a problem in the
following scenario:

1. Citra wants to check if a particular folder exists in sdmc, and if
   not, create it.
2. The folder does exist, but it has a different capitalization than
   Citra expects, due to a mismatch between Citra's code and (typically)
   files dumped from a real 3DS using ThreeSD.
3. Citra tries to open the folder, but DocumentsTree fails to find it,
   because the case doesn't match.
4. Citra then tries to create the folder, but creating the folder fails,
   because the underlying filesystem considers the folder to exist.
5. The game fails to start.

(Sorry, did I say creating the folder fails? Actually, a new folder does
get created, with " (1)" appended to the end of the name. SAF makes no
guarantees whatsoever about what happens in this situation – it's all
determined by the storage provider!)

This commit makes the caching layer case insensitive so that the
described scenario will work better.
This commit is contained in:
JosJuice 2023-11-07 20:46:25 +01:00 committed by GitHub
parent 86566f1c14
commit 3f4b57635e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 46 additions and 27 deletions

View File

@ -4,6 +4,7 @@ import android.content.Context;
import android.net.Uri; import android.net.Uri;
import android.provider.DocumentsContract; import android.provider.DocumentsContract;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.documentfile.provider.DocumentFile; import androidx.documentfile.provider.DocumentFile;
@ -14,6 +15,7 @@ import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.URLDecoder; import java.net.URLDecoder;
import java.util.HashMap; import java.util.HashMap;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.StringTokenizer; import java.util.StringTokenizer;
@ -48,12 +50,12 @@ public class DocumentsTree {
Uri mUri = node.uri; Uri mUri = node.uri;
try { try {
String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD); String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD);
if (node.children.get(filename) != null) return true; if (node.findChild(filename) != null) return true;
DocumentFile createdFile = FileUtil.createFile(context, mUri.toString(), name); DocumentFile createdFile = FileUtil.createFile(context, mUri.toString(), name);
if (createdFile == null) return false; if (createdFile == null) return false;
DocumentsNode document = new DocumentsNode(createdFile, false); DocumentsNode document = new DocumentsNode(createdFile, false);
document.parent = node; document.parent = node;
node.children.put(document.key, document); node.addChild(document);
return true; return true;
} catch (Exception e) { } catch (Exception e) {
Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage()); Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage());
@ -69,12 +71,12 @@ public class DocumentsTree {
Uri mUri = node.uri; Uri mUri = node.uri;
try { try {
String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD); String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD);
if (node.children.get(filename) != null) return true; if (node.findChild(filename) != null) return true;
DocumentFile createdDirectory = FileUtil.createDir(context, mUri.toString(), name); DocumentFile createdDirectory = FileUtil.createDir(context, mUri.toString(), name);
if (createdDirectory == null) return false; if (createdDirectory == null) return false;
DocumentsNode document = new DocumentsNode(createdDirectory, true); DocumentsNode document = new DocumentsNode(createdDirectory, true);
document.parent = node; document.parent = node;
node.children.put(document.key, document); node.addChild(document);
return true; return true;
} catch (Exception e) { } catch (Exception e) {
Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage()); Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage());
@ -105,7 +107,7 @@ public class DocumentsTree {
} }
// If this directory have not been iterate struct it. // If this directory have not been iterate struct it.
if (!node.loaded) structTree(node); if (!node.loaded) structTree(node);
return node.children.keySet().toArray(new String[0]); return node.getChildNames();
} }
public long getFileSize(String filepath) { public long getFileSize(String filepath) {
@ -153,7 +155,7 @@ public class DocumentsTree {
input.close(); input.close();
output.flush(); output.flush();
output.close(); output.close();
destinationNode.children.put(document.key, document); destinationNode.addChild(document);
return true; return true;
} catch (Exception e) { } catch (Exception e) {
Log.error("[DocumentsTree]: Cannot copy file, error: " + e.getMessage()); Log.error("[DocumentsTree]: Cannot copy file, error: " + e.getMessage());
@ -185,7 +187,7 @@ public class DocumentsTree {
return false; return false;
} }
if (node.parent != null) { if (node.parent != null) {
node.parent.children.remove(node.key); node.parent.removeChild(node);
} }
return true; return true;
} catch (Exception e) { } catch (Exception e) {
@ -214,7 +216,7 @@ public class DocumentsTree {
if (parent.isDirectory && !parent.loaded) { if (parent.isDirectory && !parent.loaded) {
structTree(parent); structTree(parent);
} }
return parent.children.get(filename); return parent.findChild(filename);
} }
/** /**
@ -227,15 +229,19 @@ public class DocumentsTree {
for (CheapDocument document : documents) { for (CheapDocument document : documents) {
DocumentsNode node = new DocumentsNode(document); DocumentsNode node = new DocumentsNode(document);
node.parent = parent; node.parent = parent;
parent.children.put(node.key, node); parent.addChild(node);
} }
parent.loaded = true; parent.loaded = true;
} }
@NonNull
private static String toLowerCase(@NonNull String str) {
return str.toLowerCase(Locale.ROOT);
}
private static class DocumentsNode { private static class DocumentsNode {
private DocumentsNode parent; private DocumentsNode parent;
private final Map<String, DocumentsNode> children = new HashMap<>(); private final Map<String, DocumentsNode> children = new HashMap<>();
private String key;
private String name; private String name;
private Uri uri; private Uri uri;
private boolean loaded = false; private boolean loaded = false;
@ -246,7 +252,6 @@ public class DocumentsTree {
private DocumentsNode(CheapDocument document) { private DocumentsNode(CheapDocument document) {
name = document.getFilename(); name = document.getFilename();
uri = document.getUri(); uri = document.getUri();
key = FileUtil.getFilenameWithExtensions(uri);
isDirectory = document.isDirectory(); isDirectory = document.isDirectory();
loaded = !isDirectory; loaded = !isDirectory;
} }
@ -254,18 +259,42 @@ public class DocumentsTree {
private DocumentsNode(DocumentFile document, boolean isCreateDir) { private DocumentsNode(DocumentFile document, boolean isCreateDir) {
name = document.getName(); name = document.getName();
uri = document.getUri(); uri = document.getUri();
key = FileUtil.getFilenameWithExtensions(uri);
isDirectory = isCreateDir; isDirectory = isCreateDir;
loaded = true; loaded = true;
} }
private void rename(String key) { private void rename(String name) {
if (parent == null) { if (parent == null) {
return; return;
} }
parent.children.remove(this.key); parent.removeChild(this);
this.name = key; this.name = name;
parent.children.put(key, this); parent.addChild(this);
}
private void addChild(DocumentsNode node) {
children.put(toLowerCase(node.name), node);
}
private void removeChild(DocumentsNode node) {
children.remove(toLowerCase(node.name));
}
@Nullable
private DocumentsNode findChild(String filename) {
return children.get(toLowerCase(filename));
}
@NonNull
private String[] getChildNames() {
String[] names = new String[children.size()];
int i = 0;
for (DocumentsNode child : children.values()) {
names[i++] = child.name;
}
return names;
} }
} }
} }

View File

@ -338,13 +338,12 @@ public class FileUtil {
for (Pair<CheapDocument, DocumentFile> file : files) { for (Pair<CheapDocument, DocumentFile> file : files) {
DocumentFile to = file.second; DocumentFile to = file.second;
Uri toUri = to.getUri(); Uri toUri = to.getUri();
String filename = getFilenameWithExtensions(toUri);
String toPath = toUri.getPath(); String toPath = toUri.getPath();
DocumentFile toParent = to.getParentFile(); DocumentFile toParent = to.getParentFile();
if (toParent == null) if (toParent == null)
continue; continue;
FileUtil.copyFile(context, file.first.getUri().toString(), FileUtil.copyFile(context, file.first.getUri().toString(),
toParent.getUri().toString(), filename); toParent.getUri().toString(), to.getName());
progress++; progress++;
if (listener != null) { if (listener != null) {
listener.onCopyProgress(toPath, progress, total); listener.onCopyProgress(toPath, progress, total);
@ -424,15 +423,6 @@ public class FileUtil {
return false; return false;
} }
public static String getFilenameWithExtensions(Uri uri) {
String path = uri.getPath();
final int slashIndex = path.lastIndexOf('/');
path = path.substring(slashIndex + 1);
// On Android versions below 10, it is possible to select the storage root, which might result in filenames with a colon.
final int colonIndex = path.indexOf(':');
return path.substring(colonIndex + 1);
}
public static double getFreeSpace(Context context, Uri uri) { public static double getFreeSpace(Context context, Uri uri) {
try { try {
Uri docTreeUri = DocumentsContract.buildDocumentUriUsingTree( Uri docTreeUri = DocumentsContract.buildDocumentUriUsingTree(