From 19189cab2be78486c1195d07bf6636e1b10197be Mon Sep 17 00:00:00 2001 From: Alex Yackers <7115964+yackers@users.noreply.github.com> Date: Sat, 21 Dec 2024 23:02:41 +0200 Subject: [PATCH 1/2] feature: `DocumentsProvider` implementation Signed-off-by: Alex Yackers <7115964+yackers@users.noreply.github.com> --- CHANGELOG.md | 8 + README.md | 289 ++++++++++++- android/src/main/AndroidManifest.xml | 10 + .../plugins/docman/DocManDocumentsProvider.kt | 397 ++++++++++++++++++ .../devdf/plugins/docman/DocManPlugin.kt | 6 +- .../plugins/docman/extensions/FileExt.kt | 30 ++ .../plugins/docman/extensions/StringExt.kt | 8 +- .../devdf/plugins/docman/utils/DocManBuild.kt | 8 + .../devdf/plugins/docman/utils/DocManFiles.kt | 86 +++- .../devdf/plugins/docman/utils/DocManMedia.kt | 5 +- .../plugins/docman/utils/DocManMimeType.kt | 19 +- .../docman/utils/DocManProviderSettings.kt | 187 +++++++++ example/README.md | 10 + example/assets/provider.json | 32 ++ example/lib/main.dart | 6 +- example/lib/src/utils/app_dir.dart | 12 +- .../utils/provider_folder_initializer.dart | 61 +++ example/pubspec.lock | 2 +- example/pubspec.yaml | 3 + pubspec.yaml | 4 +- 20 files changed, 1146 insertions(+), 37 deletions(-) create mode 100644 android/src/main/kotlin/devdf/plugins/docman/DocManDocumentsProvider.kt create mode 100644 android/src/main/kotlin/devdf/plugins/docman/extensions/FileExt.kt create mode 100644 android/src/main/kotlin/devdf/plugins/docman/utils/DocManProviderSettings.kt create mode 100644 example/assets/provider.json create mode 100644 example/lib/src/utils/provider_folder_initializer.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a5785..ac11106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 1.1.0 + +* New Feature: Implemented simple custom `DocumentsProvider` +* Small fixes +* Updated README +* Updated example +* Updated dependencies + ## 1.0.2 * Fix: missed export of `PermissionsException` class diff --git a/README.md b/README.md index a7fb1fd..3cbeafe 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ A Flutter plugin that simplifies file & directory operations on Android devices. Leveraging the Storage Access Framework (SAF) API, it provides seamless integration for files & directories operations, persisted permissions management, and more. +Allows to set up own +simple [DocumentsProvider](https://developer.android.com/reference/kotlin/android/provider/DocumentsProvider) +for specific directory within your app's storage. ## 🚀 Features @@ -24,6 +27,7 @@ it provides seamless integration for files & directories operations, persisted p - Separated actions that can be performed in the background, like with isolates or WorkManager. - Persisted permissions management. - No manifest permissions are required. +- `DocumentsProvider` implementation. ## 🤖 Supported Android versions @@ -94,10 +98,13 @@ All public classes and methods are well-documented. - [Action methods](#documentfile-action-methods) - 🧩 [DocumentThumbnail](#-documentthumbnail-class) - [Unsupported methods](#unsupported-methods) -6. đŸ—ƒī¸ [**DocMan Exceptions**](#docman-exceptions) -7. đŸ“Ļ [**Changelog**](#-changelog) -8. â‰ī¸ [**Help & Questions**](#help--questions) -9. 🌱 [**Contributing**](#-contributing) +6. đŸ—‚ī¸ [**DocumentsProvider**](#documents-provider) (đŸ–ŧī¸ [*see examples*](#documents-provider-examples)) + - [Setup DocumentsProvider](#setup-documentsprovider) + - [DocumentsProvider example](#provider-json-example) +7. đŸ—ƒī¸ [**DocMan Exceptions**](#docman-exceptions) +8. đŸ“Ļ [**Changelog**](#-changelog) +9. â‰ī¸ [**Help & Questions**](#help--questions) +10. 🌱 [**Contributing**](#-contributing) @@ -122,14 +129,16 @@ following methods: Allows picking a directory from the device storage. You can specify the initial directory to start from. -> âš ī¸ When picking directory, it also grants access to it. +> [!WARNING] +> When picking directory, it also grants access to it. > On **Android 11 (API level 30)** and higher it's ***impossible*** to grant access to **root directories of sdCard, download folder**, also it's ***impossible*** to select any file from: > **Android/data/** directory and all subdirectories, **Android/obb/** directory and all subdirectories. > > [All restrictions are described here at developer.android.com](https://developer.android.com/training/data-storage/shared/documents-files#document-tree-access-restrictions) -> âš ī¸ `initDir`: Option to set initial directory uri for picker is available since Android 8.0 (Api 26). +> [!NOTE] +> `initDir`: Option to set initial directory uri for picker is available since Android 8.0 (Api 26). > If the option is not available, the picker will start from the default directory. ```dart @@ -141,7 +150,8 @@ Future pickBackupDir() => DocMan.pick.directory(initDir: 'content Allows picking single or multiple documents. You can specify the initial directory to start from. Filter by MIME types & extensions, by location - only local files (no cloud providers etc.) or both. You can choose a limit strategy when picking multiple documents. -Grant persisted permissions to the picked documents. +Grant persisted permissions to the picked documents. The point of this is to get only the metadata of the documents, +without copying them to the cache/files directory. ```dart Future> pickDocuments() => @@ -302,7 +312,8 @@ If the grant has already been persisted, taking it again will just update the gr You can instantiate a [DocManPermissionManager](/lib/src/utils/doc_man_permissions.dart) class or use the helper: `DocMan.perms` to manage permissions. -> âš ī¸ Persistable permissions have limitations: +> [!CAUTION] +> Persistable permissions have limitations: > - Limited to **128** permissions per app for **Android 10** and below > - Limited to **512** permissions per app for **Android 11** and above @@ -456,8 +467,8 @@ The purpose of it is to get the file's metadata like name, size, mime type, last it without the need to copy each file in cache/files directory. All supported methods are divided in extensions grouped by channels (Action, Activity, Events). - -> â„šī¸ Methods for directories are marked with `📁`, for files `📄`. +> [!NOTE] +> Methods for directories are marked with `📁`, for files `📄`. #### **Instantiate DocumentFile** @@ -465,16 +476,17 @@ There are two ways to instantiate a `DocumentFile`: - From the uri (content://), saved previously, with persisted permission. - > ⛔ In rarely cases `DocumentFile` can be instantiated even if the uri doesn't have persisted permission. - For example uris like `content://media/external/file/106` cannot be instantiated directly, - but if the file was picked through (`DocMan.pick.visualMedia()` for example), it will be instantiated, - but most of the methods will throw an exception, you will be able only to read the file content. - ```dart Future backupDir() => DocumentFile(uri: 'content://com.android.externalstorage.documents/tree/primary%3ADocMan').get(); ``` +> [!CAUTION] +> In rarely cases `DocumentFile` can be instantiated even if the uri doesn't have persisted permission. +> For example uris like `content://media/external/file/106` cannot be instantiated directly, +> but if the file was picked through (`DocMan.pick.visualMedia()` for example), it will be instantiated, +> but most of the methods will throw an exception, you will be able only to read the file content. + - From the app local `File.path` or `Directory.path`. ```dart @@ -536,6 +548,8 @@ if it's a file, you can read it via stream as bytes or string. file.readAsString(charset: 'UTF-8', bufferSize: 1024, start: 0); ``` + + - `readAsBytes` `📄` Read the file content as bytes stream. Can be used only on file & file must exist. @@ -546,6 +560,8 @@ if it's a file, you can read it via stream as bytes or string. file.readAsBytes(bufferSize: (1024 * 8), start: 0); ``` + + - `listDocumentsStream` `📁` List the documents in the directory as stream. Can be used only on directory & directory must exist. @@ -578,7 +594,7 @@ and can be performed in the background (with isolates or WorkManager). Future readBytes(DocumentFile file) => file.read(); ``` - â„šī¸ If file is big, it's better to use stream-based method `readAsBytes`. + â„šī¸ If file is big, it's better to use stream-based method [readAsBytes](#documentfile-read-as-bytes). - `createDirectory` `📁` Create a new subdirectory with the specified name. @@ -621,7 +637,7 @@ and can be performed in the background (with isolates or WorkManager). ``` â„šī¸ This method returns all documents in the directory, if list has many items, - it's better to use stream-based method `listDocumentsStream`. + it's better to use stream-based method [listDocumentsStream](#list-documents-stream). - `find` `📁` Find the document in the directory by name. @@ -707,10 +723,11 @@ and can be performed in the background (with isolates or WorkManager). ```dart Future thumbnail(DocumentFile file) => file.thumbnail(width: 256, height: 256, quality: 70); ``` - + > [!NOTE] > âš ī¸ Sometimes due to different document providers, thumbnail can have bigger dimensions, than requested. Some document providers may not support thumbnail generation. + > [!TIP] > âš ī¸ If file is local image, only `jpg`, `png`, `webp`, `gif` types are currently supported for thumbnail generation, in all other cases support depends on the document provider. @@ -732,6 +749,7 @@ It stores the `width`, `height` of the image, and the `bytes` (Uint8List) of the Information about currently (temporarily) unsupported methods in the plugin. +> [!CAUTION] > âš ī¸ Currently `📄` `rename` action was commented out due to the issue with the SAF API. > Very few Documents Providers support renaming files & after renaming, the document may not be found, > so it's better to use `copy` & `delete` actions instead. @@ -755,6 +773,241 @@ Information about currently (temporarily) unsupported methods in the plugin.
+ + +## đŸ—‚ī¸ DocumentsProvider + +`DocMan` provides a way to set up a simple +custom [DocumentsProvider](https://developer.android.com/reference/kotlin/android/provider/DocumentsProvider) +for your app. The main purpose of this feature is to share app files & directories with other apps, +by `System File Picker UI`. You provide the name of the directory, +where your public files are stored. The plugin will create a custom DocumentsProvider for your app, +that will be accessible by other apps. You can customize it, and set the permissions for the files & directories. + +> [!NOTE] +> If you don't want to use the custom DocumentsProvider, you can just delete the `provider.json`, +> if it exists, in the `assets` directory. + +> [!TIP] +> When you perform any kind of action on files or directories in the provider directory, +> Provider will reflect the changes in the System File Picker UI. + +#### **Setup DocumentsProvider** + +1. Create/Copy the `provider.json` file to the `assets` directory in your app. + You can find the example file in the [plugin's example app](/example/assets/provider.json). +2. Update the `pubspec.yaml` file. + + ```yaml + flutter: + assets: + - assets/provider.json + ``` + +#### **DocumentsProvider configuration** + +All configuration is stored in the `assets/provider.json` file. + +> [!IMPORTANT] +> Once you set up the provider, ***do not change any parameter dynamically***, +> otherwise the provider will not work correctly. + +- `rootPath` The name of the directory where your public files are stored. + This ***parameter is required***. This is an entry point for the provider. + Directory will be created automatically, if it doesn't exist. + Plugin first will try to create the directory in the external storage (app files folder), if not available, + then in the internal storage (app data folder - which is `app_flutter/`) + + Example values: `public_documents`, `provider`, `nested/public/path`. + ```json + { + "rootPath": "public_documents" + } + ``` + +- `providerName` - The name of the provider that will be shown in the System UI, + if null it will use the app name. Do not set long name, it will be truncated. + + ```json + { + "providerName": "DocMan Example" + } + ``` + +- `providerSubtitle` - The subtitle of the provider that will be shown in the System UI, if null it will be hidden. + + ```json + { + "providerSubtitle": "Documents & media files" + } + ``` + +- `mimeTypes` - List of mime types that the provider supports. Set this to null to show provider in all scenarios. + + ```json + { + "mimeTypes": ["image/*", "video/*"] + } + ``` +- `extensions` - List of file extensions that the provider supports. Set this to null to show provider in all scenarios. + ```json + { + "extensions": ["pdf", ".docx"] + } + ``` + + On the init, if you provide `mimeTypes` & `extensions`, the plugin will check if the platform supports them & + will combine in a single list & filter only supported types. + +> [!NOTE] +> If you set `mimeTypes` for example to `["image/*"]`, when `System File Picker UI` is opened by any other +> app, which also wants to get images, it will show your provider in list of providers. But remember if you set +`mimeTypes` or `extensions` to specific types, but you store different types of files in the directory, +> they will be also visible. + +> [!IMPORTANT] +> **In short:** if you set `mimeTypes` or `extensions`, +***you have to store only files of these types in the provider directory***. + +- `showInSystemUI` - Whether to show the provider in the System UI. If set to `false`, the provider will be hidden. + This is working only on Android 10 (Api 29) and above, on lower versions it will always be shown. + +- `supportRecent` - Whether to add provider files to the `Recent` list. +- `supportSearch` - Whether to include provider files in search in the System UI. +- `maxRecentFiles` - Maximum number of recent files that will be shown in the `Recent` list. + Android max limit is 64, plugin default is 15. +- `maxSearchResults` - Maximum number of search results that will be shown in the search list. + Plugin default is 10. + +🚩 **Supported flags for directories:** + +> [!TIP] +> You can skip the `directories` section, if you plan to support all actions for directories. +> Because by default all actions are set to `true`, even if you don't provide them in the section. + +- `create` - Whether the provider supports creation of new files & directories within it. +- `delete` - Whether the provider supports deletion of files & directories. +- `move` - Whether documents in the provider can be moved. +- `rename` - Whether documents in the provider can be renamed. +- `copy` - Whether documents in the provider can be copied. + +Section for directories in the `provider.json` file: + +```json +{ + "directories": { + "create": true, + "delete": true, + "move": true, + "rename": true, + "copy": true + } +} +``` + +đŸŗī¸ **Supported flags for files:** + +> [!TIP] +> You can skip the `files` section, if you plan to support all actions for directories. +> Because by default all actions are set to `true`, even if you don't provide them in the section. + +- `delete` - Whether the provider supports deletion of files & directories. +- `move` - Whether documents in the provider can be moved. +- `rename` - Whether documents in the provider can be renamed. +- `write` - Whether documents in the provider can be modified. +- `copy` - Whether documents in the provider can be copied. +- `thumbnail` - Indicates that documents can be represented as a thumbnails. + - The provider supports generating custom thumbnails for videos and PDFs. + - Thumbnails for images are generated by system. + - All thumbnails, generated by the provider, are cached in the `thumbs` directory under the `docManMedia` directory. + - You can clear the thumbnail cache using `DocMan.dir.clearCache()`. + +Section for files in the `provider.json` file: + +```json +{ + "files": { + "delete": true, + "move": true, + "rename": true, + "write": true, + "copy": true, + "thumbnail": true + } +} +``` + +or short version, if all actions are supported: + +```json +{ + "files": { + "delete": false + } +} +``` + + +
+ +đŸ—’ī¸ Full Example of the `provider.json` (click for expand/collapse) + +```json +{ + "rootPath": "nested/provider_folder", + "providerName": "DocMan Example", + "providerSubtitle": "Documents & media files", + "mimeTypes": [ + "image/*" + ], + "extensions": [ + ".pdf", + "mp4" + ], + "showInSystemUI": true, + "supportRecent": true, + "supportSearch": true, + "maxRecentFiles": 20, + "maxSearchResults": 20, + "directories": { + "create": false, + "delete": true, + "move": true, + "rename": true, + "copy": true + }, + "files": { + "delete": true, + "move": true, + "rename": true, + "write": true, + "copy": true, + "thumbnail": true + } +} +``` + +
+ + + + +
+
+đŸ–ŧī¸ DocumentsProvider examples (click for expand/collapse) + +| Side menu view in System File Manager | Visibility in Recents | +|:---------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------:| +| | | + +| DocumentsProvider through Intent | DocumentsProvider via File Manager | +|:---------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------:| +| | | + +
+ +
+ ## đŸ—ƒī¸ DocMan Exceptions diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index c27b18c..f2ccad0 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -3,6 +3,16 @@ package="devdf.plugins.docman"> + + + + + + BufferedInputStream(input).use { it.readBytes().toString(Charsets.UTF_8) } + } + settings = DocManProviderSettings.fromJson(json) + rootDirectory = DocManFiles.providerDir(settings.rootPath, context!!) + applicationName = + context!!.applicationInfo.loadLabel(context!!.packageManager).toString() + authority = context!!.packageName + ".docman.documents" + }.getOrNull() ?: return false + + //Check if settings are not yet initialized + return ::settings.isInitialized + } + + /** Describes the root of the provider */ + override fun queryRoots(projection: Array?): Cursor { + if (!::settings.isInitialized) { + return MatrixCursor(projection ?: arrayOf()) + } + + //1. Setting up the cursor + val cursor = MatrixCursor(projection ?: settings.defaultRootProjection()) + + cursor.newRow().apply { + add(Root.COLUMN_ROOT_ID, "root") + add(Root.COLUMN_DOCUMENT_ID, ROOT_DOC_ID) + add(Root.COLUMN_MIME_TYPES, settings.mimeTypes) + add(Root.COLUMN_FLAGS, settings.getRootFlags()) + add(Root.COLUMN_ICON, context!!.applicationInfo.icon) + add(Root.COLUMN_TITLE, settings.providerName ?: applicationName) + add(Root.COLUMN_SUMMARY, settings.providerSubtitle) + } + + //2. Notify the system that the cursor has changed & return the cursor + return cursor.apply { + setNotificationUrl(DocumentsContract.buildRootsUri(authority)) + } + } + + /** Queries the recent documents */ + override fun queryRecentDocuments(rootId: String, projection: Array?): Cursor { + if (!::settings.isInitialized) { + return MatrixCursor(projection ?: arrayOf()) + } + + val result = MatrixCursor(projection ?: settings.defaultDocumentProjection()) + + getFile(rootId).walkTopDown() + .filter { it.isFile } + .map { it to it.lastModified() } + .sortedByDescending { it.second } + .take(settings.maxRecentFiles) + .forEach { (file, _) -> includeFile(result, null, file) } + + return result + } + + override fun querySearchDocuments( + rootId: String, + query: String, + projection: Array? + ): Cursor { + if (!::settings.isInitialized) { + return MatrixCursor(projection ?: arrayOf()) + } + + val result = MatrixCursor(projection ?: settings.defaultDocumentProjection()) + val parent = getFile(rootId) + val normalQuery = + Normalizer.normalize(query.lowercase(Locale.getDefault()), Normalizer.Form.NFD) + + parent.walkTopDown().asSequence() + .filter { it.isFile } + .map { + it to Normalizer.normalize( + it.name.lowercase(Locale.getDefault()), + Normalizer.Form.NFD + ) + } + .filter { (_, fileName) -> fileName.contains(normalQuery) } + .take(settings.maxSearchResults) + .forEach { (file, _) -> includeFile(result, null, file) } + + return result + } + + override fun queryChildDocuments( + parentDocumentId: String?, + projection: Array?, + sortOrder: String? + ): Cursor { + if (!::settings.isInitialized) { + return MatrixCursor(projection ?: arrayOf()) + } + + var cursor = MatrixCursor(projection ?: settings.defaultDocumentProjection()) + + val parent = getFile(parentDocumentId!!) + for (file in parent.listFiles()!!) + cursor = includeFile(cursor, null, file) + + // Notify the system that the cursor has changed + return cursor.apply { + setNotificationUrl(childDocumentsUri(parentDocumentId)) + } + } + + override fun queryDocument(documentId: String?, projection: Array?): Cursor { + if (!::settings.isInitialized) { + return MatrixCursor(projection ?: arrayOf()) + } + + val cursor = MatrixCursor(projection ?: settings.defaultDocumentProjection()) + return includeFile(cursor, documentId, null) + } + + override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean { + val parent = getFile(parentDocumentId!!) + val file = getFile(documentId!!) + return file.parentFile == parent + } + + override fun createDocument( + parentDocumentId: String?, + mimeType: String?, + displayName: String + ): String = File(getFile(parentDocumentId!!), displayName).apply { + val success = when (mimeType) { + DocumentsContract.Document.MIME_TYPE_DIR -> mkdir() + else -> createNewFile() + } + if (!success) throw FileSystemException(this) + notifyChange(childDocumentsUri(parentDocumentId)) + }.let { getDocumentId(it) } + + override fun deleteDocument(documentId: String?) { + getFile(documentId!!).apply { + if (!deleteRecursively()) throw FileSystemException(this) + /// Notify the system that the document has been deleted + parentFile?.let { + notifyChange(childDocumentsUri(getDocumentId(it))) + } + } + } + + override fun removeDocument(documentId: String, parentDocumentId: String?) { + val parent = getFile(parentDocumentId!!) + val file = getFile(documentId) + + if (parent == file || file.parentFile == null || file.parentFile!! == parent) { + if (!file.deleteRecursively()) throw FileSystemException(file) + /// Notify the system that the document has been deleted + notifyChange(childDocumentsUri(parentDocumentId)) + } else { + throw FileNotFoundException("Couldn't delete document with ID '$documentId'") + } + } + + override fun renameDocument(documentId: String?, displayName: String?): String { + if (displayName == null) + throw FileNotFoundException("Couldn't rename document '$documentId' as the new name is null") + + val sourceFile = getFile(documentId!!) + val sourceParentFile = sourceFile.parentFile + ?: throw FileNotFoundException("Couldn't rename document '$documentId' as it has no parent") + val destFile = sourceParentFile.resolve(displayName) + + try { + if (!sourceFile.renameTo(destFile)) + throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'") + } catch (e: Exception) { + throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}': ${e.message}") + } + + return getDocumentId(destFile) + } + + override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String?): String { + val parent = getFile(targetParentDocumentId!!) + val oldFile = getFile(sourceDocumentId) + val newFile = parent.resolveWithoutConflict(oldFile.name) + + try { + if (!(newFile.createNewFile() && newFile.setWritable(true) && newFile.setReadable(true))) + throw IOException("Couldn't create new file") + + FileInputStream(oldFile).use { inStream -> + FileOutputStream(newFile).use { outStream -> + inStream.copyTo(outStream) + } + } + } catch (e: IOException) { + throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${e.message}") + } + + return getDocumentId(newFile) + } + + override fun moveDocument( + sourceDocumentId: String, + sourceParentDocumentId: String?, + targetParentDocumentId: String? + ): String { + try { + val newDocumentId = copyDocument( + sourceDocumentId, sourceParentDocumentId!!, + targetParentDocumentId + ) + removeDocument(sourceDocumentId, sourceParentDocumentId) + return newDocumentId + } catch (e: FileNotFoundException) { + throw FileNotFoundException("Couldn't move document '$sourceDocumentId'") + } + } + + + override fun openDocument( + documentId: String?, + mode: String?, + signal: CancellationSignal? + ): ParcelFileDescriptor { + val file = documentId?.let { getFile(it) } + val accessMode = ParcelFileDescriptor.parseMode(mode) + return ParcelFileDescriptor.open(file, accessMode) + } + + override fun openDocumentThumbnail( + documentId: String?, + sizeHint: Point, + signal: CancellationSignal? + ): AssetFileDescriptor { + //1. Get the file for the document ID + val file = getFile(documentId!!) + //1.1. Get the thumbnail name for the document + val thumbName = "${sizeHint.x}x${sizeHint.y}_${documentId}" + //2. Get the thumbnail file for the document + val thumbFile = File( + DocManFiles.thumbnailsCacheDir(context!!), + file.getThumbnailName(thumbName) + ) + //3. If the thumbnail doesn't exist and the file can be used to generate a thumbnail + if (!thumbFile.exists() && (file.isVideo() || file.extension == "pdf")) { + DocManFiles.getThumbnailForFile(file, thumbFile, sizeHint, context!!) + } + //4. Return the thumbnail file + return AssetFileDescriptor( + ParcelFileDescriptor.open( + if (thumbFile.exists() && thumbFile.length() != 0L) thumbFile else file, + ParcelFileDescriptor.MODE_READ_ONLY + ), + 0, + AssetFileDescriptor.UNKNOWN_LENGTH + ) + } + + /** + * @return The [File] that corresponds to the document ID supplied by [getDocumentId] + */ + private fun getFile(documentId: String): File { + return when (documentId) { + "root", ROOT_DOC_ID -> rootDirectory + else -> rootDirectory.resolve(documentId).apply { + if (!exists()) throw FileNotFoundException("Couldn't find document with ID '$documentId'") + } + } + } + + /** + * @return A unique ID for the provided [File] + */ + private fun getDocumentId(file: File): String { + return if (file == rootDirectory) ROOT_DOC_ID else file.relativeTo(rootDirectory).path + } + + /** + * @return A new [File] with a unique name based off the supplied [name], + * not conflicting with any existing file + */ + private fun File.resolveWithoutConflict(name: String): File { + var file = resolve(name) + if (file.exists()) { + var nameNumber = 1 + val extension = name.substringAfterLast('.') + val baseName = name.substringBeforeLast('.') + while (file.exists()) + file = if (extension == baseName) { + resolve("$baseName ($nameNumber++)") + } else { + resolve("$baseName (${nameNumber++}).$extension") + } + } + return file + } + + private fun copyDocument( + sourceDocumentId: String, sourceParentDocumentId: String, + targetParentDocumentId: String? + ): String { + if (!isChildDocument(sourceParentDocumentId, sourceDocumentId)) + throw FileNotFoundException("Couldn't copy document '$sourceDocumentId' as its parent is not '$sourceParentDocumentId'") + + return copyDocument(sourceDocumentId, targetParentDocumentId) + } + + + private fun includeFile(cursor: MatrixCursor, documentId: String?, file: File?): MatrixCursor { + val docID = documentId ?: file?.let { getDocumentId(it) } + val docFile = file ?: getFile(documentId!!) + + cursor.newRow().apply { + add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, docID) + add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(docFile)) + add( + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + if (docFile == rootDirectory) applicationName else docFile.name + ) + add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, docFile.lastModified()) + add(DocumentsContract.Document.COLUMN_SIZE, docFile.length()) + add(DocumentsContract.Document.COLUMN_FLAGS, settings.getDocumentFlags(docFile)) + add( + DocumentsContract.Document.COLUMN_ICON, + if (docFile == rootDirectory) context!!.applicationInfo.icon else null + ) + add(DocumentsContract.Document.COLUMN_SUMMARY, null) + } + + return cursor + } + + private fun getTypeForFile(file: File): Any = if (file.isDirectory) { + DocumentsContract.Document.MIME_TYPE_DIR + } else { + file.name.getMimeTypeByExtension() + } + + private fun MatrixCursor.setNotificationUrl(uri: Uri) = + setNotificationUri(context!!.contentResolver, uri) + + private fun notifyChange(uri: Uri) = context!!.contentResolver.notifyChange(uri, null) +} \ No newline at end of file diff --git a/android/src/main/kotlin/devdf/plugins/docman/DocManPlugin.kt b/android/src/main/kotlin/devdf/plugins/docman/DocManPlugin.kt index e162df8..5dc9ce7 100644 --- a/android/src/main/kotlin/devdf/plugins/docman/DocManPlugin.kt +++ b/android/src/main/kotlin/devdf/plugins/docman/DocManPlugin.kt @@ -33,10 +33,10 @@ class DocManPlugin : FlutterPlugin, ActivityAware { var messenger: BinaryMessenger? = null - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { //1. Setting the context and messenger - context = flutterPluginBinding.applicationContext - messenger = flutterPluginBinding.binaryMessenger + context = binding.applicationContext + messenger = binding.binaryMessenger //2. Attaching the activity docManActivity.onAttach() //3. Attaching the actions diff --git a/android/src/main/kotlin/devdf/plugins/docman/extensions/FileExt.kt b/android/src/main/kotlin/devdf/plugins/docman/extensions/FileExt.kt new file mode 100644 index 0000000..eb798b9 --- /dev/null +++ b/android/src/main/kotlin/devdf/plugins/docman/extensions/FileExt.kt @@ -0,0 +1,30 @@ +package devdf.plugins.docman.extensions + +import java.io.File + +/** Check if file can be used to generate a thumbnail */ +fun File.canThumbnail(): Boolean { + return name.getMimeTypeByExtension().let { + it == "application/pdf" || it.startsWith("image/") || it.startsWith("video/") + } +} + +/** Check if file is a video file by extension mimeType */ +fun File.isVideo(): Boolean { + return name.getMimeTypeByExtension().startsWith("video/") +} + +/** Get thumbnail name for the file + * + * @param name Name to be used for the thumbnail or null. + * If null, the file name will be used + * @return Thumbnail name for the file. + * Example: thumb_file_name.jpg + */ +fun File.getThumbnailName(name: String?): String { + //1. To create unique thumbnail name, use relative path, convert to lowercase and replace spaces + val uniqueName = + (name?.substringBeforeLast(".") ?: nameWithoutExtension).asFileName().lowercase() + return "$uniqueName.jpg" +} + diff --git a/android/src/main/kotlin/devdf/plugins/docman/extensions/StringExt.kt b/android/src/main/kotlin/devdf/plugins/docman/extensions/StringExt.kt index 183f25b..ab2a309 100644 --- a/android/src/main/kotlin/devdf/plugins/docman/extensions/StringExt.kt +++ b/android/src/main/kotlin/devdf/plugins/docman/extensions/StringExt.kt @@ -1,6 +1,7 @@ package devdf.plugins.docman.extensions import android.net.Uri +import android.webkit.MimeTypeMap import java.io.File @@ -20,4 +21,9 @@ fun String.toUri(): Uri { /** Sanitize string to be used as a file name */ fun String.asFileName(): String = - this.replace(Regex("[\\\\/:*?\"<>|\\[\\]\\s]"), "_") \ No newline at end of file + this.replace(Regex("[\\\\/:*?\"<>|\\[\\]\\s]"), "_") + +fun String.getMimeTypeByExtension(): String { + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(substringAfterLast('.', "")) + ?: "application/octet-stream" +} \ No newline at end of file diff --git a/android/src/main/kotlin/devdf/plugins/docman/utils/DocManBuild.kt b/android/src/main/kotlin/devdf/plugins/docman/utils/DocManBuild.kt index 67ee5bf..792aca9 100644 --- a/android/src/main/kotlin/devdf/plugins/docman/utils/DocManBuild.kt +++ b/android/src/main/kotlin/devdf/plugins/docman/utils/DocManBuild.kt @@ -58,5 +58,13 @@ class DocManBuild { /** Can use `ContentResolver.loadThumbnail` */ @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q) fun loadThumbnail(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + + /** Can use `DocumentsContract.Document.FLAG_SUPPORTS_MOVE` */ + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N) + fun supportsMoveCopyFlag(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + + /** Can use `DocumentsContract.Root.FLAG_EMPTY` */ + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q) + fun supportsRootEmptyFlag(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q } } \ No newline at end of file diff --git a/android/src/main/kotlin/devdf/plugins/docman/utils/DocManFiles.kt b/android/src/main/kotlin/devdf/plugins/docman/utils/DocManFiles.kt index ce90a60..10abd63 100644 --- a/android/src/main/kotlin/devdf/plugins/docman/utils/DocManFiles.kt +++ b/android/src/main/kotlin/devdf/plugins/docman/utils/DocManFiles.kt @@ -1,7 +1,9 @@ package devdf.plugins.docman.utils import android.content.Context +import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Point import android.net.Uri import android.util.Log import android.util.Size @@ -15,13 +17,17 @@ import devdf.plugins.docman.extensions.getFileExtension import devdf.plugins.docman.extensions.isAppFile import devdf.plugins.docman.extensions.isImage import devdf.plugins.docman.extensions.isMediaMimeType +import devdf.plugins.docman.extensions.isVideo import devdf.plugins.docman.extensions.isVisualMedia import devdf.plugins.docman.extensions.nameAsFileName import devdf.plugins.docman.extensions.toDocumentFile +import io.flutter.util.PathUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject import java.io.File import java.io.IOException import java.io.InputStreamReader @@ -31,6 +37,35 @@ import java.nio.charset.Charset /** Helper class for file operations */ class DocManFiles { companion object { + + /** Convert JSON string to map + * Used for parse provider.json file + * + * @param json The JSON string to convert + * @return The converted map + */ + fun jsonToMap(json: String): Map { + val map = mutableMapOf() + val jsonMap = JSONObject(json) + jsonMap.keys().forEach { + map[it] = jsonMap[it] + //1. Check for array and convert to list + if (jsonMap[it] is JSONArray) { + val list = mutableListOf() + for (i in 0 until (jsonMap[it] as JSONArray).length()) { + list.add((jsonMap[it] as JSONArray).get(i)) + } + map[it] = list + } + //2. Check for nested JSON object and convert to map + if (jsonMap[it] is JSONObject) { + map[it] = jsonToMap(jsonMap[it].toString()) + } + + } + return map + } + /** Save [DocumentFile] to a cache file. * * @param doc The [DocumentFile] to save @@ -192,9 +227,8 @@ class DocManFiles { context: Context ): String? = withContext(Dispatchers.IO) { //1. Prepare target file - val targetDirectory = cacheMediaDir(context) val targetFileName = "thumb_${doc.getBaseName(true)}.${format.extension}" - val targetFile = File(targetDirectory, targetFileName) + val targetFile = File(thumbnailsCacheDir(context), targetFileName) //2. Get thumbnail bitmap and compress it to the target file try { DocManMedia.getThumbnailBitmap(doc, size, context)?.let { bitmap -> @@ -210,6 +244,34 @@ class DocManFiles { } } + /** Get Thumbnail as file for [File] */ + fun getThumbnailForFile( + file: File, + thumb: File, + size: Point, + context: Context + ) = runCatching { + val doc = DocumentFile.fromFile(file) + val thumbSize = Size(size.x, size.y) + + when { + doc.isVideo(context) -> DocManMedia.videoThumbnail(doc, thumbSize, context) + doc.type == "application/pdf" -> DocManMedia.pdfThumbnail( + doc, + thumbSize, + context + ) + + else -> null + }?.let { + //2. If bitmap is not null, save it to cache + thumb.outputStream().use { stream -> + it.compress(Bitmap.CompressFormat.JPEG, 90, stream) + } + } + + }.getOrNull() + /** Generate a file name for temporary files */ fun genFileName(): String = "docman_file_${System.currentTimeMillis() % 100000}" @@ -231,6 +293,26 @@ class DocManFiles { } } + /** Get provider directory. + * If external files directory is not available, internal data directory is used. + * + * @param rootPath The relative path of the root provider directory + * @param context The context of the application + * @return The provider directory + */ + fun providerDir(rootPath: String, context: Context): File { + val dir = context.getExternalFilesDir(null) ?: File(PathUtils.getDataDirectory(context)) + return dir.resolve(rootPath).apply { if (!exists()) mkdir() } + } + + /** Get plugin cache directory for thumbnails */ + fun thumbnailsCacheDir(context: Context): File { + return cacheMediaDir(context).resolve("thumbs").apply { + if (!exists()) mkdir() + deleteOnExit() + } + } + /** Get plugin cache directory for media files */ private fun cacheMediaDir(context: Context): File = createCacheTempDir(context, "docManMedia") diff --git a/android/src/main/kotlin/devdf/plugins/docman/utils/DocManMedia.kt b/android/src/main/kotlin/devdf/plugins/docman/utils/DocManMedia.kt index d509dc9..68d5c5f 100644 --- a/android/src/main/kotlin/devdf/plugins/docman/utils/DocManMedia.kt +++ b/android/src/main/kotlin/devdf/plugins/docman/utils/DocManMedia.kt @@ -109,7 +109,6 @@ class DocManMedia { return outputStream } - /** Try to get image thumbnail */ private fun imageThumbnail( doc: DocumentFile, @@ -136,7 +135,7 @@ class DocManMedia { }.getOrNull() /** Try to get video thumbnail */ - private fun videoThumbnail( + fun videoThumbnail( doc: DocumentFile, size: Size, context: Context @@ -175,7 +174,7 @@ class DocManMedia { }.getOrNull() /** Try to get pdf thumbnail */ - private fun pdfThumbnail( + fun pdfThumbnail( doc: DocumentFile, size: Size, context: Context diff --git a/android/src/main/kotlin/devdf/plugins/docman/utils/DocManMimeType.kt b/android/src/main/kotlin/devdf/plugins/docman/utils/DocManMimeType.kt index 0c3feea..df581f1 100644 --- a/android/src/main/kotlin/devdf/plugins/docman/utils/DocManMimeType.kt +++ b/android/src/main/kotlin/devdf/plugins/docman/utils/DocManMimeType.kt @@ -8,7 +8,13 @@ import android.webkit.MimeTypeMap class DocManMimeType { companion object { - fun combine(mimeTypes: List?, extensions: List?): List { + /** Combine mimeTypes and extensions into a single list */ + fun combine( + mimeTypes: List?, + extensions: List?, + //Allow all mimeTypes to be added, even if they are not recognized by the system + allowAll: Boolean = false + ): List { val mimeTypesSet = mutableSetOf() //1. Filter current mimeTypes & add to the set mimeTypes?.forEach { @@ -16,16 +22,19 @@ class DocManMimeType { if (it.endsWith("/*")) mimeTypesSet.add(it) //1.2 Check if prefix mime is already in set with asterisks if (!mimeTypesSet.contains("${it.split("/")[0]}/*")) { - if (MimeTypeMap.getSingleton().hasMimeType(it) || it == "directory") { + if (MimeTypeMap.getSingleton() + .hasMimeType(it) || it == "directory" || allowAll + ) { mimeTypesSet.add(it) } } } //2. Filter extensions and add to the set extensions?.forEach { - MimeTypeMap.getSingleton().getMimeTypeFromExtension(it)?.let { mimeType -> - mimeTypesSet.add(mimeType) - } + MimeTypeMap.getSingleton().getMimeTypeFromExtension(it.substringAfterLast(".")) + ?.let { mimeType -> + mimeTypesSet.add(mimeType) + } } return mimeTypesSet.toList() diff --git a/android/src/main/kotlin/devdf/plugins/docman/utils/DocManProviderSettings.kt b/android/src/main/kotlin/devdf/plugins/docman/utils/DocManProviderSettings.kt new file mode 100644 index 0000000..3ce6834 --- /dev/null +++ b/android/src/main/kotlin/devdf/plugins/docman/utils/DocManProviderSettings.kt @@ -0,0 +1,187 @@ +package devdf.plugins.docman.utils + +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Root +import devdf.plugins.docman.extensions.canThumbnail +import java.io.File + +data class DirectoryFlags( + val create: Boolean, + val delete: Boolean, + val move: Boolean, + val rename: Boolean, + val copy: Boolean, +) { + companion object { + fun fromJson(resultMap: Map?): DirectoryFlags = DirectoryFlags( + create = resultMap?.get("create") as? Boolean ?: true, + delete = resultMap?.get("delete") as? Boolean ?: true, + move = resultMap?.get("move") as? Boolean ?: true, + rename = resultMap?.get("rename") as? Boolean ?: true, + copy = resultMap?.get("copy") as? Boolean ?: true, + ) + } +} + +data class FileFlags( + val write: Boolean, + val delete: Boolean, + val move: Boolean, + val rename: Boolean, + val copy: Boolean, + val thumbnail: Boolean, +) { + companion object { + fun fromJson(resultMap: Map?): FileFlags = FileFlags( + write = resultMap?.get("write") as? Boolean ?: true, + delete = resultMap?.get("delete") as? Boolean ?: true, + move = resultMap?.get("move") as? Boolean ?: true, + rename = resultMap?.get("rename") as? Boolean ?: true, + copy = resultMap?.get("copy") as? Boolean ?: true, + thumbnail = resultMap?.get("thumbnail") as? Boolean ?: true, + ) + } +} + +/** Settings for the DocumentsProvider */ +class DocManProviderSettings( + val rootPath: String, + val providerName: String?, + val providerSubtitle: String?, + val mimeTypes: String?, + val showInSystemUI: Boolean, + val supportRecent: Boolean, + val supportSearch: Boolean, + val maxRecentFiles: Int, + val maxSearchResults: Int, + val directoryFlags: DirectoryFlags, + val fileFlags: FileFlags, +) { + + companion object { + @Suppress("UNCHECKED_CAST") + fun fromJson(json: String): DocManProviderSettings { + //1. Convert json to map + val map = DocManFiles.jsonToMap(json) + //2. Collect supported mime types + val mimeTypes = DocManMimeType.combine( + map["mimeTypes"] as? List, + map["extensions"] as? List, + true + ).takeIf { it.isNotEmpty() }?.joinToString("\n") + //3. Get the directory & file flags + val dirFlags = DirectoryFlags.fromJson(map["directories"] as? Map) + val fileFlags = FileFlags.fromJson(map["files"] as? Map) + //4. Return the settings + return DocManProviderSettings( + rootPath = map["rootPath"] as? String + ?: throw IllegalArgumentException("DocumentsProvider Root path is missing"), + providerName = map["providerName"] as? String, + providerSubtitle = map["providerSubtitle"] as? String, + mimeTypes = mimeTypes, + showInSystemUI = map["showInSystemUI"] as? Boolean ?: true, + supportRecent = map["supportRecent"] as? Boolean ?: true, + supportSearch = map["supportSearch"] as? Boolean ?: true, + maxRecentFiles = map["maxRecentFiles"] as? Int ?: 15, + maxSearchResults = map["maxSearchResults"] as? Int ?: 10, + directoryFlags = dirFlags, + fileFlags = fileFlags, + ) + } + } + + fun getRootFlags(): Int { + //1. Set the flags, first set the local only flag due to the nature of the provider + var flags = Root.FLAG_LOCAL_ONLY or Root.FLAG_SUPPORTS_IS_CHILD + //2. Add the flags based on the settings + if (directoryFlags.create) { + flags = flags or Root.FLAG_SUPPORTS_CREATE + } + if (supportRecent) { + flags = flags or Root.FLAG_SUPPORTS_RECENTS + } + if (supportSearch) { + flags = flags or Root.FLAG_SUPPORTS_SEARCH + } + if (!showInSystemUI && DocManBuild.supportsRootEmptyFlag()) { + flags = flags or Root.FLAG_EMPTY + } + + return flags + } + + /** Get the document flags by file type & settings */ + fun getDocumentFlags(file: File): Int = if (file.isDirectory) { + getDirectoryFlags(file) + } else { + getDocumentFileFlags(file) + } + + /** Get the flags for the directory */ + private fun getDirectoryFlags(file: File): Int { + var flags = 0 + if (directoryFlags.create && file.canWrite()) { + flags = flags or DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE + } + if (directoryFlags.delete) { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE + } + if (directoryFlags.move && DocManBuild.supportsMoveCopyFlag()) { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE + } + if (directoryFlags.copy && DocManBuild.supportsMoveCopyFlag()) { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY + } + if (directoryFlags.rename) { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME + } + + return flags + } + + /** Get the flags for the document file (file, not directory) */ + private fun getDocumentFileFlags(file: File): Int { + var flags = 0 + if (fileFlags.write) { + flags = DocumentsContract.Document.FLAG_SUPPORTS_WRITE + } + if (fileFlags.delete) { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE + } + if (fileFlags.move && DocManBuild.supportsMoveCopyFlag()) { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE + } + if (fileFlags.move && DocManBuild.supportsMoveCopyFlag()) { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY + } + if (fileFlags.rename) { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME + } + if (fileFlags.thumbnail && file.canThumbnail()) { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL + } + + return flags + } + + fun defaultRootProjection(): Array = arrayOf( + Root.COLUMN_ROOT_ID, + Root.COLUMN_DOCUMENT_ID, + Root.COLUMN_MIME_TYPES, + Root.COLUMN_FLAGS, + Root.COLUMN_ICON, + Root.COLUMN_TITLE, + Root.COLUMN_SUMMARY, + ) + + fun defaultDocumentProjection(): Array = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_ICON, + DocumentsContract.Document.COLUMN_SUMMARY, + ) +} \ No newline at end of file diff --git a/example/README.md b/example/README.md index 385ae7a..90fc46d 100644 --- a/example/README.md +++ b/example/README.md @@ -37,3 +37,13 @@ Demonstrates how to use the docman plugin. | Picked Directory actions | Local Directory actions | |:---------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------:| | | | + +## đŸ—‚ī¸ DocumentsProvider + +| Side menu view in System File Manager | Visibility in Recents | +|:---------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------:| +| | | + +| DocumentsProvider through Intent | DocumentsProvider via File Manager | +|:---------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------:| +| | | diff --git a/example/assets/provider.json b/example/assets/provider.json new file mode 100644 index 0000000..6b5021b --- /dev/null +++ b/example/assets/provider.json @@ -0,0 +1,32 @@ +{ + "rootPath": "nested/provider_folder", + "providerName": "DocMan Example", + "providerSubtitle": "Documents & media files", + "mimeTypes": [ + "image/*" + ], + "extensions": [ + ".pdf", + "mp4" + ], + "showInSystemUI": true, + "supportRecent": true, + "supportSearch": true, + "maxRecentFiles": 20, + "maxSearchResults": 20, + "directories": { + "create": false, + "delete": true, + "move": true, + "rename": true, + "copy": true + }, + "files": { + "delete": true, + "move": true, + "rename": true, + "write": true, + "copy": true, + "thumbnail": true + } +} \ No newline at end of file diff --git a/example/lib/main.dart b/example/lib/main.dart index 61574d9..175f719 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,8 +1,10 @@ import 'package:docman_example/src/doc_man.dart'; import 'package:docman_example/src/utils/app_dir.dart'; +import 'package:docman_example/src/utils/provider_folder_initializer.dart'; import 'package:docman_example/src/utils/router.dart'; import 'package:flutter/material.dart'; +/// Main entry point for the DocMan example app. void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -10,6 +12,8 @@ void main() async { await AppDir().init(); //2. Init router AppRouter().init(); - + //3. Init DocumentsProvider sample folder with media and documents + await ProviderFolderInitializer().init(); + //4. Run the app runApp(const DocManExample()); } diff --git a/example/lib/src/utils/app_dir.dart b/example/lib/src/utils/app_dir.dart index 9c5ba49..7403f7c 100644 --- a/example/lib/src/utils/app_dir.dart +++ b/example/lib/src/utils/app_dir.dart @@ -1,4 +1,4 @@ -import 'dart:io' show Directory; +import 'dart:io' show Directory, Platform; import 'package:docman/docman.dart'; @@ -32,6 +32,9 @@ class AppDir { /// The external directory for application documents (optional). static late Directory? filesExt; + /// The directory used as root for `Documents Provider` on Android. + static late Directory provider; + /// Initializes the application directories. /// /// This method sets up the directories for application documents, temporary files, @@ -45,6 +48,13 @@ class AppDir { data = (await DocMan.dir.data())!; cacheExt = await DocMan.dir.cacheExt(); filesExt = await DocMan.dir.filesExt(); + // Initialize the provider directory for future use in the app + // By this path, you can add files & dirs to the `Documents Provider` + provider = Directory([(filesExt?.path ?? files.path), 'nested/provider_folder'].join(Platform.pathSeparator)); + + //If its nested path create it + await provider.create(recursive: true); + return _instance; } diff --git a/example/lib/src/utils/provider_folder_initializer.dart b/example/lib/src/utils/provider_folder_initializer.dart new file mode 100644 index 0000000..2a04ab9 --- /dev/null +++ b/example/lib/src/utils/provider_folder_initializer.dart @@ -0,0 +1,61 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:docman_example/src/utils/app_dir.dart'; +import 'package:flutter/services.dart'; + +/// This class creates the folder structure for the provider demo +class ProviderFolderInitializer { + final List _mediaAssets = [ + 'assets/images/jpeg_example.jpg', + 'assets/images/webp_example.webp', + 'assets/images/gif_example.gif', + 'assets/images/png_example.png', + 'assets/video/video_sample.mp4' + ]; + + final List _docAssets = ['assets/pdf/example.pdf']; + + final String _providerMediaFolder = 'media'; + final String _providerDocFolder = 'documents'; + + String get _mediaFolderPath => [AppDir.provider.path, _providerMediaFolder].join(Platform.pathSeparator); + + String get _documentsFolderPath => [AppDir.provider.path, _providerDocFolder].join(Platform.pathSeparator); + + Future init() async { + //1. Create the folder if not exists + //2. If the folder exists, return + await AppDir.provider.create(); + //3. Copy the files from the assets folder to the folder + if (AppDir.provider.listSync().isEmpty) await _initSubFolders(); + } + + Future _initSubFolders() async { + //3.2 Copy the media files + await _initSubFolder(_mediaFolderPath, _mediaAssets); + //3.3 Copy the doc files + await _initSubFolder(_documentsFolderPath, _docAssets); + } + + Future _initSubFolder(String folderPath, List assets) async { + //1. Create the folder if not exists + if (!await Directory(folderPath).exists()) await Directory(folderPath).create(); + //2. Copy the media files + for (final asset in assets) { + final extension = asset.split('.').last; + final randomName = String.fromCharCodes(List.generate(10, (index) => Random().nextInt(26) + 97)); + await _getFilePath(asset, [folderPath, '$randomName.$extension'].join(Platform.pathSeparator)); + } + } + + Future _getFilePath(String asset, String dest) async { + final file = File(dest); + if (!(await file.exists())) { + final bytes = (await rootBundle.load(asset)).buffer.asUint8List(); + await file.writeAsBytes(bytes); + } + + return file.path; + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 5ba8169..a7d5b5f 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -47,7 +47,7 @@ packages: path: ".." relative: true source: path - version: "1.0.1" + version: "1.1.0" fake_async: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 9880465..4fdc923 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -25,4 +25,7 @@ flutter: - assets/pdf/ - assets/images/ - assets/video/ + # This is required, if you want to use DocumentsProvider on Android. + # Contains the provider configuration file. + - assets/provider.json uses-material-design: true \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 397b7d2..9039621 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: docman description: Flutter File & Directory Plugin. Easy Android file operations with Storage Access Framework (SAF) API integration. repository: https://github.com/devdfcom/docman issue_tracker: https://github.com/devdfcom/docman/issues -version: 1.0.2 +version: 1.1.0 environment: sdk: ^3.5.2 @@ -30,7 +30,7 @@ topics: - files - picker - file-selection - - storage + - documents-provider screenshots: - description: The docman logo. From 27974b437d7658326c43f286f240f1bc5cc1e831 Mon Sep 17 00:00:00 2001 From: Alex Yackers <7115964+yackers@users.noreply.github.com> Date: Sat, 21 Dec 2024 23:30:06 +0200 Subject: [PATCH 2/2] fixup! feature: `DocumentsProvider` implementation Signed-off-by: Alex Yackers <7115964+yackers@users.noreply.github.com> --- example/lib/src/utils/app_dir.dart | 5 ++++- .../utils/provider_folder_initializer.dart | 21 +++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/example/lib/src/utils/app_dir.dart b/example/lib/src/utils/app_dir.dart index 7403f7c..e44dc12 100644 --- a/example/lib/src/utils/app_dir.dart +++ b/example/lib/src/utils/app_dir.dart @@ -50,7 +50,10 @@ class AppDir { filesExt = await DocMan.dir.filesExt(); // Initialize the provider directory for future use in the app // By this path, you can add files & dirs to the `Documents Provider` - provider = Directory([(filesExt?.path ?? files.path), 'nested/provider_folder'].join(Platform.pathSeparator)); + provider = Directory([ + (filesExt?.path ?? files.path), + 'nested/provider_folder' + ].join(Platform.pathSeparator)); //If its nested path create it await provider.create(recursive: true); diff --git a/example/lib/src/utils/provider_folder_initializer.dart b/example/lib/src/utils/provider_folder_initializer.dart index 2a04ab9..2c26696 100644 --- a/example/lib/src/utils/provider_folder_initializer.dart +++ b/example/lib/src/utils/provider_folder_initializer.dart @@ -19,16 +19,20 @@ class ProviderFolderInitializer { final String _providerMediaFolder = 'media'; final String _providerDocFolder = 'documents'; - String get _mediaFolderPath => [AppDir.provider.path, _providerMediaFolder].join(Platform.pathSeparator); + String get _mediaFolderPath => + [AppDir.provider.path, _providerMediaFolder].join(Platform.pathSeparator); - String get _documentsFolderPath => [AppDir.provider.path, _providerDocFolder].join(Platform.pathSeparator); + String get _documentsFolderPath => + [AppDir.provider.path, _providerDocFolder].join(Platform.pathSeparator); Future init() async { //1. Create the folder if not exists //2. If the folder exists, return await AppDir.provider.create(); //3. Copy the files from the assets folder to the folder - if (AppDir.provider.listSync().isEmpty) await _initSubFolders(); + if (AppDir.provider.listSync().isEmpty) { + await _initSubFolders(); + } } Future _initSubFolders() async { @@ -40,12 +44,17 @@ class ProviderFolderInitializer { Future _initSubFolder(String folderPath, List assets) async { //1. Create the folder if not exists - if (!await Directory(folderPath).exists()) await Directory(folderPath).create(); + if (!await Directory(folderPath).exists()) { + await Directory(folderPath).create(); + } + //2. Copy the media files for (final asset in assets) { final extension = asset.split('.').last; - final randomName = String.fromCharCodes(List.generate(10, (index) => Random().nextInt(26) + 97)); - await _getFilePath(asset, [folderPath, '$randomName.$extension'].join(Platform.pathSeparator)); + final randomName = String.fromCharCodes( + List.generate(10, (index) => Random().nextInt(26) + 97)); + await _getFilePath(asset, + [folderPath, '$randomName.$extension'].join(Platform.pathSeparator)); } }