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..e44dc12 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,16 @@ 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..2c26696
--- /dev/null
+++ b/example/lib/src/utils/provider_folder_initializer.dart
@@ -0,0 +1,70 @@
+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.