diff --git a/.gitignore b/.gitignore index 2a3bafc..f55a11c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ migrate_working_dir/ # IntelliJ related *.iml -*.ipr + *.ipr + *.iws .idea/ @@ -24,3 +25,5 @@ migrate_working_dir/ build/ #Custom .devices/ + +.cursor/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 027bcc2..35be866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,89 +1,102 @@ +## 1.2.1 + +- **Bug Fix**: Fixed issue where some SAF providers (like Termux) strip file extensions from `DocumentFile.name`. + - Added `displayName` property to expose the raw provider display name + - Added `fileName` property for computed filename with extension + - The `name` property now automatically preserves file extensions by computing them from MIME type when needed + - This ensures consistent behavior across all SAF providers + ## 1.2.0 -* New Feature: Added method `all()` for `DocMan.dir` helper. +- New Feature: Added method `all()` for `DocMan.dir` helper. It's a simple way to get all app directories in one call as map. - ```dart + ```dart /// Get all app directories as map with keys as directory names and values as paths. final Map dirs = await DocMan.dir.all(); - - ///Result example: - { - "cache": "/data/user/0/com.example.app/cache", - "files": "/data/user/0/com.example.app/files", - "data": "/data/user/0/com.example.app/app_flutter", - // External directories, can be empty strings if not available. - "cacheExt": "/storage/emulated/0/Android/data/com.example.app/cache", - "filesExt": "/storage/emulated/0/Android/data/com.example.app/files", + + ///Result example: + { + "cache": "/data/user/0/com.example.app/cache", + "files": "/data/user/0/com.example.app/files", + "data": "/data/user/0/com.example.app/app_flutter", + // External directories, can be empty strings if not available. + "cacheExt": "/storage/emulated/0/Android/data/com.example.app/cache", + "filesExt": "/storage/emulated/0/Android/data/com.example.app/files", } - ``` -* Feature: Added ability to instantiate `DocumentThumbnail` from `Content Uri` or `File.path`. + ``` + +- Feature: Added ability to instantiate `DocumentThumbnail` from `Content Uri` or `File.path`. Same as `DocumentFile.thumbnail()` method, but now you can get `DocumentThumbnail` directly. - ```dart + ```dart /// Create thumbnail from content uri or file path. final DocumentThumbnail thumbnail = await DocumentThumbnail.fromUri( - contentUriOrFilePath, - width: 100, - height: 100, - png: true, + contentUriOrFilePath, + width: 100, + height: 100, + png: true, ); - ``` -* Feature: Added syntax sugar for `DocumentFile` instantiation from `Content Uri` or `File.path`. + ``` + +- Feature: Added syntax sugar for `DocumentFile` instantiation from `Content Uri` or `File.path`. - ```dart + ```dart /// New syntax sugar for DocumentFile instantiation from content uri or file path. final DocumentFile? doc = await DocumentFile.fromUri(contentUriOrFilePath); - + /// Old way. final DocumentFile? doc = await DocumentFile(contentUriOrFilePath).get(); - ``` -* Fix: problem with parallel calls to `DocumentFile` `action` methods, when working with different + ``` + +- Fix: problem with parallel calls to `DocumentFile` `action` methods, when working with different documents. Now it's fixed. `DocManQueueManager` was primarily used for all `activity` methods, and it caused the problem. For example, now it's possible to create list or grid of documents thumbnails without any problems. If you were getting errors in log like `Error loading thumbnail: AlreadyRunning Method: documentfileaction`, it should be fixed now. -* Fix: error in syntax in methods `DocumentFile.share()` & `DocumentFile.open()`. +- Fix: error in syntax in methods `DocumentFile.share()` & `DocumentFile.open()`. String `title` parameter was not optional, but it should be. Now it's fixed. Please check your code and change `title` parameter to optional if you are using these methods. **From:** + ```dart final bool share = await doc.share('Share this document:'); final bool open = await doc.open('Open with:'); ``` **To:** - ```dart - final bool share = await doc.share(title: 'Share this document:'); - final bool open = await doc.open(title: 'Open with:'); - ``` -* Chore: Updated dependencies, updated example, updated README, some code cleanup & small fixes. + ```dart + final bool share = await doc.share(title: 'Share this document:'); + final bool open = await doc.open(title: 'Open with:'); + ``` + +- Chore: Updated dependencies, updated example, updated README, some code cleanup & small fixes. ## 1.1.0 -* New Feature: Implemented simple custom `DocumentsProvider` -* Small fixes -* Updated README -* Updated example -* Updated dependencies +- New Feature: Implemented simple custom `DocumentsProvider` +- Small fixes +- Updated README +- Updated example +- Updated dependencies ## 1.0.2 -* Fix: missed export of `PermissionsException` class -* Example: updated deprecated `withOpacity` to `withAlpha` -* Documentation fixes +- Fix: missed export of `PermissionsException` class +- Example: updated deprecated `withOpacity` to `withAlpha` +- Documentation fixes ## 1.0.1 -* Readme fixes -* Screenshot -* Workflow fixes +- Readme fixes +- Screenshot +- Workflow fixes ## 1.0.0 -* DocMan initial release +- DocMan initial release diff --git a/README.md b/README.md index cf8925c..d2d0b06 100644 --- a/README.md +++ b/README.md @@ -70,37 +70,37 @@ Future dirOperationsExample() async { **API documentation** is available at [pub.dev](https://pub.dev/documentation/docman/latest/). All public classes and methods are well-documented. -**Note:** To try the demos shown in the images run the [***example***](/example) included in this plugin. +**Note:** To try the demos shown in the images run the [**_example_**](/example) included in this plugin. ### Table of Contents 1. đŸ› ī¸ [**Installation**](#installation) -2. 👆 [**Picker**](#-picker) (đŸ–ŧī¸ [*see examples*](#picker-examples)) - - [Pick directory](#pick-directory) - - [Pick documents](#pick-documents) - - [Pick files](#pick-files) - - [Pick visualMedia](#pick-visualmedia) -3. 📂 [**App Directories**](#-app-directories) (đŸ–ŧī¸ [*see examples*](#app-directories-examples)) - - [Supported app directories](#supported-app-directories) - - [Get all directories at once](#get-all-directories-at-once) - - â™ģī¸ [Plugin Cache cleaner](#plugin-cache-cleaner) -4. đŸ›Ąī¸ [**Persisted permissions**](#persisted-permissions) (đŸ–ŧī¸ [*see examples*](#persisted-permissions-examples)) - - [PersistedPermission data class](#persistedpermission-class) - - [List/Stream permissions](#list--stream-permissions) - - [List/Stream Documents with permissions](#list--stream-documents-with-permissions) - - [Release & Release all actions](#release--release-all-actions) - - [Get Uri permission status](#get-uri-permission-status) - - â™ģī¸ [Validate permissions](#validate-permissions) -5. 📄 [**DocumentFile**](#-documentfile) (đŸ–ŧī¸ [*see examples*](#documentfile-examples)) - - [Instantiate DocumentFile](#instantiate-documentfile) - - [Activity methods](#documentfile-activity-methods) - - [Events / Stream methods](#documentfile-events--stream-methods) - - [Action methods](#documentfile-action-methods) - - 🧩 [DocumentThumbnail](#-documentthumbnail-class) - - [Unsupported methods](#unsupported-methods) -6. đŸ—‚ī¸ [**DocumentsProvider**](#documents-provider) (đŸ–ŧī¸ [*see examples*](#documents-provider-examples)) - - [Setup DocumentsProvider](#setup-documentsprovider) - - [DocumentsProvider example](#provider-json-example) +2. 👆 [**Picker**](#-picker) (đŸ–ŧī¸ [_see examples_](#picker-examples)) + - [Pick directory](#pick-directory) + - [Pick documents](#pick-documents) + - [Pick files](#pick-files) + - [Pick visualMedia](#pick-visualmedia) +3. 📂 [**App Directories**](#-app-directories) (đŸ–ŧī¸ [_see examples_](#app-directories-examples)) + - [Supported app directories](#supported-app-directories) + - [Get all directories at once](#get-all-directories-at-once) + - â™ģī¸ [Plugin Cache cleaner](#plugin-cache-cleaner) +4. đŸ›Ąī¸ [**Persisted permissions**](#persisted-permissions) (đŸ–ŧī¸ [_see examples_](#persisted-permissions-examples)) + - [PersistedPermission data class](#persistedpermission-class) + - [List/Stream permissions](#list--stream-permissions) + - [List/Stream Documents with permissions](#list--stream-documents-with-permissions) + - [Release & Release all actions](#release--release-all-actions) + - [Get Uri permission status](#get-uri-permission-status) + - â™ģī¸ [Validate permissions](#validate-permissions) +5. 📄 [**DocumentFile**](#-documentfile) (đŸ–ŧī¸ [_see examples_](#documentfile-examples)) + - [Instantiate DocumentFile](#instantiate-documentfile) + - [Activity methods](#documentfile-activity-methods) + - [Events / Stream methods](#documentfile-events--stream-methods) + - [Action methods](#documentfile-action-methods) + - 🧩 [DocumentThumbnail](#-documentthumbnail-class) + - [Unsupported methods](#unsupported-methods) +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) @@ -131,14 +131,13 @@ Allows picking a directory from the device storage. You can specify the initial > [!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: +> 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) -> [!NOTE] -> `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 @@ -217,17 +216,19 @@ Future> pickVisualMedia() => ``` --- + +
đŸ–ŧī¸ Picker examples (click for expand/collapse) | Picking directory | Picking documents | -|:---------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------:| +| :-------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------: | | | | | Picking files | Picking visualMedia | -|:---------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------:| +| :-------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------: | | | |
@@ -311,13 +312,15 @@ Future clearPluginCache() => DocMan.dir.clearCache(); ``` --- + +
đŸ–ŧī¸ App Directories examples (click for expand/collapse) | Get directories | -|:---------------------------------------------------------------------------------------------:| +| :-------------------------------------------------------------------------------------------: | | |
@@ -338,8 +341,9 @@ or use the helper: `DocMan.perms` to manage permissions. > [!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 +> +> - Limited to **128** permissions per app for **Android 10** and below +> - Limited to **512** permissions per app for **Android 11** and above #### **PersistedPermission class** @@ -469,13 +473,15 @@ Future validatePermissions() => DocMan.perms.validateList(); ``` --- + +
đŸ–ŧī¸ Persisted permissions examples (click for expand/collapse) | List/Stream Permissions | List/Stream Documents | -|:---------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------:| +| :-------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------: | | | |
@@ -520,7 +526,7 @@ There are two ways to instantiate a `DocumentFile`: Future file() => DocumentFile(uri: 'path/to/file.jpg').get(); ///Or you can use static method fromUri Future file() => DocumentFile.fromUri('path/to/file.jpg'); - + /// If directory doesn't exist, it will create all directories in the path. Future dir() => DocumentFile(uri: 'path/to/some/directory/notCreatedYet').get(); ///Or you can use static method fromUri @@ -538,33 +544,33 @@ Like `open`, `share`, `saveTo` methods. All methods are called through Activity the system will show a dialog to choose the app to open with. Action can be performed only on file & file must exist. - * `title` parameter is optional, and not working on all devices, depends on the system. + - `title` parameter is optional, and not working on all devices, depends on the system. - ```dart - Future openFile(DocumentFile file) => file.open(title: 'Open with:'); //or file.open(); - ``` + ```dart + Future openFile(DocumentFile file) => file.open(title: 'Open with:'); //or file.open(); + ``` - `share` `📄` Share the file with other apps. - * `title` parameter is optional, and not working on all devices, depends on the system. + - `title` parameter is optional, and not working on all devices, depends on the system. - ```dart - Future shareFile(DocumentFile file) => file.share(title: 'Share with:'); //or file.share(); - ``` + ```dart + Future shareFile(DocumentFile file) => file.share(title: 'Share with:'); //or file.share(); + ``` - `saveTo` `📄` Save the file to the selected directory. You can specify the initial directory to start from, whether to show only local directories or not, and delete the original file after saving. After saving, the method returns the saved `DocumentFile`. - ```dart - Future saveFile(DocumentFile file) => - file.saveTo( - initDir: 'content uri to start from', //optional - localOnly: true, - deleteSource: true, - ); - ``` + ```dart + Future saveFile(DocumentFile file) => + file.saveTo( + initDir: 'content uri to start from', //optional + localOnly: true, + deleteSource: true, + ); + ``` #### **DocumentFile Events / Stream methods** @@ -578,10 +584,10 @@ if it's a file, you can read it via stream as bytes or string. Can be used only on file & file must exist. You can specify the encoding of the file content, buffer size or set the start position to read from. - ```dart - Stream readAsString(DocumentFile file) => - file.readAsString(charset: 'UTF-8', bufferSize: 1024, start: 0); - ``` + ```dart + Stream readAsString(DocumentFile file) => + file.readAsString(charset: 'UTF-8', bufferSize: 1024, start: 0); + ``` @@ -590,10 +596,10 @@ if it's a file, you can read it via stream as bytes or string. Can be used only on file & file must exist. You can specify the buffer size or set the start position to read from. - ```dart - Stream readAsBytes(DocumentFile file) => - file.readAsBytes(bufferSize: (1024 * 8), start: 0); - ``` + ```dart + Stream readAsBytes(DocumentFile file) => + file.readAsBytes(bufferSize: (1024 * 8), start: 0); + ``` @@ -603,10 +609,10 @@ if it's a file, you can read it via stream as bytes or string. You can specify the mimeTypes & extensions filter, to filter the documents by type, or filter documents by string in name. - ```dart - Stream listDocumentsStream(DocumentFile dir) => - dir.listDocumentsStream(mimeTypes: ['application/pdf'], extensions: ['pdf', '.docx'], nameContains: 'doc_'); - ``` + ```dart + Stream listDocumentsStream(DocumentFile dir) => + dir.listDocumentsStream(mimeTypes: ['application/pdf'], extensions: ['pdf', '.docx'], nameContains: 'doc_'); + ``` #### **DocumentFile Action methods** @@ -617,29 +623,28 @@ and can be performed in the background (with isolates or WorkManager). Returns [PersistedPermission](#persistedpermission-class) instance or `null` if there are no persisted permissions. - ```dart - Future getPermissions(DocumentFile file) => file.permissions(); - ``` + ```dart + Future getPermissions(DocumentFile file) => file.permissions(); + ``` - `read` `📄` Read the entire file content as bytes. Can be used only on file & file must exist. - ```dart - Future readBytes(DocumentFile file) => file.read(); - ``` + ```dart + Future readBytes(DocumentFile file) => file.read(); + ``` â„šī¸ 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. Can be used only on directory & directory must exist & has write permission & flag `canCreate` is `true`. Returns the created `DocumentFile` directory. - ```dart - Future createDir(DocumentFile dir) => dir.createDirectory('new_directory'); - ``` + ```dart + Future createDir(DocumentFile dir) => dir.createDirectory('new_directory'); + ``` - `createFile` `📁` Create a new file with the specified name & content in the directory. @@ -650,15 +655,15 @@ and can be performed in the background (with isolates or WorkManager). Example: `.txt` -> `docman_file_18028.txt`. Returns the created `DocumentFile` file. - ```dart - /// Create a new file with the specified name & String content in the directory. - Future createFile(DocumentFile dir) => - dir.createFile(name: '.txt', content: 'Hello World!'); - - /// Create a new file with the specified name & bytes content in the directory. - Future createFileFromBytes(DocumentFile dir) => - dir.createFile(name: 'test Document.pdf', bytes: Uint8List.fromList([1, 2, 3, 4, 5])); - ``` + ```dart + /// Create a new file with the specified name & String content in the directory. + Future createFile(DocumentFile dir) => + dir.createFile(name: '.txt', content: 'Hello World!'); + + /// Create a new file with the specified name & bytes content in the directory. + Future createFileFromBytes(DocumentFile dir) => + dir.createFile(name: 'test Document.pdf', bytes: Uint8List.fromList([1, 2, 3, 4, 5])); + ``` - `listDocuments` `📁` List the documents in the directory. @@ -666,24 +671,23 @@ and can be performed in the background (with isolates or WorkManager). You can specify the mimeTypes & extensions filter, to filter the documents by type, or filter documents by string in name. - ```dart - Future> listDocuments(DocumentFile dir) => - dir.listDocuments(mimeTypes: ['application/pdf'], extensions: ['pdf', '.docx'], nameContains: 'doc_'); - ``` + ```dart + Future> listDocuments(DocumentFile dir) => + dir.listDocuments(mimeTypes: ['application/pdf'], extensions: ['pdf', '.docx'], nameContains: 'doc_'); + ``` â„šī¸ This method returns all documents in the directory, if list has many items, it's better to use stream-based method [listDocumentsStream](#list-documents-stream). - - `find` `📁` Find the document in the directory by name. Can be used only on directory & directory must exist. Search through `listDocuments` for the first document exact matching the given name. Returns null when no matching document is found. - ```dart - Future findDocument(DocumentFile dir) => dir.find('file_name.jpg'); - ``` + ```dart + Future findDocument(DocumentFile dir) => dir.find('file_name.jpg'); + ``` - `delete` `📁` `📄` Delete the file or directory. Can be used on both file & directory. @@ -691,22 +695,24 @@ and can be performed in the background (with isolates or WorkManager). If the document is a directory, it will delete all content recursively. Returns `true` if the document was deleted. - ```dart - Future deleteFile(DocumentFile file) => file.delete(); - Future deleteDir(DocumentFile dir) => dir.delete(); - ``` + ```dart + Future deleteFile(DocumentFile file) => file.delete(); + Future deleteDir(DocumentFile dir) => dir.delete(); + ``` + - `cache` `📄` Copy the file to the cache directory (external if available, internal otherwise). If file with same name already exists in cache, it will be overwritten. Works only if the document exists & has permission to read. Returns `File` instance of the cached file. - ```dart + ```dart /// For all types of files - Future cacheFile(DocumentFile file) => file.cache(); + Future cacheFile(DocumentFile file) => file.cache(); /// If file is image (jpg, png, webp) you can specify the quality of the image - Future cacheImage(DocumentFile file) => file.cache(imageQuality: 70); - ``` + Future cacheImage(DocumentFile file) => file.cache(imageQuality: 70); + ``` + - `copyTo` `📄` Copy the file to the specified directory. File must exist & have flag `canRead` set to `true`. @@ -719,7 +725,7 @@ and can be performed in the background (with isolates or WorkManager). ///Copy file to the the directory `DocumentFile` instance with persisted permission uri Future copyFile(DocumentFile file) => file.copyTo('content://com.android.externalstorage.documents/tree/primary%3ADocMan', name: 'my new file copy'); - + ///Copy file to the the local app directory `Directory.path` Future copyFileToLocalDir(DocumentFile file) => file.copyTo('/data/user/0/devdf.plugins.docman_example/app_flutter/myDocs', name: 'test_file.txt'); @@ -736,15 +742,15 @@ and can be performed in the background (with isolates or WorkManager). Returns the `DocumentFile` instance of the moved file. After moving the file, the original file will be deleted. - ```dart + ```dart ///Move file to the the directory `DocumentFile` instance with persisted permission uri Future moveFile(DocumentFile file) => - file.moveTo('content://com.android.externalstorage.documents/tree/primary%3ADocMan', name: 'moved file name'); - + file.moveTo('content://com.android.externalstorage.documents/tree/primary%3ADocMan', name: 'moved file name'); + ///Move file to the the local app directory `Directory.path` Future moveFileToLocalDir(DocumentFile file) => - file.moveTo('/data/user/0/devdf.plugins.docman_example/cache/TempDir', name: 'moved_file.txt'); - ``` + file.moveTo('/data/user/0/devdf.plugins.docman_example/cache/TempDir', name: 'moved_file.txt'); + ``` @@ -757,9 +763,9 @@ and can be performed in the background (with isolates or WorkManager). not available. Commonly used for images, videos, pdfs. - ```dart - Future thumbnail(DocumentFile file) => file.thumbnail(width: 256, height: 256, quality: 70); - ``` + ```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. @@ -774,9 +780,9 @@ and can be performed in the background (with isolates or WorkManager). Same as `thumbnail` method, but returns the thumbnail image as a `File` instance, saved in the cache directory. First it will try to save to external cache directory, if not available, then to internal cache directory. - ```dart - Future thumbnailFile(DocumentFile file) => file.thumbnailFile(width: 192, height: 192, webp: true); - ``` + ```dart + Future thumbnailFile(DocumentFile file) => file.thumbnailFile(width: 192, height: 192, webp: true); + ``` #### 🧩 **DocumentThumbnail class** @@ -808,6 +814,7 @@ Information about currently (temporarily) unsupported methods in the plugin. > so it's better to use `copy` & `delete` actions instead. --- +
@@ -815,11 +822,11 @@ Information about currently (temporarily) unsupported methods in the plugin. đŸ–ŧī¸ DocumentFile examples (click for expand/collapse) | Local file activity | Picked File actions | -|:---------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------:| +| :-------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------: | | | | | Picked Directory actions | Local Directory actions | -|:---------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------:| +| :-------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------: | | | |
@@ -851,27 +858,28 @@ that will be accessible by other apps. You can customize it, and set the permiss 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 - ``` + ```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***, +> 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. + 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" @@ -902,7 +910,9 @@ All configuration is stored in the `assets/provider.json` file. "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"] @@ -915,12 +925,11 @@ All configuration is stored in the `assets/provider.json` file. > [!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, +> `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***. +> [!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. @@ -956,7 +965,7 @@ Section for directories in the `provider.json` file: "copy": true } } -``` +``` đŸŗī¸ **Supported flags for files:** @@ -970,10 +979,10 @@ Section for directories in the `provider.json` file: - `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()`. + - 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: @@ -1001,6 +1010,7 @@ or short version, if all actions are supported: ``` +
đŸ—’ī¸ Full Example of the `provider.json` (click for expand/collapse) @@ -1010,13 +1020,8 @@ or short version, if all actions are supported: "rootPath": "nested/provider_folder", "providerName": "DocMan Example", "providerSubtitle": "Documents & media files", - "mimeTypes": [ - "image/*" - ], - "extensions": [ - ".pdf", - "mp4" - ], + "mimeTypes": ["image/*"], + "extensions": [".pdf", "mp4"], "showInSystemUI": true, "supportRecent": true, "supportSearch": true, @@ -1042,19 +1047,18 @@ or short version, if all actions are supported:
- - +
đŸ–ŧī¸ DocumentsProvider examples (click for expand/collapse) | Side menu view in System File Manager | Visibility in Recents | -|:---------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------:| +| :-------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------: | | | | | DocumentsProvider through Intent | DocumentsProvider via File Manager | -|:---------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------:| +| :-------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------: | | | |
@@ -1088,7 +1092,7 @@ Common exceptions for all channels: - `PickerMaxLimitException` Thrown for `DocMan.pick.visualMedia()` method. When `limit` parameter is greater than max allowed by the platform, currently it - uses [MediaStore.getPickImagesMaxLimit](https://developer.android.com/reference/android/provider/MediaStore#getPickImagesMaxLimit()) + uses [MediaStore.getPickImagesMaxLimit]() on supported devices (Android 11 & above), otherwise it forces the limit to 100. - `PickerCountException` Thrown when you set picker parameter `limitResultCancel` to `true`. diff --git a/android/src/main/kotlin/devdf/plugins/docman/DocManDocumentsProvider.kt b/android/src/main/kotlin/devdf/plugins/docman/DocManDocumentsProvider.kt index e0471c7..f715f08 100644 --- a/android/src/main/kotlin/devdf/plugins/docman/DocManDocumentsProvider.kt +++ b/android/src/main/kotlin/devdf/plugins/docman/DocManDocumentsProvider.kt @@ -220,7 +220,20 @@ class DocManDocumentsProvider : DocumentsProvider() { 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) + + // Preserve extension if displayName doesn't have one and source file is not a directory + val finalDisplayName = if (!sourceFile.isDirectory && !displayName.contains('.')) { + val sourceExtension = sourceFile.extension + if (sourceExtension.isNotEmpty()) { + "$displayName.$sourceExtension" + } else { + displayName + } + } else { + displayName + } + + val destFile = sourceParentFile.resolve(finalDisplayName) try { if (!sourceFile.renameTo(destFile)) diff --git a/android/src/main/kotlin/devdf/plugins/docman/channels/DocManEvents.kt b/android/src/main/kotlin/devdf/plugins/docman/channels/DocManEvents.kt index 1e2099f..960241b 100644 --- a/android/src/main/kotlin/devdf/plugins/docman/channels/DocManEvents.kt +++ b/android/src/main/kotlin/devdf/plugins/docman/channels/DocManEvents.kt @@ -33,7 +33,7 @@ class DocManEvents(private val plugin: DocManPlugin) : EventsBase { return } //4. Setting scope block - block = when (DocManMethod.fromMethodName(method)) { + val eventBlock = when (DocManMethod.fromMethodName(method)) { DocManMethod.DocumentFileEvent -> DocumentFileEvent(plugin, call, eventSink) DocManMethod.PermissionsEvent -> PermissionsEvents(plugin, call, eventSink) //Return not implemented error @@ -42,13 +42,16 @@ class DocManEvents(private val plugin: DocManPlugin) : EventsBase { return } } + block = eventBlock //5. Setting the job - job = CoroutineScope(Dispatchers.IO).launch { block!!.onListen() } + job = CoroutineScope(Dispatchers.IO).launch { eventBlock.onListen() } //6. Setting the job completion job?.invokeOnCompletion { - job = null - block = null + if (block == eventBlock) { + job = null + block = null + } } } diff --git a/android/src/main/kotlin/devdf/plugins/docman/extensions/DocumentFileExt.kt b/android/src/main/kotlin/devdf/plugins/docman/extensions/DocumentFileExt.kt index c18243d..a0fb643 100644 --- a/android/src/main/kotlin/devdf/plugins/docman/extensions/DocumentFileExt.kt +++ b/android/src/main/kotlin/devdf/plugins/docman/extensions/DocumentFileExt.kt @@ -36,7 +36,9 @@ import java.io.FileNotFoundException fun DocumentFile.toMapResult(context: Context): Map? { return when { isDirectory || isFile -> mapOf( - "name" to name, + "name" to getComputedName(context), + "displayName" to name, + "fileName" to getComputedName(context), "type" to when { isDirectory -> "directory" else -> type @@ -277,6 +279,33 @@ fun DocumentFile.getFileExtension(context: Context): String { fun DocumentFile.nameContains(str: String): Boolean = name?.contains(str, ignoreCase = true) ?: str.isEmpty() +/** Get the computed name of the [DocumentFile] that preserves file extensions + * + * This method addresses the issue where some SAF providers (like Termux) strip file extensions + * from the display name. It computes a proper filename that includes the extension when needed. + * + * @param context The context of the application + * @return The computed name with extension preserved when possible + */ +fun DocumentFile.getComputedName(context: Context): String { + val displayName = name ?: return "unknown" + + // If it's a directory, return the display name as-is + if (isDirectory) return displayName + + // If the display name already has an extension, return it as-is + if (displayName.contains('.')) return displayName + + // For files without extension in display name, try to compute the extension from MIME type + val extension = getFileExtension(context) + + return if (extension.isNotEmpty()) { + "$displayName.$extension" + } else { + displayName + } +} + /** Get the name of the [DocumentFile] as a file name * * @return The name of the [DocumentFile] as a file name, @@ -373,6 +402,33 @@ suspend fun DocumentFile.writeContent( context: Context ) = DocManFiles.writeContentToDocumentFile(this, content, context) +/** Overwrite an existing file represented by this [DocumentFile]. + * + * Must be called from a coroutine. + * + * @param content The content to write into the file + * @param context The context of the application + * @return The same [DocumentFile] instance after the write operation completes + * @throws IllegalStateException if the document is not an existing file + * or if it cannot be written to + * + * @see writeContent + */ +suspend fun DocumentFile.writeFile( + content: ByteArray, + context: Context +): DocumentFile { + if (!isFile || !exists()) { + throw IllegalStateException("DocumentFile does not reference an existing file.") + } + if (!canWrite() && !isWritable(context)) { + throw IllegalStateException("DocumentFile is not writable.") + } + + writeContent(content, context) + return this +} + /** Copy the file [DocumentFile] to directory [DocumentFile]. * * Must be called from a coroutine. diff --git a/android/src/main/kotlin/devdf/plugins/docman/methods/DocumentFileAction.kt b/android/src/main/kotlin/devdf/plugins/docman/methods/DocumentFileAction.kt index 25d8165..c19b34c 100644 --- a/android/src/main/kotlin/devdf/plugins/docman/methods/DocumentFileAction.kt +++ b/android/src/main/kotlin/devdf/plugins/docman/methods/DocumentFileAction.kt @@ -1,6 +1,7 @@ package devdf.plugins.docman.methods import android.util.Size +import android.util.Log import androidx.documentfile.provider.DocumentFile import devdf.plugins.docman.DocManPlugin import devdf.plugins.docman.definitions.ActionMethodBase @@ -22,6 +23,7 @@ import devdf.plugins.docman.extensions.toDocumentFile import devdf.plugins.docman.extensions.toMapResult import devdf.plugins.docman.extensions.toUri import devdf.plugins.docman.extensions.writeContent +import devdf.plugins.docman.extensions.writeFile import devdf.plugins.docman.utils.BitmapCompressFormat import devdf.plugins.docman.utils.DocManFiles import devdf.plugins.docman.utils.DocManMimeType @@ -60,6 +62,7 @@ class DocumentFileAction( "read" -> readDocument() "createDirectory" -> createDirectory() "createFile" -> createFile() + "write" -> writeFile() "list" -> listDocuments() "find" -> findFile() "cache" -> cacheDocument() @@ -115,31 +118,26 @@ class DocumentFileAction( } private fun createFile() { - val (baseName, extension) = call.argument("name")?.split(".")?.let { list -> - (list.firstOrNull()?.takeIf { it.isNotEmpty() }?.asFileName() - ?: DocManFiles.genFileName()) to list.getOrNull(1) - } ?: (DocManFiles.genFileName() to null) - - //1. Return error if extension is not provided - if (extension.isNullOrEmpty()) return onError("Extension is required in file name") - //2. Detect the mime type - //TODO: what if user wants to use custom extension or mime type? Example: '.bck' -> 'application/backup' - val mimeType = DocManMimeType.fromExtension(extension) - ?: return onError("Cannot detect mime type for extension $extension") - //3. Get the content + val fileNameArgument = call.argument("name")?.trim() + val (displayName, extension) = resolveFileName(fileNameArgument) ?: return + + //1. Detect the mime type, use default if not detected + val mimeType = DocManMimeType.fromExtension(extension.lowercase()) + ?: "application/octet-stream" + //2. Get the content val content = call.argument("content") ?: return onError("Content is required") - //4. Check if directory is not app directory and can't create file + //3. Check if directory is not app directory and can't create file if (!doc.isAppDir(plugin.context) && !doc.canCreate(plugin.context)) { return onError("Cannot create file in this directory") } CoroutineScope(Dispatchers.IO).launch { try { - //5. Create the file - val resultDoc = doc.createFile(mimeType, baseName) - //6. Write the content + //4. Create the file + val resultDoc = doc.createFile(mimeType, displayName) + //5. Write the content resultDoc?.writeContent(content, plugin.context) - //7. Return the result + //6. Return the result success(resultDoc?.toMapResult(plugin.context)) } catch (e: Exception) { onError(e.message) @@ -147,6 +145,50 @@ class DocumentFileAction( } } + private fun resolveFileName(name: String?): Pair? { + val rawName = name?.takeIf { it.isNotEmpty() } + ?: run { + onError("Invalid or empty file name") + return null + } + + val sanitizedName = if (doc.isAppDir(plugin.context)) rawName.asFileName() else rawName + val lastDotIndex = sanitizedName.lastIndexOf('.') + + // Allow files without extensions + val extension = if (lastDotIndex > 0 && lastDotIndex != sanitizedName.lastIndex) { + sanitizedName.substring(lastDotIndex + 1) + } else { + "" // No extension + } + + val baseName = if (lastDotIndex > 0) { + sanitizedName.substring(0, lastDotIndex) + } else { + sanitizedName + }.takeIf { it.isNotEmpty() } ?: DocManFiles.genFileName() + + val displayName = if (extension.isNotEmpty()) "$baseName.$extension" else baseName + return displayName to extension + } + + private fun writeFile() { + if (!doc.isFile) return onError("Document is not a file") + if (!doc.exists()) return onError("Document does not exist") + if (!doc.canWrite()) return onError("Document is not writable") + + val content = call.argument("content") ?: return onError("Content is required") + + CoroutineScope(Dispatchers.IO).launch { + try { + doc.writeFile(content, plugin.context) + success(doc.toMapResult(plugin.context)) + } catch (e: Exception) { + onError(e.message) + } + } + } + private fun listDocuments() { //1. Check if the directory is valid @@ -202,9 +244,10 @@ class DocumentFileAction( private fun copyTo() { //1. Validate the source document & destination directory val destDoc = validateCopyMove() ?: return - //2. Check new name - val name = call.argument("name")?.substringBefore(".")?.asFileName() - ?: doc.getBaseName() + //2. Check new name - preserve full filename with extension + val name = call.argument("name")?.trim()?.takeIf { it.isNotEmpty() }?.let { + if (destDoc.isAppDir(plugin.context)) it.asFileName() else it + } //3. Copy document to destination directory CoroutineScope(Dispatchers.IO).launch { try { @@ -219,9 +262,10 @@ class DocumentFileAction( private fun moveTo() { //1. Validate the source document & destination directory val destDoc = validateCopyMove() ?: return - //2. Check new name - val name = call.argument("name")?.substringBefore(".")?.asFileName() - ?: doc.getBaseName() + //2. Check new name - preserve full filename with extension + val name = call.argument("name")?.trim()?.takeIf { it.isNotEmpty() }?.let { + if (destDoc.isAppDir(plugin.context)) it.asFileName() else it + } //3. Move document to destination directory CoroutineScope(Dispatchers.IO).launch { try { 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 2f3eb59..2ef7962 100644 --- a/android/src/main/kotlin/devdf/plugins/docman/utils/DocManFiles.kt +++ b/android/src/main/kotlin/devdf/plugins/docman/utils/DocManFiles.kt @@ -13,7 +13,9 @@ import androidx.documentfile.provider.DocumentFile import devdf.plugins.docman.extensions.activityUri import devdf.plugins.docman.extensions.canDelete import devdf.plugins.docman.extensions.getBaseName +import devdf.plugins.docman.extensions.getComputedName import devdf.plugins.docman.extensions.getFileExtension +import devdf.plugins.docman.extensions.isAppDir import devdf.plugins.docman.extensions.isAppFile import devdf.plugins.docman.extensions.isImage import devdf.plugins.docman.extensions.isMediaMimeType @@ -193,9 +195,27 @@ class DocManFiles { ): DocumentFile? = withContext(Dispatchers.IO) { //1. Create the target file var targetDoc: DocumentFile? = null - //2. Copy the file + //2. Determine the proper file name with extension + val fullName = name ?: doc.getComputedName(context) + + // For app directories (file://), use baseName to avoid double extension + // For SAF providers (content://), use fullName to preserve extension + val displayName = if (targetDir.isAppDir(context)) { + // Extract baseName for app directories to prevent double extension + val lastDotIndex = fullName.lastIndexOf('.') + if (lastDotIndex > 0 && lastDotIndex != fullName.lastIndex) { + fullName.substring(0, lastDotIndex) + } else { + fullName + } + } else { + // Use fullName for SAF providers to preserve extension + fullName + } + + //3. Copy the file runCatching { - targetDoc = targetDir.createFile(doc.type!!, name ?: doc.getBaseName())?.apply { + targetDoc = targetDir.createFile(doc.type!!, displayName)?.apply { context.contentResolver.openInputStream(doc.uri)?.use { inputStream -> context.contentResolver.openOutputStream(this.uri)?.use { outputStream -> inputStream.copyTo(outputStream) @@ -203,7 +223,7 @@ class DocManFiles { } } targetDoc - //3. Delete the target file if copy failed + //4. Delete the target file if copy failed }.getOrElse { e -> targetDoc?.delete().let { throw e } } } @@ -347,7 +367,7 @@ class DocManFiles { /** Write content as [ByteArray] to a [Uri] */ private fun writeContentToUri(content: ByteArray, to: Uri, context: Context) { - context.contentResolver.openOutputStream(to)?.use { outputStream -> + context.contentResolver.openOutputStream(to, "wt")?.use { outputStream -> outputStream.write(content) } ?: throw IOException("Unable to open output stream for URI: $to") @@ -387,14 +407,20 @@ class DocManFiles { return try { //1. Check if document is a directory if (doc.isDirectory) { - doc.listFiles().forEach { deleteDocumentRecursive(it, context) } + // Delete all children first and track if all deletions were successful + val childrenDeleted = doc.listFiles().all { child -> + deleteDocumentRecursive(child, context) + } + // If any child failed to delete, return false + if (!childrenDeleted) return false } //2. Check if document is initialized as file if (doc.isAppFile(context)) { doc.uri.toFile().delete() } else { //3. Delete the document if it can be deleted - doc.canDelete(context) && doc.delete() + if (!doc.canDelete(context)) return false + doc.delete() } } catch (_: Exception) { false diff --git a/example/pubspec.lock b/example/pubspec.lock index 02afb87..0443479 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -21,41 +21,41 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.19.1" docman: dependency: "direct main" description: path: ".." relative: true source: path - version: "1.1.0" + version: "1.2.0" fake_async: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" file: dependency: transitive description: @@ -101,26 +101,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.7" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -149,18 +149,18 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" platform: dependency: transitive description: @@ -202,18 +202,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: @@ -242,18 +242,18 @@ packages: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.6" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -271,5 +271,5 @@ packages: source: hosted version: "3.0.4" sdks: - dart: ">=3.6.0 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/lib/src/data/document_file.dart b/lib/src/data/document_file.dart index eb9f5cf..230a6e3 100644 --- a/lib/src/data/document_file.dart +++ b/lib/src/data/document_file.dart @@ -7,8 +7,16 @@ import 'package:flutter/material.dart'; @immutable class DocumentFile { /// The name of the document. `DISPLAY_NAME` - The display name of the document. + /// For some SAF providers (like Termux), this may be stripped of file extensions. + /// Use [displayName] for the raw provider name and [fileName] for the computed name with extension. final String name; + /// The raw display name from the SAF provider (may be stripped of extensions by some providers). + final String? displayName; + + /// The computed filename that includes the extension when the display name doesn't have one. + final String? fileName; + /// Represents the MIME type of the document. /// If the MIME type is not available, this will be `unknown`. /// If this is a directory, this will be `directory`. @@ -51,6 +59,8 @@ class DocumentFile { const DocumentFile({ required this.uri, this.name = 'unknown', + this.displayName, + this.fileName, this.type = 'unknown', this.size = 0, this.lastModified = 0, @@ -67,6 +77,8 @@ class DocumentFile { /// /// The [map] parameter must contain the following keys: /// - `name`: [String] representing the document's display name. + /// - `displayName`: [String?] representing the raw display name from the provider. + /// - `fileName`: [String?] representing the computed filename with extension. /// - `type`: [String] representing the MIME type of the document. /// - `uri`: [String] representation of the document URI. /// - `size`: [int] representing the size of the document in bytes or count of documents in the directory. @@ -81,6 +93,8 @@ class DocumentFile { /// Returns a [DocumentFile] instance. factory DocumentFile.fromMap(Map map) => DocumentFile( name: map['name'] as String, + displayName: map['displayName'] as String?, + fileName: map['fileName'] as String?, type: map['type'] as String? ?? 'unknown', uri: map['uri'] as String, size: map['size'] as int, @@ -119,6 +133,8 @@ class DocumentFile { /// Converts the [DocumentFile] instance to a map. Map toMap() => { 'name': name, + 'displayName': displayName, + 'fileName': fileName, 'type': type, 'uri': uri, 'size': size, @@ -134,7 +150,7 @@ class DocumentFile { @override String toString() => - 'DocumentFile(name: $name, type: $type, uri: $uri, size: $size, lastModified: $lastModified,' + 'DocumentFile(name: $name, displayName: $displayName, fileName: $fileName, type: $type, uri: $uri, size: $size, lastModified: $lastModified,' ' lastModifiedDate: $lastModifiedDate, exists: $exists, isDirectory: $isDirectory, isFile: $isFile, ' 'canRead: $canRead, canWrite: $canWrite, canDelete: $canDelete, canCreate: $canCreate, ' 'canThumbnail: $canThumbnail)'; @@ -146,6 +162,8 @@ class DocumentFile { identical(this, other) || other is DocumentFile && name == other.name && + displayName == other.displayName && + fileName == other.fileName && type == other.type && uri == other.uri && size == other.size && @@ -153,5 +171,5 @@ class DocumentFile { exists == other.exists; @override - int get hashCode => Object.hash(name, type, uri, size, lastModified, exists); + int get hashCode => Object.hash(name, displayName, fileName, type, uri, size, lastModified, exists); } diff --git a/lib/src/extensions/document_file_ext.dart b/lib/src/extensions/document_file_ext.dart index 06a9d6c..d2ddbd9 100644 --- a/lib/src/extensions/document_file_ext.dart +++ b/lib/src/extensions/document_file_ext.dart @@ -64,6 +64,18 @@ extension DocumentFileActionsExt on DocumentFile { DocumentFileMethods(this) .createFile(name: name, content: content, bytes: bytes); + /// Overwrite the content of the file. + /// + /// Only works if the current document is a file, exists, and has permission to write. + /// + /// - [content] optional string content that will be encoded as UTF-8. + /// - [bytes] optional raw bytes to write into the file. When both [content] and [bytes] + /// are provided, [bytes] will be used. + /// + /// Returns [DocumentFile] of the updated file, or null if something went wrong. + Future writeFile({String? content, Uint8List? bytes}) => + DocumentFileMethods(this).writeFile(content: content, bytes: bytes); + /// List the documents in the directory. /// /// Can be used only if the current document is a directory. diff --git a/lib/src/methods/document_file_methods.dart b/lib/src/methods/document_file_methods.dart index 280502d..e7367f2 100644 --- a/lib/src/methods/document_file_methods.dart +++ b/lib/src/methods/document_file_methods.dart @@ -118,7 +118,6 @@ class DocumentFileMethods { assert(doc.isDirectory, 'DocumentFile must be a directory, before calling createFile method.'); assert(name.isNotEmpty, 'Name must not be empty.'); - assert(name.contains('.'), 'Name must contain an extension.'); assert( content != null || bytes != null, 'Content or bytes must be provided.'); //If document canCreate is false, then return null @@ -132,6 +131,23 @@ class DocumentFileMethods { return result != null ? DocumentFile.fromMap(Map.from(result)) : null; } + /// Overwrite the document content. + Future writeFile({ + String? content, + Uint8List? bytes, + }) async { + assert(doc.exists, 'DocumentFile must exist, before calling write method.'); + assert(doc.isFile, + 'DocumentFile must be a file, before calling write method.'); + assert( + content != null || bytes != null, 'Content or bytes must be provided.'); + if (!doc.canWrite) return null; + final args = _args('write'); + args['content'] = bytes ?? Uint8List.fromList(content!.codeUnits); + final result = await _actionResult>(args); + return result != null ? DocumentFile.fromMap(Map.from(result)) : null; + } + /// Get list of documents in the directory. Future> list({ List mimeTypes = const [], diff --git a/pubspec.yaml b/pubspec.yaml index 6ab7e84..dd075b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,11 +2,11 @@ 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.2.0 +version: 1.2.1 environment: sdk: ^3.5.2 - flutter: '>=3.3.0' + flutter: ">=3.3.0" flutter: plugin: