From 0648b7e8e6f995438a33e50f502182ac0df869c2 Mon Sep 17 00:00:00 2001 From: Jona Date: Thu, 14 Jul 2022 22:15:07 +0200 Subject: [PATCH 01/16] (#61) add writeToFile, writeToFileAsBytes, writeToFileAsString --- .../storageaccessframework/DocumentFileApi.kt | 26 +++++ .../lib/StorageAccessFrameworkConstant.kt | 1 + docs/Usage/Storage Access Framework.md | 96 +++++++++++++++++++ .../file_explorer/file_explorer_card.dart | 7 ++ lib/src/saf/document_file.dart | 36 +++++++ lib/src/saf/saf.dart | 77 +++++++++++++++ 6 files changed, 243 insertions(+) diff --git a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentFileApi.kt b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentFileApi.kt index af90e89..ceacac1 100644 --- a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentFileApi.kt +++ b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentFileApi.kt @@ -74,6 +74,13 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : call.argument("content")!! ) } + WRITE_TO_FILE -> + writeToFile( + result, + call.argument("uri")!!, + call.argument("content")!!, + call.argument("mode")!! + ) PERSISTED_URI_PERMISSIONS -> persistedUriPermissions(result) RELEASE_PERSISTABLE_URI_PERMISSION -> @@ -311,6 +318,25 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : } } + private fun writeToFile( + result: MethodChannel.Result, + uri: String, + content: ByteArray, + mode: String, + ) { + try { + plugin.context.contentResolver.openOutputStream(Uri.parse(uri), mode)?.apply { + write(content) + flush() + close() + + result.success(true) + } + } catch (e: Exception) { + result.success(false) + } + } + @RequiresApi(API_19) private fun persistedUriPermissions(result: MethodChannel.Result) { val persistedUriPermissions = plugin.context.contentResolver.persistedUriPermissions diff --git a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt index d1ed988..e9f9d52 100644 --- a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt +++ b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt @@ -23,6 +23,7 @@ const val OPEN_DOCUMENT_TREE = "openDocumentTree" const val PERSISTED_URI_PERMISSIONS = "persistedUriPermissions" const val RELEASE_PERSISTABLE_URI_PERMISSION = "releasePersistableUriPermission" const val CREATE_FILE = "createFile" +const val WRITE_TO_FILE = "writeToFile" const val FROM_TREE_URI = "fromTreeUri" const val CAN_WRITE = "canWrite" const val CAN_READ = "canRead" diff --git a/docs/Usage/Storage Access Framework.md b/docs/Usage/Storage Access Framework.md index 34f2fdd..cc8f3fe 100644 --- a/docs/Usage/Storage Access Framework.md +++ b/docs/Usage/Storage Access Framework.md @@ -217,6 +217,33 @@ final DocumentFile? createdFile = createFileAsBytes( ); ``` +### writeToFileAsBytes + +Write to a file using raw bytes `Uint8List`. + +Given the document uri, opens the file in the specified `mode` and writes the `bytes` to it. + +`mode` represents the mode in which the file will be opened for writing. Use `FileMode.write` for truncating and `FileMode.append` for appending to the file. + +```dart +final Uri documentUri = ... +final String fileContent = 'My File Content'; + +/// Write to a file using a [Uint8List] as file contents [bytes] +final bool? success = writeToFileAsBytes( + documentUri, + bytes: Uint8List.fromList(fileContent.codeUnits), + mode: FileMode.write, +); + +/// Append to a file using a [Uint8List] as file contents [bytes] +final bool? success = writeToFileAsBytes( + documentUri, + bytes: Uint8List.fromList(fileContent.codeUnits), + mode: FileMode.write, +); +``` + ### canRead Mirror of [`DocumentFile.canRead`]() @@ -485,6 +512,31 @@ final DocumentFile? createdFile = createFileAsString( ); ``` +### writeToFileAsString + +Alias for `writeToFileAsBytes` + +Convenient method to write to a file using `content` as `String` instead `Uint8List`. + +```dart +final Uri documentUri = ... +final String fileContent = 'My File Content'; + +/// Write to a file using a [Uint8List] as file contents [bytes] +final bool? success = writeToFileAsString( + documentUri, + content: fileContent, + mode: FileMode.write, +); + +/// Append to a file using a [Uint8List] as file contents [bytes] +final bool? success = writeToFileAsBytes( + documentUri, + content: fileContent, + mode: FileMode.write, +); +``` + ### createFile Alias for `createFileAsBytes` and `createFileAsString` @@ -514,6 +566,50 @@ final DocumentFile? createdFile = createFile( ); ``` +### writeToFile + +Alias for `writeToFileAsBytes` and `writeToFileAsString` + +Convenient method to write to a file using `content` as `String` **or** `bytes` as `Uint8List`. + +You should provide either `content` or `bytes`, if both `bytes` will be used. + +`mode` represents the mode in which the file will be opened for writing. Use `FileMode.write` for truncating and `FileMode.append` for appending to the file. + + +```dart +final Uri documentUri = ... +final String fileContent = 'My File Content'; + +/// Write to a file using a [String] as file contents [content] +final bool? success = writeToFile( + documentUri, + content: fileContent, + mode: FileMode.write, +); + +/// Append to a file using a [String] as file contents [content] +final bool? success = writeToFile( + documentUri, + content: fileContent, + mode: FileMode.append, +); + +/// Write to a file using a [Uint8List] as file contents [bytes] +final DocumentFile? createdFile = writeToFile( + documentUri, + content: Uint8List.fromList(fileContent.codeUnits), + mode: FileMode.write, +); + +/// Append to a file using a [Uint8List] as file contents [bytes] +final DocumentFile? createdFile = writeToFile( + documentUri, + content: Uint8List.fromList(fileContent.codeUnits), + mode: FileMode.append, +); +``` + ## External APIs (deprecated) These APIs are from external Android libraries. diff --git a/example/lib/screens/file_explorer/file_explorer_card.dart b/example/lib/screens/file_explorer/file_explorer_card.dart index 874762e..07d40bb 100644 --- a/example/lib/screens/file_explorer/file_explorer_card.dart +++ b/example/lib/screens/file_explorer/file_explorer_card.dart @@ -198,6 +198,13 @@ class _FileExplorerCardState extends State { } }, ), + if (!_isDirectory) + DangerButton( + 'Write to File', + onTap: () async { + await writeToFile(widget.partialFile.metadata!.uri!, content: 'Hello World!'); + }, + ), ], ), ], diff --git a/lib/src/saf/document_file.dart b/lib/src/saf/document_file.dart index e072d20..2e87f81 100644 --- a/lib/src/saf/document_file.dart +++ b/lib/src/saf/document_file.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:typed_data'; import '../common/functional_extender.dart'; @@ -155,6 +156,41 @@ class DocumentFile { displayName: displayName, content: content, ); + + /// {@macro sharedstorage.saf.writeToFileAsBytes} + Future writeToFileAsBytes({ + required Uint8List bytes, + FileMode? mode, + }) => + saf.writeToFileAsBytes( + uri, + bytes: bytes, + mode: mode, + ); + + /// {@macro sharedstorage.saf.writeToFile} + Future writeToFile({ + String? content, + Uint8List? bytes, + FileMode? mode, + }) => + saf.writeToFile( + uri, + content: content, + bytes: bytes, + mode: mode, + ); + + /// Alias for [writeToFile] with [content] param + Future writeToFileAsString({ + required String content, + FileMode? mode, + }) => + saf.writeToFile( + uri, + content: content, + mode: mode, + ); /// {@macro sharedstorage.saf.length} Future get length => saf.documentLength(uri); diff --git a/lib/src/saf/saf.dart b/lib/src/saf/saf.dart index 3b79293..a0a814b 100644 --- a/lib/src/saf/saf.dart +++ b/lib/src/saf/saf.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:typed_data'; import '../../saf.dart'; @@ -281,6 +282,82 @@ Future createFileAsString( ); } +/// {@template sharedstorage.saf.writeToFile} +/// Convenient method to write to a file using either String or raw bytes. +/// +/// Under the hood this method calls `writeToFileAsString` or `writeToFileAsBytes` +/// depending on which argument is passed. +/// +/// If both (bytes and content) are passed, the bytes will be used and the content will be ignored. +/// {@endtemplate} +Future writeToFile( + Uri uri, { + Uint8List? bytes, + String? content, + FileMode? mode, +}) { + assert( + bytes != null || content != null, + '''Either [bytes] or [content] should be provided''', + ); + + return bytes != null + ? writeToFileAsBytes( + uri, + bytes: bytes, + mode: mode, + ) + : writeToFileAsString( + uri, + content: content!, + mode: mode, + ); +} + +/// {@template sharedstorage.saf.writeToFileAsBytes} +/// Write to a file. +/// - `uri` is the URI of the file. +/// - `bytes` is the content of the document as a list of bytes `Uint8List`. +/// - `mode` is the mode in which the file will be opened for writing. Use `FileMode.write` for truncating and `FileMode.append` for appending to the file. +/// +/// Returns `true` if the file was successfully written to. +/// {@endtemplate} +Future writeToFileAsBytes( + Uri uri, { + required Uint8List bytes, + FileMode? mode, +}) async { + var writeMode = 'wt'; + + if (mode == FileMode.append || mode == FileMode.writeOnlyAppend) { + writeMode = 'wa'; + } + + final args = { + 'uri': uri.toString(), + 'content': bytes, + 'mode': writeMode, + }; + + return kDocumentFileChannel.invokeMethod('writeToFile', args); +} + +/// {@template sharedstorage.saf.writeToFileAsString} +/// Convenient method to write to a file. +/// using `content` as String instead Uint8List. +/// {@endtemplate} +Future writeToFileAsString( + Uri uri, { + required String content, + FileMode? mode, +}) { + return writeToFileAsBytes( + uri, + bytes: Uint8List.fromList(content.codeUnits), + mode: mode, + ); +} + /// {@template sharedstorage.saf.length} /// Equivalent to `DocumentFile.length`. /// From 83fa900a6ac04b1fa151de62b1abcbf3046205bd Mon Sep 17 00:00:00 2001 From: lakscastro Date: Fri, 15 Jul 2022 12:37:32 -0300 Subject: [PATCH 02/16] (#85) Apply PR fixes --- .../storageaccessframework/DocumentFileApi.kt | 2 +- docs/Usage/Storage Access Framework.md | 7 +++---- lib/src/saf/saf.dart | 15 ++++++--------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentFileApi.kt b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentFileApi.kt index ceacac1..2d7a674 100644 --- a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentFileApi.kt +++ b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentFileApi.kt @@ -322,7 +322,7 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) : result: MethodChannel.Result, uri: String, content: ByteArray, - mode: String, + mode: String ) { try { plugin.context.contentResolver.openOutputStream(Uri.parse(uri), mode)?.apply { diff --git a/docs/Usage/Storage Access Framework.md b/docs/Usage/Storage Access Framework.md index cc8f3fe..506cbb7 100644 --- a/docs/Usage/Storage Access Framework.md +++ b/docs/Usage/Storage Access Framework.md @@ -223,7 +223,7 @@ Write to a file using raw bytes `Uint8List`. Given the document uri, opens the file in the specified `mode` and writes the `bytes` to it. -`mode` represents the mode in which the file will be opened for writing. Use `FileMode.write` for truncating and `FileMode.append` for appending to the file. +`mode` represents the mode in which the file will be opened for writing. Use `FileMode.write` for truncating (overwrite) and `FileMode.append` for appending to the file. ```dart final Uri documentUri = ... @@ -576,7 +576,6 @@ You should provide either `content` or `bytes`, if both `bytes` will be used. `mode` represents the mode in which the file will be opened for writing. Use `FileMode.write` for truncating and `FileMode.append` for appending to the file. - ```dart final Uri documentUri = ... final String fileContent = 'My File Content'; @@ -596,14 +595,14 @@ final bool? success = writeToFile( ); /// Write to a file using a [Uint8List] as file contents [bytes] -final DocumentFile? createdFile = writeToFile( +final bool? success = writeToFile( documentUri, content: Uint8List.fromList(fileContent.codeUnits), mode: FileMode.write, ); /// Append to a file using a [Uint8List] as file contents [bytes] -final DocumentFile? createdFile = writeToFile( +final bool? success = writeToFile( documentUri, content: Uint8List.fromList(fileContent.codeUnits), mode: FileMode.append, diff --git a/lib/src/saf/saf.dart b/lib/src/saf/saf.dart index a0a814b..cea8c55 100644 --- a/lib/src/saf/saf.dart +++ b/lib/src/saf/saf.dart @@ -202,7 +202,7 @@ Future createDirectory(Uri parentUri, String displayName) async { } /// {@template sharedstorage.saf.createFile} -/// Convenient method to create files using either String or raw bytes. +/// Convenient method to create files using either [String] or raw bytes [Uint8List]. /// /// Under the hood this method calls `createFileAsString` or `createFileAsBytes` /// depending on which argument is passed. @@ -283,7 +283,7 @@ Future createFileAsString( } /// {@template sharedstorage.saf.writeToFile} -/// Convenient method to write to a file using either String or raw bytes. +/// Convenient method to write to a file using either [String] or raw bytes [Uint8List]. /// /// Under the hood this method calls `writeToFileAsString` or `writeToFileAsBytes` /// depending on which argument is passed. @@ -327,14 +327,11 @@ Future writeToFileAsBytes( required Uint8List bytes, FileMode? mode, }) async { - var writeMode = 'wt'; - - if (mode == FileMode.append || mode == FileMode.writeOnlyAppend) { - writeMode = 'wa'; - } + final writeMode = + mode == FileMode.append || mode == FileMode.writeOnlyAppend ? 'wa' : 'wt'; final args = { - 'uri': uri.toString(), + 'uri': '$uri', 'content': bytes, 'mode': writeMode, }; @@ -344,7 +341,7 @@ Future writeToFileAsBytes( /// {@template sharedstorage.saf.writeToFileAsString} /// Convenient method to write to a file. -/// using `content` as String instead Uint8List. +/// using `content` as [String] instead [Uint8List]. /// {@endtemplate} Future writeToFileAsString( Uri uri, { From 90ae52d0eb5aad92da3e48a8c771c923e1c4fe5e Mon Sep 17 00:00:00 2001 From: lakscastro Date: Fri, 15 Jul 2022 12:48:33 -0300 Subject: [PATCH 03/16] (#85, #79, #61) Add more actions related to the `writeToFile` API --- .../file_explorer/file_explorer_card.dart | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/example/lib/screens/file_explorer/file_explorer_card.dart b/example/lib/screens/file_explorer/file_explorer_card.dart index 07d40bb..a79e6af 100644 --- a/example/lib/screens/file_explorer/file_explorer_card.dart +++ b/example/lib/screens/file_explorer/file_explorer_card.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -85,6 +87,12 @@ class _FileExplorerCardState extends State { bool get _isDirectory => file.metadata?.isDirectory ?? false; + int _generateLuckNumber() { + final random = Random(); + + return random.nextInt(1000); + } + @override Widget build(BuildContext context) { return SimpleCard( @@ -105,9 +113,19 @@ class _FileExplorerCardState extends State { return Image.memory(content!); } + final contentAsString = String.fromCharCodes(content!); + + final fileIsEmpty = contentAsString.isEmpty; + return Container( padding: k8dp.all, - child: Text(String.fromCharCodes(content!)), + child: Text( + fileIsEmpty ? 'This file is empty' : contentAsString, + style: TextStyle( + color: fileIsEmpty ? Colors.black26 : null, + fontStyle: fileIsEmpty ? FontStyle.italic : null, + ), + ), ); }, ); @@ -173,7 +191,6 @@ class _FileExplorerCardState extends State { final uri = widget.partialFile.metadata!.uri!; try { - // OpenFile.open('/sdcard/example.txt'); final launched = await openDocumentFile(uri); if (launched ?? false) { @@ -198,13 +215,46 @@ class _FileExplorerCardState extends State { } }, ), - if (!_isDirectory) + if (!_isDirectory) ...[ DangerButton( 'Write to File', onTap: () async { - await writeToFile(widget.partialFile.metadata!.uri!, content: 'Hello World!'); + await writeToFile( + widget.partialFile.metadata!.uri!, + content: + 'Hello World! Your luck number is: ${_generateLuckNumber()}', + mode: FileMode.write, + ); + }, + ), + DangerButton( + 'Append to file', + onTap: () async { + final contents = await getDocumentContentAsString( + widget.partialFile.metadata!.uri!, + ); + + final prependWithNewLine = contents?.isNotEmpty ?? true; + + await writeToFile( + widget.partialFile.metadata!.uri!, + content: + "${prependWithNewLine ? '\n' : ''}You file got bigger! Here's your luck number: ${_generateLuckNumber()}", + mode: FileMode.append, + ); + }, + ), + DangerButton( + 'Erase file content', + onTap: () async { + await writeToFile( + widget.partialFile.metadata!.uri!, + content: '', + mode: FileMode.write, + ); }, ), + ], ], ), ], From 46a2b7e0663f194c1fd5b71e2e59231476db82cb Mon Sep 17 00:00:00 2001 From: lakscastro Date: Fri, 15 Jul 2022 13:01:27 -0300 Subject: [PATCH 04/16] (#88) Replace unsecure `HTTP` with `HTTPS` link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9511917..c68b17c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ These are the brilliant minds behind the development of this plugin!
dangilbert

💻 🐛
dhaval-k-simformsolutions

🐛 🤔
Daniel Dunn

🐛 💻 📖 -
jfaltis

🐛 💻 📖 +
jfaltis

🐛 💻 📖 From 83f5ad70db6656674cd5d6018d6a606423e76959 Mon Sep 17 00:00:00 2001 From: lakscastro Date: Fri, 15 Jul 2022 13:06:52 -0300 Subject: [PATCH 05/16] (#89) Update description to match `pub.dev` requirements --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 49b10ba..11d6b5c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: shared_storage -description: "Flutter plugin to work with external storage." +description: "Flutter plugin to work with external storage with privacy-friendly APIs." version: 0.4.2 homepage: https://github.com/lakscastro/shared-storage repository: https://github.com/lakscastro/shared-storage From 74e87005ff24e31ad289fd57ab4279e1160b7e31 Mon Sep 17 00:00:00 2001 From: Laks Castro Date: Fri, 15 Jul 2022 14:57:22 -0300 Subject: [PATCH 06/16] (#85) Update plugin version to `v0.5.0` --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 49b10ba..c1973dc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: shared_storage description: "Flutter plugin to work with external storage." -version: 0.4.2 +version: 0.5.0 homepage: https://github.com/lakscastro/shared-storage repository: https://github.com/lakscastro/shared-storage issue_tracker: https://github.com/lakscastro/shared-storage/issues From 0815bd2e237f139430c126744fbffbb987bc1bca Mon Sep 17 00:00:00 2001 From: Laks Castro Date: Fri, 15 Jul 2022 15:48:24 -0300 Subject: [PATCH 07/16] (#89) Remove redundant `with` --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 11d6b5c..4aec06c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: shared_storage -description: "Flutter plugin to work with external storage with privacy-friendly APIs." +description: "Flutter plugin to work with external storage and privacy-friendly APIs." version: 0.4.2 homepage: https://github.com/lakscastro/shared-storage repository: https://github.com/lakscastro/shared-storage From 8c75cea9b84649c139abb774d01f9a5ce3c8647f Mon Sep 17 00:00:00 2001 From: lakscastro Date: Sun, 17 Jul 2022 18:38:27 -0300 Subject: [PATCH 08/16] (#94) Add actions and refactor `listFiles` API --- .../DocumentsContractApi.kt | 35 +- .../lib/DocumentCommon.kt | 122 ++-- .../lib/DocumentFileColumn.kt | 7 +- .../file_explorer/file_explorer_card.dart | 557 ++++++++++++------ .../file_explorer/file_explorer_page.dart | 220 ++++--- .../granted_uris/granted_uri_card.dart | 67 ++- .../granted_uris/granted_uris_page.dart | 9 +- example/lib/theme/spacing.dart | 30 +- example/lib/utils/apply_if_not_null.dart | 8 + example/lib/utils/confirm_decorator.dart | 41 ++ example/lib/utils/disabled_text_style.dart | 12 + example/lib/utils/format_bytes.dart | 11 + example/lib/utils/inline_span.dart | 29 + example/lib/utils/mime_types.dart | 6 + example/lib/utils/take_if.dart | 7 + example/lib/widgets/confirmation_dialog.dart | 44 ++ example/lib/widgets/key_value_text.dart | 26 +- example/lib/widgets/text_field_dialog.dart | 66 +++ example/pubspec.yaml | 1 + lib/saf.dart | 1 - lib/src/saf/document_file.dart | 62 +- lib/src/saf/partial_document_file.dart | 96 --- lib/src/saf/saf.dart | 15 +- 23 files changed, 942 insertions(+), 530 deletions(-) create mode 100644 example/lib/utils/apply_if_not_null.dart create mode 100644 example/lib/utils/confirm_decorator.dart create mode 100644 example/lib/utils/disabled_text_style.dart create mode 100644 example/lib/utils/format_bytes.dart create mode 100644 example/lib/utils/inline_span.dart create mode 100644 example/lib/utils/mime_types.dart create mode 100644 example/lib/utils/take_if.dart create mode 100644 example/lib/widgets/confirmation_dialog.dart create mode 100644 example/lib/widgets/text_field_dialog.dart delete mode 100644 lib/src/saf/partial_document_file.dart diff --git a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentsContractApi.kt b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentsContractApi.kt index aca2a1b..0559b9c 100644 --- a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentsContractApi.kt +++ b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentsContractApi.kt @@ -35,30 +35,31 @@ internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : val width = call.argument("width")!! val height = call.argument("height")!! - val bitmap = - DocumentsContract.getDocumentThumbnail( - plugin.context.contentResolver, - uri, - Point(width, height), - null - ) + val bitmap = DocumentsContract.getDocumentThumbnail( + plugin.context.contentResolver, + uri, + Point(width, height), + null + ) - CoroutineScope(Dispatchers.Default).launch { - if (bitmap != null) { + if (bitmap != null) { + CoroutineScope(Dispatchers.Default).launch { val base64 = bitmapToBase64(bitmap) val data = - mapOf( - "base64" to base64, - "uri" to "$uri", - "width" to bitmap.width, - "height" to bitmap.height, - "byteCount" to bitmap.byteCount, - "density" to bitmap.density - ) + mapOf( + "base64" to base64, + "uri" to "$uri", + "width" to bitmap.width, + "height" to bitmap.height, + "byteCount" to bitmap.byteCount, + "density" to bitmap.density + ) launch(Dispatchers.Main) { result.success(data) } } + } else { + result.success(null) } } else { result.notSupported(call.method, API_21) diff --git a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentCommon.kt b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentCommon.kt index d072e48..46aee0d 100644 --- a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentCommon.kt +++ b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentCommon.kt @@ -39,74 +39,58 @@ fun documentFromUri( /** - * Standard map encoding of a `DocumentFile` and must be used before returning any `DocumentFile` - * from plugin results, like: - * ```dart - * result.success(createDocumentFileMap(documentFile)) - * ``` + * Convert a [DocumentFile] using the default method for map encoding */ fun createDocumentFileMap(documentFile: DocumentFile?): Map? { if (documentFile == null) return null - return mapOf( - "isDirectory" to documentFile.isDirectory, - "isFile" to documentFile.isFile, - "isVirtual" to documentFile.isVirtual, - "name" to (documentFile.name ?: ""), - "type" to (documentFile.type ?: ""), - "uri" to "${documentFile.uri}", - "exists" to "${documentFile.exists()}" + return createDocumentFileMap( + DocumentsContract.getDocumentId(documentFile.uri), + parentUri = documentFile.parentFile?.uri, + isDirectory = documentFile.isDirectory, + isFile = documentFile.isFile, + isVirtual = documentFile.isVirtual, + name = documentFile.name, + type = documentFile.type, + uri = documentFile.uri, + exists = documentFile.exists(), + size = documentFile.length(), + lastModified = documentFile.lastModified() ) } - /** - * Standard map encoding of a row result of a `DocumentFile` - * ```kt + * Standard map encoding of a `DocumentFile` and must be used before returning any `DocumentFile` + * from plugin results, like: + * ```dart * result.success(createDocumentFileMap(documentFile)) * ``` - * - * Example: - * ```py - * input = { - * "last_modified": 2939496, # Key from DocumentsContract.Document.COLUMN_LAST_MODIFIED - * "_display_name": "MyFile" # Key from DocumentsContract.Document.COLUMN_DISPLAY_NAME - * } - * - * output = createCursorRowMap(input) - * - * print(output) - * { - * "lastModified": 2939496, - * "displayName": "MyFile" - * } - * ``` */ -fun createCursorRowMap( - parentUri: Uri, +fun createDocumentFileMap( + id: String?, + parentUri: Uri?, + isDirectory: Boolean?, + isFile: Boolean?, + isVirtual: Boolean?, + name: String?, + type: String?, uri: Uri, - data: Map, - isDirectory: Boolean? -): Map { - val values = DocumentFileColumn.values() - - val formattedData = mutableMapOf() - - for (value in values) { - val key = parseDocumentFileColumn(value) - - if (data[key] != null) { - formattedData[documentFileColumnToRawString(value)!!] = data[key]!! - } - } - + exists: Boolean?, + size: Long?, + lastModified: Long? +): Map { return mapOf( - "data" to formattedData, - "metadata" to mapOf( - "parentUri" to "$parentUri", - "isDirectory" to isDirectory, - "uri" to "$uri" - ) + "id" to id, + "parentUri" to "$parentUri", + "isDirectory" to isDirectory, + "isFile" to isFile, + "isVirtual" to isVirtual, + "name" to name, + "type" to type, + "uri" to "$uri", + "exists" to exists, + "size" to size, + "lastModified" to lastModified ) } @@ -130,7 +114,7 @@ fun traverseDirectoryEntries( targetUri: Uri, columns: Array, rootOnly: Boolean, - block: (data: Map, isLast: Boolean) -> Unit + block: (data: Map, isLast: Boolean) -> Unit ): Boolean { val documentId = try { DocumentsContract.getDocumentId(targetUri) @@ -158,7 +142,10 @@ fun traverseDirectoryEntries( if (rootOnly) emptyArray() else arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE) val intrinsicColumns = - arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_FLAGS + ) val projection = arrayOf( *columns, @@ -215,11 +202,22 @@ fun traverseDirectoryEntries( } block( - createCursorRowMap( - parent, - uri, - data, - isDirectory = isDirectory + createDocumentFileMap( + parentUri = parent, + uri = uri, + name = data[DocumentsContract.Document.COLUMN_DISPLAY_NAME] as String?, + exists = true, + id = data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String, + isDirectory = isDirectory == true, + isFile = isDirectory == false, + isVirtual = if (Build.VERSION.SDK_INT >= API_24) { + (data[DocumentsContract.Document.COLUMN_FLAGS] as Int and DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT) != 0 + } else { + false + }, + type = data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String, + size = data[DocumentsContract.Document.COLUMN_SIZE] as Long?, + lastModified = data[DocumentsContract.Document.COLUMN_LAST_MODIFIED] as Long? ), dirNodes.isEmpty() && cursor.isLast ) diff --git a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt index 6a178ff..7d54089 100644 --- a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt +++ b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt @@ -16,7 +16,8 @@ enum class DocumentFileColumn { enum class DocumentFileColumnType { LONG, - STRING + STRING, + INT } fun parseDocumentFileColumn(column: String): DocumentFileColumn? { @@ -66,7 +67,8 @@ fun typeOfColumn(column: String): DocumentFileColumnType? { DocumentsContract.Document.COLUMN_MIME_TYPE to DocumentFileColumnType.STRING, DocumentsContract.Document.COLUMN_SIZE to DocumentFileColumnType.LONG, DocumentsContract.Document.COLUMN_SUMMARY to DocumentFileColumnType.STRING, - DocumentsContract.Document.COLUMN_LAST_MODIFIED to DocumentFileColumnType.LONG + DocumentsContract.Document.COLUMN_LAST_MODIFIED to DocumentFileColumnType.LONG, + DocumentsContract.Document.COLUMN_FLAGS to DocumentFileColumnType.INT ) return values[column] @@ -76,5 +78,6 @@ fun cursorHandlerOf(type: DocumentFileColumnType): (Cursor, Int) -> Any { when(type) { DocumentFileColumnType.LONG -> { return { cursor, index -> cursor.getLong(index) } } DocumentFileColumnType.STRING -> { return { cursor, index -> cursor.getString(index) } } + DocumentFileColumnType.INT -> { return { cursor, index -> cursor.getInt(index) } } } } diff --git a/example/lib/screens/file_explorer/file_explorer_card.dart b/example/lib/screens/file_explorer/file_explorer_card.dart index a79e6af..35bdf7d 100644 --- a/example/lib/screens/file_explorer/file_explorer_card.dart +++ b/example/lib/screens/file_explorer/file_explorer_card.dart @@ -3,55 +3,136 @@ import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; +import 'package:fl_toast/fl_toast.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_storage/saf.dart'; import '../../theme/spacing.dart'; +import '../../utils/apply_if_not_null.dart'; +import '../../utils/confirm_decorator.dart'; +import '../../utils/disabled_text_style.dart'; +import '../../utils/format_bytes.dart'; +import '../../utils/inline_span.dart'; +import '../../utils/mime_types.dart'; import '../../widgets/buttons.dart'; import '../../widgets/key_value_text.dart'; import '../../widgets/simple_card.dart'; +import '../../widgets/text_field_dialog.dart'; import 'file_explorer_page.dart'; class FileExplorerCard extends StatefulWidget { const FileExplorerCard({ Key? key, - required this.partialFile, + required this.documentFile, required this.didUpdateDocument, }) : super(key: key); - final PartialDocumentFile partialFile; - final void Function(PartialDocumentFile?) didUpdateDocument; + final DocumentFile documentFile; + final void Function(DocumentFile?) didUpdateDocument; @override _FileExplorerCardState createState() => _FileExplorerCardState(); } class _FileExplorerCardState extends State { - PartialDocumentFile get file => widget.partialFile; + DocumentFile get _file => widget.documentFile; - static const _size = Size.square(150); + static const _expandedThumbnailSize = Size.square(150); - Uint8List? imageBytes; + Uint8List? _thumbnailImageBytes; + Size? _thumbnailSize; - Future _loadThumbnailIfAvailable() async { - final uri = file.metadata?.uri; + int get _sizeInBytes => _file.size ?? 0; + + bool _expanded = false; + String? get _displayName => _file.name; - if (uri == null) return; + Future _loadThumbnailIfAvailable() async { + final uri = _file.uri; final bitmap = await getDocumentThumbnail( uri: uri, - width: _size.width, - height: _size.height, + width: _expandedThumbnailSize.width, + height: _expandedThumbnailSize.height, ); - if (bitmap == null || !mounted) return; + if (bitmap == null) { + _thumbnailImageBytes = Uint8List.fromList([]); + _thumbnailSize = Size.zero; + } else { + _thumbnailImageBytes = bitmap.bytes; + _thumbnailSize = Size(bitmap.width! / 1, bitmap.height! / 1); + } - setState(() => imageBytes = bitmap.bytes); + if (mounted) setState(() {}); } StreamSubscription? _subscription; + Future Function() _fileConfirmation( + String action, + VoidCallback callback, + ) { + return confirm( + context, + action, + callback, + message: [ + normal('This action '), + bold('writes'), + normal(' to this file, '), + bold('it can '), + bold(red('corrupt the file ')), + normal('or'), + bold(red(' even lose your data')), + normal(', be cautious.'), + ], + ); + } + + VoidCallback _directoryConfirmation(String action, VoidCallback callback) { + return confirm( + context, + action, + callback, + message: [ + normal('This action '), + bold('writes'), + normal(' to this file, '), + bold('it can '), + bold(red('corrupt the file ')), + normal('or'), + bold(red(' even lose your data')), + normal(', be cautious.'), + ], + ); + } + + Widget _buildMimeTypeIconThumbnail(String mimeType, {double? size}) { + if (mimeType == kDirectoryMime) { + return Icon(Icons.folder, size: size, color: Colors.blueGrey); + } + + if (mimeType == kApkMime) { + return Icon(Icons.android, color: const Color(0xff3AD17D), size: size); + } + + if (mimeType == kTextPlainMime) { + return Icon(Icons.description, size: size, color: Colors.blue); + } + + if (mimeType.startsWith(kVideoMime)) { + return Icon(Icons.movie, size: size, color: Colors.deepOrange); + } + + return Icon( + Icons.browser_not_supported_outlined, + size: size, + color: disabledColor(), + ); + } + @override void initState() { super.initState(); @@ -63,9 +144,9 @@ class _FileExplorerCardState extends State { void didUpdateWidget(covariant FileExplorerCard oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.partialFile.data?[DocumentFileColumn.id] != - widget.partialFile.data?[DocumentFileColumn.id]) { + if (oldWidget.documentFile.id != widget.documentFile.id) { _loadThumbnailIfAvailable(); + if (mounted) setState(() => _expanded = false); } } @@ -85,7 +166,7 @@ class _FileExplorerCardState extends State { Uint8List? content; - bool get _isDirectory => file.metadata?.isDirectory ?? false; + bool get _isDirectory => _file.isDirectory == true; int _generateLuckNumber() { final random = Random(); @@ -93,170 +174,298 @@ class _FileExplorerCardState extends State { return random.nextInt(1000); } - @override - Widget build(BuildContext context) { - return SimpleCard( - onTap: () async { - if (file.metadata?.isDirectory == false) { - content = await getDocumentContent(file.metadata!.uri!); - - final mimeType = - file.data![DocumentFileColumn.mimeType] as String? ?? ''; - - if (content != null) { - final isImage = mimeType.startsWith('image/'); - - await showModalBottomSheet( - context: context, - builder: (context) { - if (isImage) { - return Image.memory(content!); - } - - final contentAsString = String.fromCharCodes(content!); - - final fileIsEmpty = contentAsString.isEmpty; - - return Container( - padding: k8dp.all, - child: Text( - fileIsEmpty ? 'This file is empty' : contentAsString, - style: TextStyle( - color: fileIsEmpty ? Colors.black26 : null, - fontStyle: fileIsEmpty ? FontStyle.italic : null, - ), - ), - ); - }, - ); + Widget _buildThumbnail({double? size}) { + late Widget thumbnail; + + if (_thumbnailImageBytes == null) { + thumbnail = const CircularProgressIndicator(); + } else if (_thumbnailImageBytes!.isEmpty) { + thumbnail = _buildMimeTypeIconThumbnail( + _mimeTypeOrEmpty, + size: size, + ); + } else { + thumbnail = Image.memory( + _thumbnailImageBytes!, + fit: BoxFit.contain, + ); + + if (!_expanded) { + final width = _thumbnailSize?.width; + final height = _thumbnailSize?.height; + + final aspectRatio = + width != null && height != null ? width / height : 1.0; + + thumbnail = AspectRatio( + aspectRatio: aspectRatio, + child: thumbnail, + ); + } + } + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: _expanded ? MainAxisSize.max : MainAxisSize.min, + children: [ + Align( + alignment: _expanded ? Alignment.centerLeft : Alignment.center, + child: thumbnail, + ), + if (_expanded) _buildExpandButton(), + ], + ), + ); + } + + Widget _buildExpandButton() { + return IconButton( + onPressed: () => setState(() => _expanded = !_expanded), + icon: _expanded + ? const Icon(Icons.expand_less, color: Colors.grey) + : const Icon(Icons.expand_more, color: Colors.grey), + ); + } + + Uri get _currentUri => widget.documentFile.uri; + + Widget _buildNotAvailableText() { + return Text('Not available', style: disabledTextStyle()); + } + + Widget _buildOpenWithButton() => + Button('Open with', onTap: _openFileWithExternalApp); + + Widget _buildDocumentSimplifiedTile() { + return ListTile( + dense: true, + leading: _buildThumbnail(size: 25), + title: Text( + '$_displayName', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(formatBytes(_sizeInBytes, 2)), + trailing: _buildExpandButton(), + ); + } + + Widget _buildDocumentMetadata() { + return KeyValueText( + entries: { + 'name': '$_displayName', + 'type': '${_file.type}', + 'isVirtual': '${_file.isVirtual}', + 'isDirectory': '${_file.isDirectory}', + 'isFile': '${_file.isFile}', + 'size': '${formatBytes(_sizeInBytes, 2)} ($_sizeInBytes bytes)', + 'lastModified': '${(() { + if (_file.lastModified == null) { + return null; } - } + + return _file.lastModified!.toIso8601String(); + })()}', + 'id': '${_file.id}', + 'parentUri': _file.parentUri?.apply((u) => Uri.decodeFull('$u')) ?? + _buildNotAvailableText(), + 'uri': Uri.decodeFull('${_file.uri}'), }, + ); + } + + Widget _buildAvailableActions() { + return Wrap( children: [ - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: imageBytes == null - ? Container( - height: _size.height, - width: _size.width, - color: Colors.grey, - ) - : Image.memory( - imageBytes!, - height: _size.height, - width: _size.width, - fit: BoxFit.contain, - ), - ), - KeyValueText( - entries: { - 'name': '${file.data?[DocumentFileColumn.displayName]}', - 'type': '${file.data?[DocumentFileColumn.mimeType]}', - 'size': '${file.data?[DocumentFileColumn.size]}', - 'lastModified': '${(() { - if (file.data?[DocumentFileColumn.lastModified] == null) { - return null; - } - - final millisecondsSinceEpoch = - file.data?[DocumentFileColumn.lastModified] as int; - - final date = - DateTime.fromMillisecondsSinceEpoch(millisecondsSinceEpoch); - - return date.toIso8601String(); - })()}', - 'summary': '${file.data?[DocumentFileColumn.summary]}', - 'id': '${file.data?[DocumentFileColumn.id]}', - 'parentUri': '${file.metadata?.parentUri}', - 'uri': '${file.metadata?.uri}', - }, + if (_isDirectory) + ActionButton( + 'Open Directory', + onTap: _openDirectory, + ), + _buildOpenWithButton(), + DangerButton( + 'Delete ${_isDirectory ? 'Directory' : 'File'}', + onTap: _isDirectory + ? _directoryConfirmation('Delete', _deleteDocument) + : _fileConfirmation('Delete', _deleteDocument), ), - Wrap( - children: [ - if (_isDirectory) - ActionButton( - 'Open Directory', - onTap: () async { - if (_isDirectory) { - _openFolderFileListPage( - file.metadata!.uri!, - ); - } - }, - ), - ActionButton( - 'Open With', - onTap: () async { - final uri = widget.partialFile.metadata!.uri!; - - try { - final launched = await openDocumentFile(uri); - - if (launched ?? false) { - print('Successfully opened $uri'); - } else { - print('Failed to launch $uri'); - } - } on PlatformException { - print( - "There's no activity associated with the file type of this Uri: $uri", - ); - } - }, - ), - DangerButton( - 'Delete ${_isDirectory ? 'Directory' : 'File'}', - onTap: () async { - final deleted = await delete(widget.partialFile.metadata!.uri!); - - if (deleted ?? false) { - widget.didUpdateDocument(null); - } - }, + if (!_isDirectory) ...[ + DangerButton( + 'Write to File', + onTap: _fileConfirmation('Overwite', _overwriteFileContents), + ), + DangerButton( + 'Append to file', + onTap: _fileConfirmation('Append', _appendFileContents), + ), + DangerButton( + 'Erase file content', + onTap: _fileConfirmation('Erase', _eraseFileContents), + ), + DangerButton( + 'Edit file contents', + onTap: _editFileContents, + ), + ], + ], + ); + } + + String get _mimeTypeOrEmpty => _file.type ?? ''; + + Future _showFileContents() async { + if (_isDirectory) return; + + const k10mb = 1024 * 1024 * 10; + + if (!_mimeTypeOrEmpty.startsWith(kTextMime) && + !_mimeTypeOrEmpty.startsWith(kImageMime)) { + if (_mimeTypeOrEmpty == kApkMime) { + return showTextToast( + text: + 'Requesting to install a package (.apk) is not currently supported, to request this feature open an issue at github.com/lakscastro/shared-storage/issues', + context: context, + ); + } + + return _openFileWithExternalApp(); + } + + // Too long, will take too much time to read + if (_sizeInBytes > k10mb) { + return showTextToast( + text: 'File too long to open', + context: context, + ); + } + + content = await getDocumentContent(_file.uri); + + if (content != null) { + final isImage = _mimeTypeOrEmpty.startsWith(kImageMime); + + await showModalBottomSheet( + context: context, + builder: (context) { + if (isImage) { + return Image.memory(content!); + } + + final contentAsString = String.fromCharCodes(content!); + + final fileIsEmpty = contentAsString.isEmpty; + + return Container( + padding: k8dp.all, + child: Text( + fileIsEmpty ? 'This file is empty' : contentAsString, + style: fileIsEmpty ? disabledTextStyle() : null, ), - if (!_isDirectory) ...[ - DangerButton( - 'Write to File', - onTap: () async { - await writeToFile( - widget.partialFile.metadata!.uri!, - content: - 'Hello World! Your luck number is: ${_generateLuckNumber()}', - mode: FileMode.write, - ); - }, - ), - DangerButton( - 'Append to file', - onTap: () async { - final contents = await getDocumentContentAsString( - widget.partialFile.metadata!.uri!, - ); - - final prependWithNewLine = contents?.isNotEmpty ?? true; - - await writeToFile( - widget.partialFile.metadata!.uri!, - content: - "${prependWithNewLine ? '\n' : ''}You file got bigger! Here's your luck number: ${_generateLuckNumber()}", - mode: FileMode.append, - ); - }, - ), - DangerButton( - 'Erase file content', - onTap: () async { - await writeToFile( - widget.partialFile.metadata!.uri!, - content: '', - mode: FileMode.write, - ); - }, - ), - ], - ], + ); + }, + ); + } + } + + Future _deleteDocument() async { + final deleted = await delete(_currentUri); + + if (deleted ?? false) { + widget.didUpdateDocument(null); + } + } + + Future _overwriteFileContents() async { + await writeToFile( + _currentUri, + content: 'Hello World! Your luck number is: ${_generateLuckNumber()}', + mode: FileMode.write, + ); + } + + Future _appendFileContents() async { + final contents = await getDocumentContentAsString( + _currentUri, + ); + + final prependWithNewLine = contents?.isNotEmpty ?? true; + + await writeToFile( + _currentUri, + content: + "${prependWithNewLine ? '\n' : ''}You file got bigger! Here's your luck number: ${_generateLuckNumber()}", + mode: FileMode.append, + ); + } + + Future _eraseFileContents() async { + await writeToFile( + _currentUri, + content: '', + mode: FileMode.write, + ); + } + + Future _editFileContents() async { + final content = await showDialog( + context: context, + builder: (context) { + return const TextFieldDialog( + labelText: 'New file content:', + hintText: 'Writing to this file', + actionText: 'Edit', + ); + }, + ); + + if (content != null) { + _fileConfirmation( + 'Overwrite', + () => writeToFileAsString( + _currentUri, + content: content, + mode: FileMode.write, ), + )(); + } + } + + Future _openFileWithExternalApp() async { + final uri = _currentUri; + + try { + final launched = await openDocumentFile(uri); + + if (launched ?? false) { + print('Successfully opened $uri'); + } else { + print('Failed to launch $uri'); + } + } on PlatformException { + print( + "There's no activity associated with the file type of this Uri: $uri", + ); + } + } + + Future _openDirectory() async { + if (_isDirectory) { + _openFolderFileListPage(_file.uri); + } + } + + @override + Widget build(BuildContext context) { + return SimpleCard( + onTap: _isDirectory ? _openDirectory : _showFileContents, + children: [ + if (_expanded) ...[ + _buildThumbnail(size: 50), + _buildDocumentMetadata(), + _buildAvailableActions() + ] else + _buildDocumentSimplifiedTile(), ], ); } diff --git a/example/lib/screens/file_explorer/file_explorer_page.dart b/example/lib/screens/file_explorer/file_explorer_page.dart index 91fc1e1..4c7094a 100644 --- a/example/lib/screens/file_explorer/file_explorer_page.dart +++ b/example/lib/screens/file_explorer/file_explorer_page.dart @@ -1,12 +1,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; + import 'package:shared_storage/saf.dart'; import '../../theme/spacing.dart'; import '../../widgets/buttons.dart'; import '../../widgets/light_text.dart'; import '../../widgets/simple_card.dart'; +import '../../widgets/text_field_dialog.dart'; import 'file_explorer_card.dart'; class FileExplorerPage extends StatefulWidget { @@ -22,11 +24,11 @@ class FileExplorerPage extends StatefulWidget { } class _FileExplorerPageState extends State { - List? _files; + List? _files; late bool _hasPermission; - StreamSubscription? _listener; + StreamSubscription? _listener; Future _grantAccess() async { final uri = await openDocumentTree(initialUri: widget.uri); @@ -38,98 +40,142 @@ class _FileExplorerPageState extends State { _loadFiles(); } - Widget _buildFileList() { - return CustomScrollView( - slivers: [ - if (!_hasPermission) - SliverPadding( - padding: k6dp.eb, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - SimpleCard( - onTap: () => {}, - children: [ - Center( - child: LightText( - 'No permission granted to this folder\n\n${widget.uri}\n', - ), - ), - Center( - child: ActionButton( - 'Grant Access', - onTap: _grantAccess, - ), - ), - ], + Widget _buildNoPermissionWarning() { + return SliverPadding( + padding: k6dp.eb, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + SimpleCard( + onTap: () => {}, + children: [ + Center( + child: LightText( + 'No permission granted to this folder\n\n${widget.uri}\n', + ), + ), + Center( + child: ActionButton( + 'Grant Access', + onTap: _grantAccess, ), - ], + ), + ], + ), + ], + ), + ), + ); + } + + Future _createCustomDocument() async { + final filename = await showDialog( + context: context, + builder: (context) => const TextFieldDialog( + hintText: 'File name:', + labelText: 'My Text File', + suffixText: '.txt', + actionText: 'Create', + ), + ); + + if (filename == null) return; + + final createdFile = await createFile( + widget.uri, + mimeType: 'text/plain', + displayName: filename, + ); + + if (createdFile != null) { + _files?.add(createdFile); + + if (mounted) setState(() {}); + } + } + + Widget _buildCreateDocumentButton() { + return SliverPadding( + padding: k6dp.eb, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + Center( + child: ActionButton( + 'Create a custom document', + onTap: _createCustomDocument, ), ), - ) - else ...[ - SliverPadding( - padding: k6dp.eb, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - Center( - child: ActionButton( - 'Create a custom document', - onTap: () => {}, - ), + ], + ), + ), + ); + } + + void _didUpdateDocument( + DocumentFile before, + DocumentFile? after, + ) { + if (after == null) { + _files?.removeWhere((doc) => doc.id == before.id); + + if (mounted) setState(() {}); + } + } + + Widget _buildDocumentList() { + return SliverPadding( + padding: k6dp.et, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final file = _files![index]; + + return FileExplorerCard( + documentFile: file, + didUpdateDocument: (document) => + _didUpdateDocument(file, document), + ); + }, + childCount: _files!.length, + ), + ), + ); + } + + Widget _buildEmptyFolderWarning() { + return SliverPadding( + padding: k6dp.eb, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + SimpleCard( + onTap: () => {}, + children: const [ + Center( + child: LightText( + 'Empty folder', ), - ], - ), + ), + ], ), - ), + ], + ), + ), + ); + } + + Widget _buildFileList() { + return CustomScrollView( + slivers: [ + if (!_hasPermission) + _buildNoPermissionWarning() + else ...[ + _buildCreateDocumentButton(), if (_files!.isNotEmpty) - SliverPadding( - padding: k6dp.et, - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final file = _files![index]; - - return FileExplorerCard( - partialFile: file, - didUpdateDocument: (document) { - if (document == null) { - _files?.removeWhere( - (doc) => - doc.data?[DocumentFileColumn.id] == - file.data?[DocumentFileColumn.id], - ); - - if (mounted) setState(() {}); - } - }, - ); - }, - childCount: _files!.length, - ), - ), - ) + _buildDocumentList() else - SliverPadding( - padding: k6dp.eb, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - SimpleCard( - onTap: () => {}, - children: const [ - Center( - child: LightText( - 'Empty folder', - ), - ), - ], - ), - ], - ), - ), - ) + _buildEmptyFolderWarning(), ] ], ); diff --git a/example/lib/screens/granted_uris/granted_uri_card.dart b/example/lib/screens/granted_uris/granted_uri_card.dart index 55c3aa4..42e9cc6 100644 --- a/example/lib/screens/granted_uris/granted_uri_card.dart +++ b/example/lib/screens/granted_uris/granted_uri_card.dart @@ -55,41 +55,48 @@ class _GrantedUriCardState extends State { ); } + Widget _buildAvailableActions() { + return Wrap( + children: [ + ActionButton( + 'Create Sample File', + onTap: () => _appendSampleFile( + widget.permissionUri.uri, + ), + ), + ActionButton( + 'Open Tree Here', + onTap: () => openDocumentTree(initialUri: widget.permissionUri.uri), + ), + Padding(padding: k2dp.all), + DangerButton( + 'Revoke', + onTap: () => _revokeUri( + widget.permissionUri.uri, + ), + ), + ], + ); + } + + Widget _buildGrantedUriMetadata() { + return KeyValueText( + entries: { + 'isWritePermission': '${widget.permissionUri.isWritePermission}', + 'isReadPermission': '${widget.permissionUri.isReadPermission}', + 'persistedTime': '${widget.permissionUri.persistedTime}', + 'uri': Uri.decodeFull('${widget.permissionUri.uri}'), + }, + ); + } + @override Widget build(BuildContext context) { return SimpleCard( onTap: _openListFilesPage, children: [ - KeyValueText( - entries: { - 'isWritePermission': '${widget.permissionUri.isWritePermission}', - 'isReadPermission': '${widget.permissionUri.isReadPermission}', - 'persistedTime': '${widget.permissionUri.persistedTime}', - 'uri': '${widget.permissionUri.uri}', - }, - ), - Wrap( - children: [ - ActionButton( - 'Create Sample File', - onTap: () => _appendSampleFile( - widget.permissionUri.uri, - ), - ), - ActionButton( - 'Open Tree Here', - onTap: () => - openDocumentTree(initialUri: widget.permissionUri.uri), - ), - Padding(padding: k2dp.all), - DangerButton( - 'Revoke', - onTap: () => _revokeUri( - widget.permissionUri.uri, - ), - ), - ], - ), + _buildGrantedUriMetadata(), + _buildAvailableActions(), ], ); } diff --git a/example/lib/screens/granted_uris/granted_uris_page.dart b/example/lib/screens/granted_uris/granted_uris_page.dart index fa9ea92..62f5739 100644 --- a/example/lib/screens/granted_uris/granted_uris_page.dart +++ b/example/lib/screens/granted_uris/granted_uris_page.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:shared_storage/saf.dart'; import '../../theme/spacing.dart'; @@ -24,13 +23,9 @@ class _GrantedUrisPageState extends State { } Future _loadPersistedUriPermissions() async { - final status = await Permission.storage.request(); + persistedPermissionUris = await persistedUriPermissions(); - if (status.isGranted) { - persistedPermissionUris = await persistedUriPermissions(); - - setState(() => {}); - } + if (mounted) setState(() => {}); } /// Prompt user with a folder picker (Available for Android 5.0+) diff --git a/example/lib/theme/spacing.dart b/example/lib/theme/spacing.dart index 5113633..3a36206 100644 --- a/example/lib/theme/spacing.dart +++ b/example/lib/theme/spacing.dart @@ -1,18 +1,22 @@ import 'package:flutter/cupertino.dart'; -extension EdgeInsetsAlias on double { - EdgeInsets get all => EdgeInsets.all(this); - EdgeInsets get lr => EdgeInsets.symmetric(horizontal: this); - EdgeInsets get tb => EdgeInsets.symmetric(vertical: this); - EdgeInsets get ol => EdgeInsets.only(left: this); - EdgeInsets get or => EdgeInsets.only(left: this); - EdgeInsets get lb => EdgeInsets.only(left: this, bottom: this); - EdgeInsets get lt => EdgeInsets.only(left: this, top: this); - EdgeInsets get rt => EdgeInsets.only(right: this, top: this); - EdgeInsets get et => EdgeInsets.only(left: this, right: this, bottom: this); - EdgeInsets get eb => EdgeInsets.only(left: this, right: this, top: this); - EdgeInsets get el => EdgeInsets.only(right: this, top: this, bottom: this); - EdgeInsets get er => EdgeInsets.only(left: this, top: this, bottom: this); +extension EdgeInsetsAlias on num { + EdgeInsets get all => EdgeInsets.all(this / 1); + EdgeInsets get lr => EdgeInsets.symmetric(horizontal: this / 1); + EdgeInsets get tb => EdgeInsets.symmetric(vertical: this / 1); + EdgeInsets get ol => EdgeInsets.only(left: this / 1); + EdgeInsets get or => EdgeInsets.only(left: this / 1); + EdgeInsets get lb => EdgeInsets.only(left: this / 1, bottom: this / 1); + EdgeInsets get lt => EdgeInsets.only(left: this / 1, top: this / 1); + EdgeInsets get rt => EdgeInsets.only(right: this / 1, top: this / 1); + EdgeInsets get et => + EdgeInsets.only(left: this / 1, right: this / 1, bottom: this / 1); + EdgeInsets get eb => + EdgeInsets.only(left: this / 1, right: this / 1, top: this / 1); + EdgeInsets get el => + EdgeInsets.only(right: this / 1, top: this / 1, bottom: this / 1); + EdgeInsets get er => + EdgeInsets.only(left: this / 1, top: this / 1, bottom: this / 1); } const k8dp = 16.0; diff --git a/example/lib/utils/apply_if_not_null.dart b/example/lib/utils/apply_if_not_null.dart new file mode 100644 index 0000000..ae12359 --- /dev/null +++ b/example/lib/utils/apply_if_not_null.dart @@ -0,0 +1,8 @@ +extension ApplyIfNotNull on T? { + R? apply(R Function(T) f) { + // Local variable to allow automatic type promotion. Also see: + // + final T? self = this; + return (self == null) ? null : f(self); + } +} diff --git a/example/lib/utils/confirm_decorator.dart b/example/lib/utils/confirm_decorator.dart new file mode 100644 index 0000000..88f5926 --- /dev/null +++ b/example/lib/utils/confirm_decorator.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../widgets/confirmation_dialog.dart'; +import 'inline_span.dart'; + +Future Function() confirm( + BuildContext context, + String action, + VoidCallback callback, { + List? message, + String? text, +}) { + assert( + text != null || message != null, + '''You should provide at least one [message] or [text]''', + ); + Future openConfirmationDialog() async { + final result = await showDialog( + context: context, + builder: (context) => ConfirmationDialog( + color: Colors.red, + actionName: action, + body: Text.rich( + TextSpan( + children: [ + if (text != null) normal(text) else ...message!, + ], + ), + ), + ), + ); + + final confirmed = result == true; + + if (confirmed) callback(); + + return confirmed; + } + + return openConfirmationDialog; +} diff --git a/example/lib/utils/disabled_text_style.dart b/example/lib/utils/disabled_text_style.dart new file mode 100644 index 0000000..61e9530 --- /dev/null +++ b/example/lib/utils/disabled_text_style.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +TextStyle disabledTextStyle() { + return TextStyle( + color: disabledColor(), + fontStyle: FontStyle.italic, + ); +} + +Color disabledColor() { + return Colors.black26; +} diff --git a/example/lib/utils/format_bytes.dart b/example/lib/utils/format_bytes.dart new file mode 100644 index 0000000..a0d1948 --- /dev/null +++ b/example/lib/utils/format_bytes.dart @@ -0,0 +1,11 @@ +import 'dart:math'; + +String formatBytes(int bytes, int decimals) { + if (bytes <= 0) return '0 B'; + + const suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + final i = (log(bytes) / log(1024)).floor(); + + return '${(bytes / pow(1024, i)).toStringAsFixed(decimals)} ${suffixes[i]}'; +} diff --git a/example/lib/utils/inline_span.dart b/example/lib/utils/inline_span.dart new file mode 100644 index 0000000..115fea7 --- /dev/null +++ b/example/lib/utils/inline_span.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +InlineSpan Function(Object) customStyleDecorator(TextStyle textStyle) { + InlineSpan applyStyles(Object data) { + if (data is String) { + return TextSpan( + text: data, + style: textStyle, + ); + } + + if (data is TextSpan) { + return TextSpan( + text: data.text, + style: (data.style ?? const TextStyle()).merge(textStyle), + ); + } + + return data as InlineSpan; + } + + return applyStyles; +} + +final bold = customStyleDecorator(const TextStyle(fontWeight: FontWeight.bold)); +final italic = + customStyleDecorator(const TextStyle(fontStyle: FontStyle.italic)); +final red = customStyleDecorator(const TextStyle(color: Colors.red)); +final normal = customStyleDecorator(const TextStyle()); diff --git a/example/lib/utils/mime_types.dart b/example/lib/utils/mime_types.dart new file mode 100644 index 0000000..6a6df5b --- /dev/null +++ b/example/lib/utils/mime_types.dart @@ -0,0 +1,6 @@ +const kTextPlainMime = 'text/plain'; +const kApkMime = 'application/vnd.android.package-archive'; +const kImageMime = 'image/'; +const kTextMime = 'text/'; +const kDirectoryMime = 'vnd.android.document/directory'; +const kVideoMime = 'video/'; diff --git a/example/lib/utils/take_if.dart b/example/lib/utils/take_if.dart new file mode 100644 index 0000000..447844f --- /dev/null +++ b/example/lib/utils/take_if.dart @@ -0,0 +1,7 @@ +extension TakeIf on T { + T? takeIf(bool Function(T) predicate) { + final T self = this; + + return predicate(self) ? this : null; + } +} diff --git a/example/lib/widgets/confirmation_dialog.dart b/example/lib/widgets/confirmation_dialog.dart new file mode 100644 index 0000000..8fe9c1e --- /dev/null +++ b/example/lib/widgets/confirmation_dialog.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'buttons.dart'; + +class ConfirmationDialog extends StatefulWidget { + const ConfirmationDialog({ + Key? key, + required this.color, + this.message, + this.body, + required this.actionName, + }) : assert( + message != null || body != null, + '''You should at least provde [message] or body to explain to the user the context of this confirmation''', + ), + super(key: key); + + final Color color; + final String? message; + final Widget? body; + final String actionName; + + @override + State createState() => _ConfirmationDialogState(); +} + +class _ConfirmationDialogState extends State { + @override + Widget build(BuildContext context) { + return AlertDialog( + content: widget.body ?? Text(widget.message!), + title: const Text('Are you sure?'), + actions: [ + Button('Cancel', onTap: () => Navigator.pop(context, false)), + DangerButton( + widget.actionName, + onTap: () { + Navigator.pop(context, true); + }, + ), + ], + ); + } +} diff --git a/example/lib/widgets/key_value_text.dart b/example/lib/widgets/key_value_text.dart index 1fce051..6dfa3cc 100644 --- a/example/lib/widgets/key_value_text.dart +++ b/example/lib/widgets/key_value_text.dart @@ -1,28 +1,36 @@ import 'package:flutter/material.dart'; +/// Use the entry value as [Widget] to use a [WidgetSpan] and [Text] to use a [InlineSpan] class KeyValueText extends StatefulWidget { const KeyValueText({Key? key, required this.entries}) : super(key: key); - final Map entries; + final Map entries; @override _KeyValueTextState createState() => _KeyValueTextState(); } class _KeyValueTextState extends State { - TextSpan _buildTextSpan(String key, String value) { + TextSpan _buildTextSpan(String key, Object value) { return TextSpan( children: [ TextSpan( text: '$key: ', ), - TextSpan( - text: '$value\n', - style: const TextStyle( - fontWeight: FontWeight.bold, - decoration: TextDecoration.underline, + if (value is Widget) + WidgetSpan( + child: value, + alignment: PlaceholderAlignment.middle, + ) + else if (value is String) + TextSpan( + text: value, + style: const TextStyle( + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), ), - ), + const TextSpan(text: '\n'), ], ); } @@ -35,7 +43,7 @@ class _KeyValueTextState extends State { for (final key in widget.entries.keys) _buildTextSpan( key, - '${widget.entries[key]}', + widget.entries[key]!, ), ], ), diff --git a/example/lib/widgets/text_field_dialog.dart b/example/lib/widgets/text_field_dialog.dart new file mode 100644 index 0000000..0ea4eb4 --- /dev/null +++ b/example/lib/widgets/text_field_dialog.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../utils/disabled_text_style.dart'; +import 'buttons.dart'; + +class TextFieldDialog extends StatefulWidget { + const TextFieldDialog({ + Key? key, + required this.labelText, + required this.hintText, + this.suffixText, + required this.actionText, + }) : super(key: key); + + final String labelText; + final String hintText; + final String? suffixText; + final String actionText; + + @override + _TextFieldDialogState createState() => _TextFieldDialogState(); +} + +class _TextFieldDialogState extends State { + late TextEditingController _textFieldController = TextEditingController(); + + @override + void initState() { + super.initState(); + + _textFieldController = TextEditingController(); + } + + @override + void dispose() { + _textFieldController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + content: TextField( + controller: _textFieldController, + decoration: InputDecoration( + labelText: widget.labelText, + hintText: widget.hintText, + suffixText: widget.suffixText, + suffixStyle: disabledTextStyle(), + ), + ), + actions: [ + DangerButton( + 'Cancel', + onTap: () => Navigator.pop(context), + ), + Button( + widget.actionText, + onTap: () => + Navigator.pop(context, _textFieldController.text), + ), + ], + ); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 870f4f2..a5c3e65 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -9,6 +9,7 @@ environment: sdk: ">=2.12.0 <3.0.0" dependencies: + fl_toast: ^3.1.0 flutter: sdk: flutter lint: ^1.8.2 diff --git a/lib/saf.dart b/lib/saf.dart index 1f9768e..191cd91 100644 --- a/lib/saf.dart +++ b/lib/saf.dart @@ -3,6 +3,5 @@ library shared_storage; export './src/saf/document_bitmap.dart'; export './src/saf/document_file.dart'; export './src/saf/document_file_column.dart'; -export './src/saf/partial_document_file.dart'; export './src/saf/saf.dart'; export './src/saf/uri_permission.dart'; diff --git a/lib/src/saf/document_file.dart b/lib/src/saf/document_file.dart index 2e87f81..bc3f398 100644 --- a/lib/src/saf/document_file.dart +++ b/lib/src/saf/document_file.dart @@ -17,38 +17,58 @@ extension UriDocumentFileUtils on Uri { /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile) class DocumentFile { const DocumentFile({ + required this.id, + required this.parentUri, + required this.size, required this.name, required this.type, required this.uri, required this.isDirectory, required this.isFile, required this.isVirtual, + required this.lastModified, }); factory DocumentFile.fromMap(Map map) { return DocumentFile( - isDirectory: map['isDirectory'] as bool, - isFile: map['isFile'] as bool, - isVirtual: map['isVirtual'] as bool, - name: map['name'] as String, + parentUri: (map['parentUri'] as String?)?.apply((p) => Uri.parse(p)), + id: map['id'] as String?, + isDirectory: map['isDirectory'] as bool?, + isFile: map['isFile'] as bool?, + isVirtual: map['isVirtual'] as bool?, + name: map['name'] as String?, type: map['type'] as String?, uri: Uri.parse(map['uri'] as String), + size: map['size'] as int?, + lastModified: (map['lastModified'] as int?) + ?.apply((l) => DateTime.fromMillisecondsSinceEpoch(l)), ); } - /// Display name of this document file, useful to show as a title in a list of files - final String name; + /// Display name of this document file, useful to show as a title in a list of files. + final String? name; - /// Mimetype of this document file, useful to determine how to display it + /// Mimetype of this document file, useful to determine how to display it. final String? type; - /// Path, URI, location of this document, it can exists or not, you should check by using `exists()` API + /// Path, URI, location of this document, it can exists or not, you should check by using `exists()` API. final Uri uri; - /// Whether this document is a directory or not + /// Uri of the parent document of [this] document. + final Uri? parentUri; + + /// Generally represented as `primary:/Some/Resource` and can be used to identify the current document file. /// - /// Since it's a [DocumentFile], it can represent a folder/directory rather than a file - final bool isDirectory; + /// See [this diagram](https://raw.githubusercontent.com/anggrayudi/SimpleStorage/master/art/terminology.png) for details, source: [anggrayudi/SimpleStorage](https://github.com/anggrayudi/SimpleStorage). + final String? id; + + /// Size of a document in bytes + final int? size; + + /// Whether this document is a directory or not. + /// + /// Since it's a [DocumentFile], it can represent a folder/directory rather than a file. + final bool? isDirectory; /// Indicates if this [DocumentFile] represents a _file_. /// @@ -62,14 +82,14 @@ class DocumentFile { /// This identifier is an opaque implementation detail of the provider, and as such it must not be parsed. /// /// [Android Reference](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#:~:text=androidx.documentfile.provider.DocumentFile,but%20it%20has%20substantial%20overhead.() - final bool isFile; + final bool? isFile; /// Indicates if this file represents a virtual document. /// /// What is a virtual document? /// - [Video answer](https://www.youtube.com/watch?v=4h7yCZt231Y) /// - [Text docs answer](https://developer.android.com/about/versions/nougat/android-7.0#virtual_files) - final bool isVirtual; + final bool? isVirtual; /// {@macro sharedstorage.saf.fromTreeUri} static Future fromTreeUri(Uri uri) => saf.fromTreeUri(uri); @@ -133,7 +153,7 @@ class DocumentFile { Future createFile({ required String mimeType, required String displayName, - String? content, + String content = '', Uint8List? bytes, }) => saf.createFile( @@ -156,7 +176,7 @@ class DocumentFile { displayName: displayName, content: content, ); - + /// {@macro sharedstorage.saf.writeToFileAsBytes} Future writeToFileAsBytes({ required Uint8List bytes, @@ -192,11 +212,8 @@ class DocumentFile { mode: mode, ); - /// {@macro sharedstorage.saf.length} - Future get length => saf.documentLength(uri); - /// {@macro sharedstorage.saf.lastModified} - Future get lastModified => saf.lastModified(uri); + final DateTime? lastModified; /// {@macro sharedstorage.saf.findFile} Future findFile(String displayName) => @@ -211,12 +228,13 @@ class DocumentFile { Map toMap() { return { + 'uri': '$uri', + 'parentUri': '$parentUri', 'isDirectory': isDirectory, 'isFile': isFile, 'isVirtual': isVirtual, 'name': name, 'type': type, - 'uri': '$uri', }; } @@ -224,7 +242,9 @@ class DocumentFile { bool operator ==(Object other) { if (other is! DocumentFile) return false; - return isDirectory == other.isDirectory && + return id == other.id && + parentUri == other.parentUri && + isDirectory == other.isDirectory && isFile == other.isFile && isVirtual == other.isVirtual && name == other.name && diff --git a/lib/src/saf/partial_document_file.dart b/lib/src/saf/partial_document_file.dart deleted file mode 100644 index 0efdc4a..0000000 --- a/lib/src/saf/partial_document_file.dart +++ /dev/null @@ -1,96 +0,0 @@ -import '../../saf.dart'; - -/// Represent the same entity as `DocumentFile` but will be lazily loaded -/// by `listFiles` method with dynamic -/// properties and query metadata context -/// -/// _Note: Can't be instantiated_ -class PartialDocumentFile { - const PartialDocumentFile._({required this.data, required this.metadata}); - - factory PartialDocumentFile.fromMap(Map map) { - return PartialDocumentFile._( - data: (() { - final data = map['data'] as Map?; - - if (data == null) return null; - - return { - for (final value in DocumentFileColumn.values) - if (data['$value'] != null) value: data['$value'], - }; - })(), - metadata: QueryMetadata.fromMap(Map.from(map['metadata'] as Map)), - ); - } - - final Map? data; - final QueryMetadata? metadata; - - Map toMap() { - return { - 'data': data, - if (metadata != null) 'metadata': metadata?.toMap(), - }; - } - - @override - bool operator ==(Object other) { - if (other is! PartialDocumentFile) return false; - - return other.data == data && other.metadata == metadata; - } - - @override - int get hashCode => Object.hash(data, metadata); -} - -/// Represents the metadata that the given `PartialDocumentFile` was got by -/// the `contentResolver.query(uri, ...metadata)` method -/// -/// _Note: Can't be instantiated_ -class QueryMetadata { - const QueryMetadata._({ - required this.parentUri, - required this.isDirectory, - required this.uri, - }); - - factory QueryMetadata.fromMap(Map map) { - return QueryMetadata._( - parentUri: _parseUri(map['parentUri'] as String?), - isDirectory: map['isDirectory'] as bool?, - uri: _parseUri(map['uri'] as String?), - ); - } - - final Uri? parentUri; - final bool? isDirectory; - final Uri? uri; - - static Uri? _parseUri(String? uri) { - if (uri == null) return null; - - return Uri.parse(uri); - } - - Map toMap() { - return { - 'parentUri': '$parentUri', - 'isDirectory': isDirectory, - 'uri': uri, - }; - } - - @override - bool operator ==(Object other) { - if (other is! QueryMetadata) return false; - - return other.parentUri == parentUri && - other.isDirectory == isDirectory && - other.uri == uri; - } - - @override - int get hashCode => Object.hash(parentUri, isDirectory, uri); -} diff --git a/lib/src/saf/saf.dart b/lib/src/saf/saf.dart index cea8c55..5532021 100644 --- a/lib/src/saf/saf.dart +++ b/lib/src/saf/saf.dart @@ -144,7 +144,7 @@ Future getDocumentThumbnail({ /// /// [Refer to details](https://stackoverflow.com/questions/41096332/issues-traversing-through-directory-hierarchy-with-android-storage-access-framew). /// {@endtemplate} -Stream listFiles( +Stream listFiles( Uri uri, { required List columns, }) { @@ -157,9 +157,7 @@ Stream listFiles( final onCursorRowResult = kDocumentFileEventChannel.receiveBroadcastStream(args); - return onCursorRowResult - .map((e) => PartialDocumentFile.fromMap(Map.from(e as Map))) - .cast(); + return onCursorRowResult.map((e) => DocumentFile.fromMap(Map.from(e as Map))); } /// {@template sharedstorage.saf.exists} @@ -214,13 +212,8 @@ Future createFile( required String mimeType, required String displayName, Uint8List? bytes, - String? content, + String content = '', }) { - assert( - bytes != null || content != null, - '''Either [bytes] or [content] should be provided''', - ); - return bytes != null ? createFileAsBytes( parentUri, @@ -232,7 +225,7 @@ Future createFile( parentUri, mimeType: mimeType, displayName: displayName, - content: content!, + content: content, ); } From 52ba9471a30c13c4d54e0a1ab2c66cccdb0873af Mon Sep 17 00:00:00 2001 From: lakscastro Date: Sun, 17 Jul 2022 18:44:47 -0300 Subject: [PATCH 09/16] (#90) Fixes wrong API call and doc --- lib/src/saf/document_file.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/saf/document_file.dart b/lib/src/saf/document_file.dart index bc3f398..06fb8b7 100644 --- a/lib/src/saf/document_file.dart +++ b/lib/src/saf/document_file.dart @@ -110,8 +110,8 @@ class DocumentFile { /// Alias/shortname for [openDocumentFile] Future open() => openDocumentFile(); - /// {@macro sharedstorage.saf.canWrite} - Future canRead() async => saf.canWrite(uri); + /// {@macro sharedstorage.saf.canRead} + Future canRead() async => saf.canRead(uri); /// {@macro sharedstorage.saf.canWrite} Future canWrite() async => saf.canWrite(uri); From f0cc802e22a1c1fdac1d4f30f6bcc0411108431b Mon Sep 17 00:00:00 2001 From: lakscastro Date: Sun, 17 Jul 2022 18:55:31 -0300 Subject: [PATCH 10/16] (#94) Add missing `toMap` fields --- lib/src/saf/document_file.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/src/saf/document_file.dart b/lib/src/saf/document_file.dart index bc3f398..38ea27c 100644 --- a/lib/src/saf/document_file.dart +++ b/lib/src/saf/document_file.dart @@ -228,6 +228,7 @@ class DocumentFile { Map toMap() { return { + 'id': id, 'uri': '$uri', 'parentUri': '$parentUri', 'isDirectory': isDirectory, @@ -235,6 +236,8 @@ class DocumentFile { 'isVirtual': isVirtual, 'name': name, 'type': type, + 'size': size, + 'lastModified': lastModified?.millisecondsSinceEpoch, }; } From e1b3d4ed4eeee67a08c89fe5c049a00b890399a5 Mon Sep 17 00:00:00 2001 From: lakscastro Date: Sun, 17 Jul 2022 18:56:10 -0300 Subject: [PATCH 11/16] (#94) Fix `NullPointerException` --- .../sharedstorage/storageaccessframework/lib/DocumentCommon.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentCommon.kt b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentCommon.kt index 46aee0d..ca5733f 100644 --- a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentCommon.kt +++ b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentCommon.kt @@ -215,7 +215,7 @@ fun traverseDirectoryEntries( } else { false }, - type = data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String, + type = data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String?, size = data[DocumentsContract.Document.COLUMN_SIZE] as Long?, lastModified = data[DocumentsContract.Document.COLUMN_LAST_MODIFIED] as Long? ), From 8e6d64548595ce977afbf7931df0d303a378c6c1 Mon Sep 17 00:00:00 2001 From: lakscastro Date: Sun, 17 Jul 2022 19:00:39 -0300 Subject: [PATCH 12/16] (#94) Fix `KeyValueText` doc --- example/lib/widgets/key_value_text.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/lib/widgets/key_value_text.dart b/example/lib/widgets/key_value_text.dart index 6dfa3cc..db600fc 100644 --- a/example/lib/widgets/key_value_text.dart +++ b/example/lib/widgets/key_value_text.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -/// Use the entry value as [Widget] to use a [WidgetSpan] and [Text] to use a [InlineSpan] +/// Use the entry value as [Widget] to use a [WidgetSpan] and [Text] to use a [TextSpan] class KeyValueText extends StatefulWidget { const KeyValueText({Key? key, required this.entries}) : super(key: key); From ca468c59f9f5e8c18c405650d86f1ad2bdd48b3f Mon Sep 17 00:00:00 2001 From: lakscastro Date: Mon, 18 Jul 2022 14:48:18 -0300 Subject: [PATCH 13/16] (#100) Add `v0.5.0` change log --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bed20b0..d4a80f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +## 0.5.0 + +This release contains: + +- Major breaking changes. +- New API to edit existing files. +- Example project improvements. +- Bug fixes. + +To see details, refer to rollup PR [#100](https://github.com/lakscastro/shared-storage/pull/100). + +### New + +- Added `writeToFile`, `writeToFileAsString` and `writeToFileAsBytes` APIs to allow overwrite existing files by appending (`FileMode.append`) or truncating `FileMode.write` (@jfaltis). + +### Breaking changes + +- `listFiles` it's now returns a `Stream` instead of `Stream`. +- `DocumentFile.lastModified` it's now returns a `DateTime?` instead of `Future` (removed the asynchronous plugin call). +- All `DocumentFile` class fields are now nullable except by `DocumentFile.uri`. +- `createFile` doesn't requires `content` or `bytes` anymore, it's now possible to just create the file reference without defining the file data, it'll be a empty `String` by default. + +### Bug fixes + +- `DocumentFile.canRead` it's now calling the right API (`canRead`) instead of the similar one (`canWrite`). +- [Fix](https://github.com/lakscastro/shared-storage/pull/100/files#diff-6f516633fcc1095b16ad5e0cc2a2c9711ee903cb115835d703f3c0ccfd6e0d31R38-R62) infinite loading of `getDocumentThumbnail` API when thumbnail is not available. + +### Example project + +- The example project is no longer dependant of `permission_handler` plugin to request `storage` permission since it's already fully integrated with Storage Access Framework. +- File cards have now a expanded and collapsed state instead of showing all data at once. +- Icon thumbnails were added to `.apk` `image/*`, `video/*`, `text/plain` and `directories` to make easier to see what is the type of the file while navigating between the folders. +- 4 new buttons were added related to `writeToFile` API: _Write to file_ (Overwrite file contents with a predefined string), _Append to file_ (Append a predefined string to the end of the file), _Ease file content_ (Self explanatory: erase it's data but do not delete the file) and _Edit file content_ (Prompt the user with a text field to define the new file content), all buttons requires confirmation since **it can cause data loss**. +- It's now possible to create a file with a custom name through the UI (_Create a custom document_ action button on top center of the file list page). +- File card now shows the decoded uris to fix the visual pollution. + ## 0.4.2 Minimal hotfix: @@ -40,7 +76,7 @@ Minor improvements and bug fixes: Major release focused on support for `Storage Access Framework`. -### Breaking Changes +### Breaking changes - `minSdkVersion` set to `19`. - `getMediaStoreContentDirectory` return type changed to `Uri`. @@ -50,7 +86,7 @@ Major release focused on support for `Storage Access Framework`. - `import 'package:shared_storage/media_store.dart' as mediastore;` to enable **Media Store** API. - `import 'package:shared_storage/shared_storage' as sharedstorage;` if you want to import all above and as a single module (Not recommended because can conflict/override names/methods). -### New Features +### New See the label [reference here](/docs/Usage/API%20Labeling.md). From 4f5c98e7cf27c2ff0d578dc6ee4d9d2971402e5e Mon Sep 17 00:00:00 2001 From: lakscastro Date: Mon, 18 Jul 2022 14:51:38 -0300 Subject: [PATCH 14/16] (#100) Update docs to match latest breaking changes --- .../{From v0.2.0.md => Migrate to v0.3.0.md} | 4 +- docs/Migrate notes/Migrate to v0.5.0.md | 40 +++++++++++++++++++ docs/Usage/Storage Access Framework.md | 10 ++--- lib/src/saf/saf.dart | 2 +- 4 files changed, 48 insertions(+), 8 deletions(-) rename docs/Migrate notes/{From v0.2.0.md => Migrate to v0.3.0.md} (96%) create mode 100644 docs/Migrate notes/Migrate to v0.5.0.md diff --git a/docs/Migrate notes/From v0.2.0.md b/docs/Migrate notes/Migrate to v0.3.0.md similarity index 96% rename from docs/Migrate notes/From v0.2.0.md rename to docs/Migrate notes/Migrate to v0.3.0.md index 87477f4..8bf6e3a 100644 --- a/docs/Migrate notes/From v0.2.0.md +++ b/docs/Migrate notes/Migrate to v0.3.0.md @@ -7,7 +7,7 @@ dependencies: shared_storage: v0.3.0 ``` -## SDK Constraint +## SDK constraint In `android\app\build.gradle` set `android.defaultConfig.minSdkVersion` to `19`: @@ -22,7 +22,7 @@ android { } ``` -## Plugin Import +## Plugin import Although this import is still supported: diff --git a/docs/Migrate notes/Migrate to v0.5.0.md b/docs/Migrate notes/Migrate to v0.5.0.md new file mode 100644 index 0000000..afd0e64 --- /dev/null +++ b/docs/Migrate notes/Migrate to v0.5.0.md @@ -0,0 +1,40 @@ +There's major breaking changes when updating to `v0.5.0`, be careful. + +Update your `pubspec.yaml`: + +```yaml +dependencies: + shared_storage: ^0.5.0 +``` + +## Return type of `listFiles` + +Instead of: + +```dart +Stream fileStream = listFiles(uri); +``` + +use: + +```dart +Stream fileStream = listFiles(uri); +``` + +And when reading data from each file: + +```dart +// Old. +PartialDocumentFile file = ... + +String displayName = file.data![DocumentFileColumn.displayName] as String; +DateTime lastModified = DateTime.fromMillisecondsSinceEpoch(file.data![DocumentFileColumn.lastModified] as int); + +// New. +DocumentFile file = ... + +String displayName = file.name; +DateTime lastModified = file.lastModified; +``` + +It now parses all fields as class fields instead `Map` hash map. diff --git a/docs/Usage/Storage Access Framework.md b/docs/Usage/Storage Access Framework.md index 506cbb7..f8d882b 100644 --- a/docs/Usage/Storage Access Framework.md +++ b/docs/Usage/Storage Access Framework.md @@ -89,9 +89,9 @@ const List columns = [ DocumentFileColumn.mimeType, ]; -final List files = []; +final List files = []; -final Stream onNewFileLoaded = documentFileOfMyGrantedUri.listFiles(columns); +final Stream onNewFileLoaded = documentFileOfMyGrantedUri.listFiles(columns); onNewFileLoaded.listen((file) => files.add(file), onDone: () => print('All files were loaded')); ``` @@ -299,7 +299,7 @@ Returns the image thumbnail of a given `uri`, if any (e.g documents that can sho ```dart final Uint8List? imageBytes; -final PartialDocumentFile file = ... +final DocumentFile file = ... final Uri? rootUri = file.metadata?.rootUri; final String? documentId = file.data?[DocumentFileColumn.id] as String?; @@ -345,7 +345,7 @@ const List columns = [ DocumentFileColumn.mimeType, ]; -final Stream onNewFileLoaded = documentFileOfMyGrantedUri.listFiles(columns); +final Stream onNewFileLoaded = documentFileOfMyGrantedUri.listFiles(columns); ``` ### delete @@ -645,7 +645,7 @@ This class is not intended to be instantiated, and it is only used for typing an ### QueryMetadata -This class wraps useful metadata of the source queries returned by the `PartialDocumentFile`. +This class wraps useful metadata of the source queries returned by the `DocumentFile`. This class is not intended to be instantiated, and it is only used for typing and convenience purposes. diff --git a/lib/src/saf/saf.dart b/lib/src/saf/saf.dart index 5532021..7357c21 100644 --- a/lib/src/saf/saf.dart +++ b/lib/src/saf/saf.dart @@ -129,7 +129,7 @@ Future getDocumentThumbnail({ /// ```dart /// /// Usage: /// -/// final myState = []; +/// final myState = []; /// /// final onDocumentFile = listFiles(myUri, [DocumentFileColumn.id]); /// From b61990e0976e55618db25da71cc75d93d5ddda3a Mon Sep 17 00:00:00 2001 From: lakscastro Date: Mon, 18 Jul 2022 15:00:05 -0300 Subject: [PATCH 15/16] (#100) Remove `permission_handler` dependency from `/example` project --- example/pubspec.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index a5c3e65..63e59e8 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -13,7 +13,6 @@ dependencies: flutter: sdk: flutter lint: ^1.8.2 - permission_handler: ^8.1.6 shared_storage: # When depending on this package from a real application you should use: # shared_storage: ^x.y.z From 823bf8f21499ee93851b3c3a84534080a81ec3c6 Mon Sep 17 00:00:00 2001 From: lakscastro Date: Mon, 18 Jul 2022 15:01:03 -0300 Subject: [PATCH 16/16] (#100) Improve `/example` confirmation dialog description --- .../file_explorer/file_explorer_card.dart | 37 +++++++++++-------- example/lib/widgets/text_field_dialog.dart | 2 +- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/example/lib/screens/file_explorer/file_explorer_card.dart b/example/lib/screens/file_explorer/file_explorer_card.dart index 35bdf7d..88e3d82 100644 --- a/example/lib/screens/file_explorer/file_explorer_card.dart +++ b/example/lib/screens/file_explorer/file_explorer_card.dart @@ -79,14 +79,17 @@ class _FileExplorerCardState extends State { action, callback, message: [ - normal('This action '), - bold('writes'), - normal(' to this file, '), - bold('it can '), - bold(red('corrupt the file ')), - normal('or'), - bold(red(' even lose your data')), - normal(', be cautious.'), + normal('You are '), + bold('writing'), + normal(' to this file and it is '), + bold('not a reversible action'), + normal('. It can '), + bold(red('corrupt the file')), + normal(' or '), + bold(red('cause data loss')), + normal(', '), + italic('be cautious'), + normal('.'), ], ); } @@ -97,14 +100,16 @@ class _FileExplorerCardState extends State { action, callback, message: [ - normal('This action '), - bold('writes'), - normal(' to this file, '), - bold('it can '), - bold(red('corrupt the file ')), - normal('or'), - bold(red(' even lose your data')), - normal(', be cautious.'), + normal('You are '), + bold('deleting'), + normal(' this folder, this is '), + bold('not reversible'), + normal(' and '), + bold(red('can cause data loss ')), + normal('or even'), + bold(red(' corrupt some apps')), + normal(' depending on which folder you are deleting, '), + italic('be cautious.'), ], ); } diff --git a/example/lib/widgets/text_field_dialog.dart b/example/lib/widgets/text_field_dialog.dart index 0ea4eb4..d38a4dc 100644 --- a/example/lib/widgets/text_field_dialog.dart +++ b/example/lib/widgets/text_field_dialog.dart @@ -51,7 +51,7 @@ class _TextFieldDialogState extends State { ), ), actions: [ - DangerButton( + Button( 'Cancel', onTap: () => Navigator.pop(context), ),