From 254ab774398d72c05c4b53ac40d98c3b659b3eb1 Mon Sep 17 00:00:00 2001 From: Dark98 Date: Fri, 30 Jan 2026 09:59:13 +0000 Subject: [PATCH] Android SAF Impl --- app/src/main/AndroidManifest.xml | 13 +- .../providers/AppDocumentsProvider.java | 314 ++++++++++++++++++ .../com/dark98/santoku/utils/IOUtils.java | 36 +- 3 files changed, 354 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/dark98/santoku/providers/AppDocumentsProvider.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fa5fa2e..95b3449 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,6 +64,17 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/provider_paths" /> + + + + + + - \ No newline at end of file + diff --git a/app/src/main/java/com/dark98/santoku/providers/AppDocumentsProvider.java b/app/src/main/java/com/dark98/santoku/providers/AppDocumentsProvider.java new file mode 100644 index 0000000..063da2f --- /dev/null +++ b/app/src/main/java/com/dark98/santoku/providers/AppDocumentsProvider.java @@ -0,0 +1,314 @@ +package com.dark98.santoku.providers; + +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.CancellationSignal; +import android.os.Environment; +import android.provider.DocumentsContract; +import android.provider.DocumentsProvider; +import android.webkit.MimeTypeMap; + +import java.io.File; +import java.io.FileNotFoundException; +import java.util.Locale; + +public class AppDocumentsProvider extends DocumentsProvider { + private static final String ROOT_ID = "app_files"; + private static final String ROOT_DOC_ID = "app_files:"; + + private static final String[] DEFAULT_ROOT_PROJECTION = new String[] { + DocumentsContract.Root.COLUMN_ROOT_ID, + DocumentsContract.Root.COLUMN_DOCUMENT_ID, + DocumentsContract.Root.COLUMN_TITLE, + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.COLUMN_AVAILABLE_BYTES + }; + + private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_LAST_MODIFIED + }; + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor queryRoots(String[] projection) { + final String[] proj = projection != null ? projection : DEFAULT_ROOT_PROJECTION; + MatrixCursor cursor = new MatrixCursor(proj); + + File base = getBaseDir(); + MatrixCursor.RowBuilder row = cursor.newRow(); + row.add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID); + row.add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT_DOC_ID); + row.add(DocumentsContract.Root.COLUMN_TITLE, "Santoku Files"); + int flags = DocumentsContract.Root.FLAG_SUPPORTS_CREATE + | DocumentsContract.Root.FLAG_LOCAL_ONLY; + row.add(DocumentsContract.Root.COLUMN_FLAGS, flags); + row.add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, base.getFreeSpace()); + return cursor; + } + + @Override + public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException { + MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + File file = fileForDocId(documentId); + includeFile(cursor, documentId, file); + return cursor; + } + + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) + throws FileNotFoundException { + MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + File parent = fileForDocId(parentDocumentId); + File[] files = parent.listFiles(); + if (files != null) { + for (File file : files) { + includeFile(cursor, docIdForFile(file), file); + } + } + return cursor; + } + + @Override + public String getDocumentType(String documentId) throws FileNotFoundException { + File file = fileForDocId(documentId); + return getMimeType(file); + } + + @Override + public android.os.ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal) + throws FileNotFoundException { + File file = fileForDocId(documentId); + int accessMode = ParcelFileDescriptorUtil.parseMode(mode); + return android.os.ParcelFileDescriptor.open(file, accessMode); + } + + @Override + public String createDocument(String parentDocumentId, String mimeType, String displayName) + throws FileNotFoundException { + File parent = fileForDocId(parentDocumentId); + if (!parent.isDirectory()) { + throw new FileNotFoundException("Parent is not a directory: " + parentDocumentId); + } + + String name = displayName; + if (DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType)) { + File dir = new File(parent, name); + if (!dir.exists() && !dir.mkdirs()) { + throw new FileNotFoundException("Failed to create directory: " + name); + } + return docIdForFile(dir); + } + + if (!hasExtension(name)) { + String ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); + if (ext != null && !ext.isEmpty()) { + name = name + "." + ext.toLowerCase(Locale.US); + } + } + + File file = new File(parent, name); + try { + if (!file.exists() && !file.createNewFile()) { + throw new FileNotFoundException("Failed to create file: " + name); + } + } catch (Exception e) { + throw new FileNotFoundException("Failed to create file: " + e.getMessage()); + } + return docIdForFile(file); + } + + @Override + public void deleteDocument(String documentId) throws FileNotFoundException { + File file = fileForDocId(documentId); + deleteRecursively(file); + } + + @Override + public String renameDocument(String documentId, String displayName) throws FileNotFoundException { + File file = fileForDocId(documentId); + File target = new File(file.getParentFile(), displayName); + if (!file.renameTo(target)) { + throw new FileNotFoundException("Failed to rename to: " + displayName); + } + return docIdForFile(target); + } + + @Override + public boolean isChildDocument(String parentDocumentId, String documentId) { + try { + File parent = fileForDocId(parentDocumentId); + File doc = fileForDocId(documentId); + return isDescendant(parent, doc); + } catch (FileNotFoundException e) { + return false; + } + } + + @Override + public Cursor queryRecentDocuments(String rootId, String[] projection) throws FileNotFoundException { + MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + File base = getBaseDir(); + File[] files = base.listFiles(); + if (files != null) { + for (File file : files) { + includeFile(cursor, docIdForFile(file), file); + } + } + return cursor; + } + + private File getBaseDir() { + File dir = getContext().getFilesDir(); + if (dir == null) { + dir = new File(Environment.getDataDirectory(), "data"); + } + return dir; + } + + private void includeFile(MatrixCursor cursor, String docId, File file) { + int flags = 0; + if (file.isDirectory()) { + if (file.canWrite()) { + flags |= DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE; + flags |= DocumentsContract.Document.FLAG_SUPPORTS_DELETE; + flags |= DocumentsContract.Document.FLAG_SUPPORTS_RENAME; + } + } else { + if (file.canWrite()) { + flags |= DocumentsContract.Document.FLAG_SUPPORTS_WRITE; + flags |= DocumentsContract.Document.FLAG_SUPPORTS_DELETE; + flags |= DocumentsContract.Document.FLAG_SUPPORTS_RENAME; + } + } + + String mime = getMimeType(file); + MatrixCursor.RowBuilder row = cursor.newRow(); + row.add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, docId); + row.add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.getName()); + row.add(DocumentsContract.Document.COLUMN_SIZE, file.isDirectory() ? null : file.length()); + row.add(DocumentsContract.Document.COLUMN_MIME_TYPE, mime); + row.add(DocumentsContract.Document.COLUMN_FLAGS, flags); + row.add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, file.lastModified()); + } + + private String getMimeType(File file) { + if (file.isDirectory()) { + return DocumentsContract.Document.MIME_TYPE_DIR; + } + String name = file.getName(); + int dot = name.lastIndexOf('.'); + if (dot != -1) { + String ext = name.substring(dot + 1).toLowerCase(Locale.US); + String type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext); + if (type != null) { + return type; + } + } + return "application/octet-stream"; + } + + private String docIdForFile(File file) { + File base = getBaseDir(); + String basePath = base.getAbsolutePath(); + String path = file.getAbsolutePath(); + if (path.equals(basePath)) { + return ROOT_DOC_ID; + } + if (path.startsWith(basePath + File.separator)) { + String rel = path.substring(basePath.length() + 1); + return ROOT_DOC_ID + rel; + } + return ROOT_DOC_ID; + } + + private File fileForDocId(String docId) throws FileNotFoundException { + File base = getBaseDir(); + if (ROOT_DOC_ID.equals(docId) || ROOT_ID.equals(docId)) { + return base; + } + if (!docId.startsWith(ROOT_DOC_ID)) { + throw new FileNotFoundException("Unknown docId: " + docId); + } + String rel = docId.substring(ROOT_DOC_ID.length()); + File target = new File(base, rel); + try { + File canonical = target.getCanonicalFile(); + File canonicalBase = base.getCanonicalFile(); + if (!isDescendant(canonicalBase, canonical)) { + throw new FileNotFoundException("Invalid path: " + docId); + } + return canonical; + } catch (Exception e) { + throw new FileNotFoundException("Failed to resolve: " + docId); + } + } + + private boolean isDescendant(File parent, File child) { + try { + String parentPath = parent.getCanonicalPath(); + String childPath = child.getCanonicalPath(); + return childPath.equals(parentPath) || childPath.startsWith(parentPath + File.separator); + } catch (Exception e) { + return false; + } + } + + private void deleteRecursively(File file) throws FileNotFoundException { + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + } + if (!file.delete()) { + throw new FileNotFoundException("Failed to delete: " + file.getName()); + } + } + + private boolean hasExtension(String name) { + int dot = name.lastIndexOf('.'); + return dot > 0 && dot < name.length() - 1; + } + + private static final class ParcelFileDescriptorUtil { + private ParcelFileDescriptorUtil() {} + + static int parseMode(String mode) { + if ("r".equals(mode)) { + return android.os.ParcelFileDescriptor.MODE_READ_ONLY; + } + if ("w".equals(mode) || "wt".equals(mode)) { + return android.os.ParcelFileDescriptor.MODE_WRITE_ONLY + | android.os.ParcelFileDescriptor.MODE_CREATE + | android.os.ParcelFileDescriptor.MODE_TRUNCATE; + } + if ("wa".equals(mode)) { + return android.os.ParcelFileDescriptor.MODE_WRITE_ONLY + | android.os.ParcelFileDescriptor.MODE_CREATE + | android.os.ParcelFileDescriptor.MODE_APPEND; + } + if ("rw".equals(mode)) { + return android.os.ParcelFileDescriptor.MODE_READ_WRITE + | android.os.ParcelFileDescriptor.MODE_CREATE; + } + if ("rwt".equals(mode)) { + return android.os.ParcelFileDescriptor.MODE_READ_WRITE + | android.os.ParcelFileDescriptor.MODE_CREATE + | android.os.ParcelFileDescriptor.MODE_TRUNCATE; + } + return android.os.ParcelFileDescriptor.MODE_READ_ONLY; + } + } +} diff --git a/app/src/main/java/com/dark98/santoku/utils/IOUtils.java b/app/src/main/java/com/dark98/santoku/utils/IOUtils.java index 6a4987a..17e2b0e 100644 --- a/app/src/main/java/com/dark98/santoku/utils/IOUtils.java +++ b/app/src/main/java/com/dark98/santoku/utils/IOUtils.java @@ -4,6 +4,7 @@ import android.content.ContentResolver; import android.database.Cursor; import android.net.Uri; import android.provider.MediaStore; +import android.provider.OpenableColumns; import android.text.TextUtils; import org.json.JSONArray; @@ -32,18 +33,37 @@ public class IOUtils { public static String getDisplayName(Uri uri) { ContentResolver resolver = Santoku.INSTANCE.getContentResolver(); - String[] projection = {MediaStore.MediaColumns.DISPLAY_NAME}; - Cursor metaCursor = resolver.query(uri, projection, null, null, null); String fileName = null; - if (metaCursor != null) { - try { - if (metaCursor.moveToFirst()) { - fileName = metaCursor.getString(0); - } - } finally { + Cursor metaCursor = null; + try { + String[] projection = {OpenableColumns.DISPLAY_NAME}; + metaCursor = resolver.query(uri, projection, null, null, null); + if (metaCursor != null && metaCursor.moveToFirst()) { + fileName = metaCursor.getString(0); + } + } catch (Exception ignored) { + // Some providers throw for query; fall back below. + } finally { + if (metaCursor != null) { metaCursor.close(); } } + + if (fileName == null) { + try { + String path = uri.getPath(); + if (path != null) { + int idx = path.lastIndexOf('/'); + if (idx != -1 && idx + 1 < path.length()) { + fileName = path.substring(idx + 1); + } else if (!path.isEmpty()) { + fileName = path; + } + } + } catch (Exception ignored) { + } + } + return fileName; } public static String readString(InputStream in) throws IOException {