diff --git a/source/funkin/mobile/external/android/java/funkin/provider/DataFolderProvider.java b/source/funkin/mobile/external/android/java/funkin/provider/DataFolderProvider.java index 7d3d791d1..f5446c826 100644 --- a/source/funkin/mobile/external/android/java/funkin/provider/DataFolderProvider.java +++ b/source/funkin/mobile/external/android/java/funkin/provider/DataFolderProvider.java @@ -1,302 +1,270 @@ package funkin.provider; -import android.annotation.SuppressLint; + import android.content.res.AssetFileDescriptor; import android.database.Cursor; import android.database.MatrixCursor; import android.graphics.Point; import android.os.CancellationSignal; import android.os.ParcelFileDescriptor; -import android.provider.DocumentsProvider; -import android.provider.DocumentsContract.Root; import android.provider.DocumentsContract.Document; -import android.util.Log; +import android.provider.DocumentsContract.Root; import android.webkit.MimeTypeMap; - import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; -import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedList; -public class DataFolderProvider extends DocumentsProvider -{ - private static final String TAG = "DataFolderProvider"; - private static final String ROOT = "root"; +/** + * A document provider for the Storage Access Framework which exposes the files in the + * Android/data//files directory to other apps. + *

+ * Note that this replaces providing an activity matching the ACTION_GET_CONTENT intent: + *

+ * "A document provider and ACTION_GET_CONTENT should be considered mutually exclusive. If you + * support both of them simultaneously, your app will appear twice in the system picker UI, + * offering two different ways of accessing your stored data. This would be confusing for users." + * - http://developer.android.com/guide/topics/providers/document-provider.html#43 + * + * This is based on the Termux Document Provider!! We tried making our own but it was buggy :( + * https://github.com/termux/termux-app/blob/7bceab88e2272f961d1b94ef736f1a9e20173247/app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java + */ +public class DataFolderProvider extends android.provider.DocumentsProvider { - private static final String[] DEFAULT_ROOT_PROJECTION = new String[]{Root.COLUMN_ROOT_ID, Root.COLUMN_MIME_TYPES, Root.COLUMN_FLAGS, Root.COLUMN_ICON, Root.COLUMN_TITLE, Root.COLUMN_SUMMARY, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_AVAILABLE_BYTES}; + private static final String ALL_MIME_TYPES = "*/*"; - private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[]{Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE}; + private static File BASE_DIR; + private static String BASE_DIR_PATH; - private File rootDir; - @Override - public Cursor queryRoots(String[] projection) throws FileNotFoundException - { - final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION); + // The default columns to return information about a root if no specific + // columns are requested in a query. + private static final String[] DEFAULT_ROOT_PROJECTION = new String[]{ + Root.COLUMN_ROOT_ID, + Root.COLUMN_MIME_TYPES, + Root.COLUMN_FLAGS, + Root.COLUMN_ICON, + Root.COLUMN_TITLE, + Root.COLUMN_SUMMARY, + Root.COLUMN_DOCUMENT_ID, + Root.COLUMN_AVAILABLE_BYTES + }; - final MatrixCursor.RowBuilder row = result.newRow(); + // The default columns to return information about a document if no specific + // columns are requested in a query. + private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[]{ + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_FLAGS, + Document.COLUMN_SIZE + }; - row.add(Root.COLUMN_ROOT_ID, ROOT); + @Override + public Cursor queryRoots(String[] projection) { + final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION); + final String applicationName = "::APP_TITLE::"; - row.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_IS_CHILD); - - row.add(Root.COLUMN_SUMMARY, "Data Folder"); - - row.add(Root.COLUMN_DOCUMENT_ID, getDocumentID(rootDir)); - - ::if (APP_TITLE != "")::row.add(Root.COLUMN_TITLE, "::APP_TITLE::");::end:: - - ::if (APP_PACKAGE != "")::row.add(Root.COLUMN_ICON, ::APP_PACKAGE::.R.mipmap.ic_launcher);::end:: - - return result; - } - - @Override - public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException - { - final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); - - includeFile(result, getFileFromDocumentID(documentId)); - - return result; - } - - @Override - public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) throws FileNotFoundException - { - final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); - - for (File file : getFileFromDocumentID(parentDocumentId).listFiles()) - { - includeFile(result, file); + final MatrixCursor.RowBuilder row = result.newRow(); + row.add(Root.COLUMN_ROOT_ID, getDocIdForFile(BASE_DIR)); + row.add(Root.COLUMN_DOCUMENT_ID, getDocIdForFile(BASE_DIR)); + row.add(Root.COLUMN_SUMMARY, "Data Folder"); + row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH | Root.FLAG_SUPPORTS_IS_CHILD); + row.add(Root.COLUMN_TITLE, applicationName); + row.add(Root.COLUMN_MIME_TYPES, ALL_MIME_TYPES); + row.add(Root.COLUMN_AVAILABLE_BYTES, BASE_DIR.getFreeSpace()); + ::if (APP_PACKAGE != "")::row.add(Root.COLUMN_ICON, ::APP_PACKAGE::.R.mipmap.ic_launcher);::end:: + return result; } - return result; - } - - @Override - public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal) throws FileNotFoundException - { - return ParcelFileDescriptor.open(getFileFromDocumentID(documentId), ParcelFileDescriptor.parseMode(mode)); - } - - @SuppressLint({"SetWorldReadable", "SetWorldWritable"}) - @Override - public String createDocument(String parentDocumentId, String mimeType, String displayName) throws FileNotFoundException - { - File parentPath = getFileFromDocumentID(parentDocumentId); - - try - { - File file = new File(parentPath, displayName); - - if (mimeType.equals(Document.MIME_TYPE_DIR)) - assert file.mkdir(); - else - assert file.createNewFile(); - - assert file.setReadable(true, false); - assert file.setWritable(true, false); - - return getDocumentID(file); - } - catch (IOException e) - { - throw new FileNotFoundException("Cannot create file with id " + parentDocumentId + "/" + displayName); - } - } - - @Override - public void deleteDocument(String documentId) throws FileNotFoundException - { - File file = getFileFromDocumentID(documentId); - - Boolean result = false; - - try - { - if (file.isDirectory()) - { - result = deleteFolderRecursively(file); - } - else - { - result = file.delete(); - } - } - catch (Exception e) - { - Log.e(TAG, e.toString()); - - e.printStackTrace(); + @Override + public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException { + final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + includeFile(result, documentId, null); + return result; } - if (!result) - { - throw new FileNotFoundException("Failed to delete document with id " + documentId); - } - } - - public boolean deleteFolderRecursively(File folder) - { - boolean isSuccessful = true; - - final File[] files = folder.listFiles(); - - if (files == null || !folder.exists()) - return false; - - for (File item : files) - { - if (item.isDirectory()) - { - isSuccessful = isSuccessful && deleteFolderRecursively(item); - } - else - { - isSuccessful = isSuccessful && item.delete(); - } + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) throws FileNotFoundException { + final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + final File parent = getFileForDocId(parentDocumentId); + for (File file : parent.listFiles()) { + includeFile(result, null, file); + } + return result; } - isSuccessful = isSuccessful && folder.delete(); - - return isSuccessful; - } - - @Override - public String renameDocument(String documentId, String displayName) throws FileNotFoundException - { - File origin = getFileFromDocumentID(documentId); - File dest = new File(origin.getParentFile(), displayName); - - if (!origin.renameTo(dest)) - { - throw new FileNotFoundException("Failed to rename document with id " + documentId + " to " + displayName); + @Override + public ParcelFileDescriptor openDocument(final String documentId, String mode, CancellationSignal signal) throws FileNotFoundException { + final File file = getFileForDocId(documentId); + final int accessMode = ParcelFileDescriptor.parseMode(mode); + return ParcelFileDescriptor.open(file, accessMode); } - return getDocumentID(dest); - } - - @Override - public boolean isChildDocument(String parentDocumentId, String documentId) - { - return documentId.startsWith(parentDocumentId); - } - - @Override - public AssetFileDescriptor openDocumentThumbnail(String documentId, Point sizeHint, CancellationSignal signal) throws FileNotFoundException - { - final File file = getFileFromDocumentID(documentId); - final ParcelFileDescriptor parcelFileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); - return new AssetFileDescriptor(parcelFileDescriptor, 0, AssetFileDescriptor.UNKNOWN_LENGTH); - } - - private String getDocumentID(File file) - { - Path filePath = file.toPath().normalize(); - Path rootPath = rootDir.toPath(); - - if (!filePath.startsWith(rootPath)) - return null; - - return ROOT + ":" + rootPath.relativize(filePath).toString(); - } - - private File getFileFromDocumentID(String id) - { - final String[] separatedPath = id.split(":", 2); - - if (separatedPath.length < 2) - return null; - - if (!separatedPath[0].equals(ROOT)) - return null; - - final String filePath = separatedPath[1]; - - if (filePath.isEmpty()) - return rootDir; - - final Path rootPath = rootDir.toPath().normalize().toAbsolutePath(); - final Path desiredPath = rootPath.resolve(filePath).normalize().toAbsolutePath(); - - if (!desiredPath.startsWith(rootPath)) - { - return null; + @Override + public AssetFileDescriptor openDocumentThumbnail(String documentId, Point sizeHint, CancellationSignal signal) throws FileNotFoundException { + final File file = getFileForDocId(documentId); + final ParcelFileDescriptor pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + return new AssetFileDescriptor(pfd, 0, file.length()); } - return desiredPath.toFile(); - } - - private void includeFile(MatrixCursor result, File file) - { - if (file == null) - file = rootDir; - - int flags = 0; - final String mimeType = getTypeForFile(file); - - if (file.canWrite()) - { - flags |= Document.FLAG_SUPPORTS_WRITE | - Document.FLAG_SUPPORTS_DELETE | - Document.FLAG_SUPPORTS_RENAME; - - if (file.isDirectory()) - flags |= Document.FLAG_DIR_SUPPORTS_CREATE; + @Override + public boolean onCreate() { + BASE_DIR = getContext().getExternalFilesDir(null); + BASE_DIR_PATH = BASE_DIR.toPath().toString(); + return true; } - if (mimeType.startsWith("image/")) - { - flags |= Document.FLAG_SUPPORTS_THUMBNAIL; + @Override + public String createDocument(String parentDocumentId, String mimeType, String displayName) throws FileNotFoundException { + File newFile = new File(parentDocumentId, displayName); + int noConflictId = 2; + while (newFile.exists()) { + newFile = new File(parentDocumentId, displayName + " (" + noConflictId++ + ")"); + } + try { + boolean succeeded; + if (Document.MIME_TYPE_DIR.equals(mimeType)) { + succeeded = newFile.mkdir(); + } else { + succeeded = newFile.createNewFile(); + } + if (!succeeded) { + throw new FileNotFoundException("Failed to create document with id " + newFile.getPath()); + } + } catch (IOException e) { + throw new FileNotFoundException("Failed to create document with id " + newFile.getPath()); + } + return newFile.getPath(); } - final MatrixCursor.RowBuilder row = result.newRow(); - row.add(Document.COLUMN_DOCUMENT_ID, getDocumentID(file)); - row.add(Document.COLUMN_DISPLAY_NAME, file.getName()); - row.add(Document.COLUMN_SIZE, file.length()); - row.add(Document.COLUMN_MIME_TYPE, mimeType); - row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified()); - row.add(Document.COLUMN_FLAGS, flags); - } - - private static String getTypeForFile(File file) - { - if (file.isDirectory()) - { - return Document.MIME_TYPE_DIR; + @Override + public void deleteDocument(String documentId) throws FileNotFoundException { + File file = getFileForDocId(documentId); + if (!file.delete()) { + throw new FileNotFoundException("Failed to delete document with id " + documentId); + } } - else - { - return getTypeForName(file.getName()); + + @Override + public String getDocumentType(String documentId) throws FileNotFoundException { + File file = getFileForDocId(documentId); + return getMimeType(file); } - } - private static String getTypeForName(String name) - { - final int lastDot = name.lastIndexOf('.'); - if (lastDot >= 0) - { - final String extension = name.substring(lastDot + 1); + @Override + public Cursor querySearchDocuments(String rootId, String query, String[] projection) throws FileNotFoundException { + final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + final File parent = getFileForDocId(rootId); - if (extension.equals("hxc") || extension.equals("hx")) - return "application/*"; + // This example implementation searches file names for the query and doesn't rank search + // results, so we can stop as soon as we find a sufficient number of matches. Other + // implementations might rank results and use other data about files, rather than the file + // name, to produce a match. + final LinkedList pending = new LinkedList<>(); + pending.add(parent); - final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); - if (mime != null) - { - return mime; - } + final int MAX_SEARCH_RESULTS = 50; + while (!pending.isEmpty() && result.getCount() < MAX_SEARCH_RESULTS) { + final File file = pending.removeFirst(); + // Avoid directories outside the $HOME directory linked with symlinks (to avoid e.g. search + // through the whole SD card). + boolean isInsideHome; + try { + isInsideHome = file.getCanonicalPath().startsWith(BASE_DIR_PATH); + } catch (IOException e) { + isInsideHome = true; + } + if (isInsideHome) { + if (file.isDirectory()) { + Collections.addAll(pending, file.listFiles()); + } else { + if (file.getName().toLowerCase().contains(query)) { + includeFile(result, null, file); + } + } + } + } + + return result; } - return "application/octet-stream"; - } - @Override - public boolean onCreate() - { - rootDir = getContext().getExternalFilesDir(null); + @Override + public boolean isChildDocument(String parentDocumentId, String documentId) { + return documentId.startsWith(parentDocumentId); + } - return true; - } + /** + * Get the document id given a file. This document id must be consistent across time as other + * applications may save the ID and use it to reference documents later. + *

+ * The reverse of @{link #getFileForDocId}. + */ + private static String getDocIdForFile(File file) { + return file.getAbsolutePath(); + } + + /** + * Get the file given a document id (the reverse of {@link #getDocIdForFile(File)}). + */ + private static File getFileForDocId(String docId) throws FileNotFoundException { + final File f = new File(docId); + if (!f.exists()) throw new FileNotFoundException(f.getAbsolutePath() + " not found"); + return f; + } + + private static String getMimeType(File file) { + if (file.isDirectory()) { + return Document.MIME_TYPE_DIR; + } else { + final String name = file.getName(); + final int lastDot = name.lastIndexOf('.'); + if (lastDot >= 0) { + final String extension = name.substring(lastDot + 1).toLowerCase(); + final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + if (mime != null) return mime; + } + return "application/octet-stream"; + } + } + + /** + * Add a representation of a file to a cursor. + * + * @param result the cursor to modify + * @param docId the document ID representing the desired file (may be null if given file) + * @param file the File object representing the desired file (may be null if given docID) + */ + private void includeFile(MatrixCursor result, String docId, File file) + throws FileNotFoundException { + if (docId == null) { + docId = getDocIdForFile(file); + } else { + file = getFileForDocId(docId); + } + + int flags = 0; + if (file.isDirectory()) { + if (file.canWrite()) flags |= Document.FLAG_DIR_SUPPORTS_CREATE; + } else if (file.canWrite()) { + flags |= Document.FLAG_SUPPORTS_WRITE; + } + if (file.getParentFile().canWrite()) flags |= Document.FLAG_SUPPORTS_DELETE; + + final String displayName = file.getName(); + final String mimeType = getMimeType(file); + if (mimeType.startsWith("image/")) flags |= Document.FLAG_SUPPORTS_THUMBNAIL; + + final MatrixCursor.RowBuilder row = result.newRow(); + row.add(Document.COLUMN_DOCUMENT_ID, docId); + row.add(Document.COLUMN_DISPLAY_NAME, displayName); + row.add(Document.COLUMN_SIZE, file.length()); + row.add(Document.COLUMN_MIME_TYPE, mimeType); + row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified()); + row.add(Document.COLUMN_FLAGS, flags); + ::if (APP_PACKAGE != "")::row.add(Root.COLUMN_ICON, ::APP_PACKAGE::.R.mipmap.ic_launcher);::end:: + } }