diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..6ce78cc
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+github: [alexrintt]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f94715..2b5efe0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,28 @@
+## 0.7.0
+
+- New APIs and options.
+- There's no major breaking changes when updating to `v0.7.0` but there are deprecation notices over Media Store and Environment API.
+
+### New
+
+- `openDocument` API with single and multiple files support @honjow.
+- `openDocumentTree` it now also supports `persistablePermission` option which flags an one-time operation to avoid unused permission issues.
+
+### Deprecation notices
+
+- All non SAF APIs are deprecated (Media Store and Environment APIs), if you are using them, let us know by [opening an issue](https://github.com/alexrintt/shared-storage/issues/new) with your use-case so we can implement a new compatible API using a cross-platform approach.
+
+### Example project
+
+- Added a new button that implements `openDocument` API.
+
## 0.6.0
This release contains a severe API fixes and some minor doc changes:
### Breaking changes
-- Unused arguments in `DocumentFile.getContent` and `DocumentFile.getContentAsString`. [#107](https://github.com/alexrintt/shared-storage/issues/107).
+- Unused arguments in `DocumentFile.getContent` and `DocumentFile.getContentAsString`. [#107](https://github.com/alexrintt/shared-storage/issues/107) @clragon.
- Package import it's now done through a single import.
## 0.5.0
@@ -170,7 +188,7 @@ See the label [reference here](/docs/Usage/API%20Labeling.md).
- Mirror `getStorageDirectory` from [`Environment.getStorageDirectory`](https://developer.android.com/reference/android/os/Environment#getStorageDirectory%28%29).
-### Deprecation Notices
+### Deprecation notices
- `getExternalStoragePublicDirectory` was marked as deprecated and should be replaced with an equivalent API depending on your use-case, see [how to migrate `getExternalStoragePublicDirectory`](https://stackoverflow.com/questions/56468539/getexternalstoragepublicdirectory-deprecated-in-android-q). This deprecation is originated from official Android documentation and not by the plugin itself.
diff --git a/README.md b/README.md
index 45ec318..4fe52ae 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
-Install It
+
## Documentation
@@ -34,15 +34,20 @@ Latest changes are available on `master` branch and the actual latest published
All other branches are derivated from issues, new features or bug fixes.
+## Supporters
+
+- [aplicatii-romanesti](https://www.bibliotecaortodoxa.ro/) who bought me a whole month of caffeine!
+
## Contributors
-- [clragon](https://github.com/clragon) submitted a severe bug report and opened discussions around package architecture, thanks!
-- [jfaltis](https://github.com/jfaltis) fixed a memory leak and implemented an API to override existing files, thanks for you contribution!
-- [EternityForest](https://github.com/EternityForest) did fix a severe crash when the ID column was not provided and implemented a new feature to list all subfolders, thanks man!
-- Thanks [dhaval-k-simformsolutions](https://github.com/dhaval-k-simformsolutions) for taking time to submit bug reports related to duplicated file entries!
-- [dangilbert](https://github.com/dangilbert) pointed and fixed bug when the user doesn't select a folder, thanks man!
-- A huge thanks to [aplicatii-romanesti](https://www.bibliotecaortodoxa.ro/) for taking time to submit device specific issues and for supporting the project!
-- I would thanks [ankitparmar007](https://github.com/ankitparmar007) for discussing and requesting create file related APIs!
+- [honjow](https://github.com/honjow) contributed by [implementing `openDocument` Android API #110](https://github.com/alexrintt/shared-storage/pull/110) to pick single or multiple file URIs. Really helpful, thanks!
+- [clragon](https://github.com/clragon) submitted a severe [bug report #107](https://github.com/alexrintt/shared-storage/issues/107) and opened [discussions around package architecture #108](https://github.com/alexrintt/shared-storage/discussions/108), thanks!
+- [jfaltis](https://github.com/jfaltis) fixed [a memory leak #86](https://github.com/alexrintt/shared-storage/pull/86) and implemented an API to [override existing files #85](https://github.com/alexrintt/shared-storage/pull/85), thanks for your contribution!
+- [EternityForest](https://github.com/EternityForest) did [report a severe crash #50](https://github.com/alexrintt/shared-storage/issues/50) when the column ID was not provided and [implemented a new feature to list all subfolders #59](https://github.com/alexrintt/shared-storage/pull/59), thanks man!
+- Thanks [dhaval-k-simformsolutions](https://github.com/dhaval-k-simformsolutions) for taking time to submit [bug reports](https://github.com/alexrintt/shared-storage/issues?q=is%3Aissue+author%3Adhaval-k-simformsolutions) related to duplicated file entries!
+- [dangilbert](https://github.com/dangilbert) pointed and [fixed a bug #14](https://github.com/alexrintt/shared-storage/pull/14) when the user doesn't select a folder, thanks man!
+- A huge thanks to [aplicatii-romanesti](https://www.bibliotecaortodoxa.ro/) for taking time to submit [device specific issues](https://github.com/alexrintt/shared-storage/issues?q=author%3Aaplicatii-romanesti)!
+- I would thanks [ankitparmar007](https://github.com/ankitparmar007) for [discussing and requesting create file related APIs #20](https://github.com/alexrintt/shared-storage/issues/10)!
diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt
index 5f1bc30..ee42f84 100644
--- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt
+++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/DocumentFileApi.kt
@@ -7,6 +7,7 @@ import android.provider.DocumentsContract
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.documentfile.provider.DocumentFile
+import com.anggrayudi.storage.extension.isTreeDocumentFile
import com.anggrayudi.storage.file.child
import io.flutter.plugin.common.*
import io.flutter.plugin.common.EventChannel.StreamHandler
@@ -60,6 +61,10 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) :
result.notSupported(call.method, API_21)
}
}
+ OPEN_DOCUMENT ->
+ if (Build.VERSION.SDK_INT >= API_21) {
+ openDocument(call, result)
+ }
OPEN_DOCUMENT_TREE ->
if (Build.VERSION.SDK_INT >= API_21) {
openDocumentTree(call, result)
@@ -250,6 +255,32 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) :
}
}
+ @RequiresApi(API_21)
+ private fun openDocument(call: MethodCall, result: MethodChannel.Result) {
+ val initialUri = call.argument("initialUri")
+
+ val intent =
+ Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+
+ if (initialUri != null) {
+ val tree = DocumentFile.fromTreeUri(plugin.context, Uri.parse(initialUri))
+ if (Build.VERSION.SDK_INT >= API_26) {
+ putExtra(DocumentsContract.EXTRA_INITIAL_URI, tree?.uri)
+ }
+ }
+
+ type = call.argument("mimeType") ?: "*/*"
+ putExtra(Intent.EXTRA_ALLOW_MULTIPLE, call.argument("multiple") ?: false)
+ }
+
+ if (pendingResults[OPEN_DOCUMENT_CODE] != null) return
+
+ pendingResults[OPEN_DOCUMENT_CODE] = Pair(call, result)
+
+ plugin.binding?.activity?.startActivityForResult(intent, OPEN_DOCUMENT_CODE)
+ }
+
@RequiresApi(API_21)
private fun openDocumentTree(call: MethodCall, result: MethodChannel.Result) {
val grantWritePermission = call.argument("grantWritePermission")!!
@@ -349,7 +380,8 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) :
"isReadPermission" to it.isReadPermission,
"isWritePermission" to it.isWritePermission,
"persistedTime" to it.persistedTime,
- "uri" to "${it.uri}"
+ "uri" to "${it.uri}",
+ "isTreeDocumentFile" to it.uri.isTreeDocumentFile
)
}
.toList()
@@ -373,16 +405,19 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) :
val pendingResult = pendingResults[OPEN_DOCUMENT_TREE_CODE] ?: return false
val grantWritePermission = pendingResult.first.argument("grantWritePermission")!!
+ val persistablePermission = pendingResult.first.argument("persistablePermission")!!
try {
val uri = data?.data
if (uri != null) {
- plugin.context.contentResolver.takePersistableUriPermission(
+ if (persistablePermission) {
+ plugin.context.contentResolver.takePersistableUriPermission(
uri,
if (grantWritePermission) Intent.FLAG_GRANT_WRITE_URI_PERMISSION
else Intent.FLAG_GRANT_READ_URI_PERMISSION
- )
+ )
+ }
pendingResult.second.success("$uri")
@@ -394,6 +429,39 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) :
pendingResults.remove(OPEN_DOCUMENT_TREE_CODE)
}
}
+ OPEN_DOCUMENT_CODE -> {
+ val pendingResult = pendingResults[OPEN_DOCUMENT_CODE] ?: return false
+
+ val grantWritePermission = pendingResult.first.argument("grantWritePermission")!!
+ val persistablePermission = pendingResult.first.argument("persistablePermission")!!
+
+ try {
+ // if data.clipData not null, uriList from data.clipData, else uriList is data.data
+ val uriList = data?.clipData?.let {
+ (0 until it.itemCount).map { i -> it.getItemAt(i).uri }
+ } ?: data?.data?.let { listOf(it) }
+
+ if (uriList != null) {
+ if (persistablePermission) {
+ for (uri in uriList) {
+ plugin.context.contentResolver.takePersistableUriPermission(
+ uri,
+ if (grantWritePermission) Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ else Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+ }
+ }
+
+ pendingResult.second.success(uriList.map { "$it" })
+
+ return true
+ }
+
+ pendingResult.second.success(null)
+ } finally {
+ pendingResults.remove(OPEN_DOCUMENT_CODE)
+ }
+ }
}
return false
diff --git a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt
index 41d1de4..ad61abf 100644
--- a/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt
+++ b/android/src/main/kotlin/io/alexrintt/sharedstorage/storageaccessframework/lib/StorageAccessFrameworkConstant.kt
@@ -19,6 +19,7 @@ const val DOCUMENTS_CONTRACT_EXTRA_INITIAL_URI =
/**
* Available DocumentFile Method Channel APIs
*/
+const val OPEN_DOCUMENT = "openDocument"
const val OPEN_DOCUMENT_TREE = "openDocumentTree"
const val PERSISTED_URI_PERMISSIONS = "persistedUriPermissions"
const val RELEASE_PERSISTABLE_URI_PERMISSION = "releasePersistableUriPermission"
@@ -54,3 +55,4 @@ const val GET_DOCUMENT_CONTENT = "getDocumentContent"
* Intent Request Codes
*/
const val OPEN_DOCUMENT_TREE_CODE = 10
+const val OPEN_DOCUMENT_CODE = 11
diff --git a/docs/Migrate notes/Migrate to v0.7.0.md b/docs/Migrate notes/Migrate to v0.7.0.md
new file mode 100644
index 0000000..c25fe64
--- /dev/null
+++ b/docs/Migrate notes/Migrate to v0.7.0.md
@@ -0,0 +1,12 @@
+There's no major breaking changes when updating to `v0.7.0` but there are deprecation notices if you are using Media Store and Environment API.
+
+Update your `pubspec.yaml`:
+
+```yaml
+dependencies:
+ shared_storage: ^0.7.0
+```
+
+## Deprecation notices
+
+All non SAF APIs are deprecated, if you are using them, let us know by [opening an issue](https://github.com/alexrintt/shared-storage/issues/new) with your use-case so we can implement a new compatible API using a cross-platform approach.
diff --git a/docs/Usage/Environment.md b/docs/Usage/Environment.md
index cc519f2..3ce904a 100644
--- a/docs/Usage/Environment.md
+++ b/docs/Usage/Environment.md
@@ -1,3 +1,5 @@
+> **WARNING** This API is deprecated and will be removed soon. If you need it, please open an issue with your use-case to include in the next release as part of the new original cross-platform API.
+
## Import package
```dart
diff --git a/docs/Usage/Media Store.md b/docs/Usage/Media Store.md
index 50f76e8..b5476c0 100644
--- a/docs/Usage/Media Store.md
+++ b/docs/Usage/Media Store.md
@@ -1,3 +1,5 @@
+> **WARNING** This API is deprecated and will be removed soon. If you need it, please open an issue with your use-case to include in the next release as part of the new original cross-platform API.
+
## Import package
```dart
diff --git a/docs/Usage/Storage Access Framework.md b/docs/Usage/Storage Access Framework.md
index 7814827..a89cf0e 100644
--- a/docs/Usage/Storage Access Framework.md
+++ b/docs/Usage/Storage Access Framework.md
@@ -64,6 +64,46 @@ if (grantedUri != null) {
}
```
+### openDocument
+
+Same as `openDocumentTree` but for file URIs, you can request user to select a file and filter by:
+
+- Single or multiple files.
+- Mime type.
+
+You can also specify if you want a one-time operation (`persistablePermission` = false) and if you don't need write access (`grantWritePermission` = false).
+
+```dart
+const kDownloadsFolder =
+ 'content://com.android.externalstorage.documents/tree/primary%3ADownloads/document/primary%3ADownloads';
+
+final List? selectedDocumentUris = await openDocument(
+ // if you have a previously saved URI,
+ // you can use the specify the tree you user will see at startup of the file picker.
+ initialUri: Uri.parse(kDownloadsFolder),
+
+ // whether or not allow the user select multiple files.
+ multiple: true,
+
+ // whether or not the selected URIs should be persisted across app and device reboots.
+ persistablePermission: true,
+
+ // whether or not grant write permission required to edit file metadata (name) and it's contents.
+ grantWritePermission: true,
+
+ // whether or not filter by mime type.
+ mimeType: 'image/*' // default '*/*'
+);
+
+if (selectedDocumentUris == null) {
+ return print('User cancelled the operation.');
+}
+
+// If [selectedDocumentUris] are [persistablePermission]s then it will be returned by this function
+// along with any another URIs you've got permission over.
+final List persistedUris = await persistedUriPermissions();
+```
+
### listFiles
This method list files lazily **over a granted uri:**
diff --git a/example/lib/screens/file_explorer/file_explorer_card.dart b/example/lib/screens/file_explorer/file_explorer_card.dart
index 3e14a06..a290fb3 100644
--- a/example/lib/screens/file_explorer/file_explorer_card.dart
+++ b/example/lib/screens/file_explorer/file_explorer_card.dart
@@ -3,15 +3,13 @@ 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/shared_storage.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/document_file_utils.dart';
import '../../utils/format_bytes.dart';
import '../../utils/inline_span.dart';
import '../../utils/mime_types.dart';
@@ -115,7 +113,7 @@ class _FileExplorerCardState extends State {
}
Widget _buildMimeTypeIconThumbnail(String mimeType, {double? size}) {
- if (mimeType == kDirectoryMime) {
+ if (_isDirectory) {
return Icon(Icons.folder, size: size, color: Colors.blueGrey);
}
@@ -241,7 +239,7 @@ class _FileExplorerCardState extends State {
}
Widget _buildOpenWithButton() =>
- Button('Open with', onTap: _openFileWithExternalApp);
+ Button('Open with', onTap: _currentUri.openWithExternalApp);
Widget _buildDocumentSimplifiedTile() {
return ListTile(
@@ -319,60 +317,6 @@ class _FileExplorerCardState extends State {
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/alexrintt/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,
- ),
- );
- },
- );
- }
- }
-
Future _deleteDocument() async {
final deleted = await delete(_currentUri);
@@ -436,24 +380,6 @@ class _FileExplorerCardState extends State {
}
}
- 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);
@@ -463,7 +389,7 @@ class _FileExplorerCardState extends State {
@override
Widget build(BuildContext context) {
return SimpleCard(
- onTap: _isDirectory ? _openDirectory : _showFileContents,
+ onTap: _isDirectory ? _openDirectory : () => _file.showContents(context),
children: [
if (_expanded) ...[
_buildThumbnail(size: 50),
diff --git a/example/lib/screens/granted_uris/granted_uri_card.dart b/example/lib/screens/granted_uris/granted_uri_card.dart
index d3e25f9..b374e0f 100644
--- a/example/lib/screens/granted_uris/granted_uri_card.dart
+++ b/example/lib/screens/granted_uris/granted_uri_card.dart
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
import 'package:shared_storage/shared_storage.dart';
import '../../theme/spacing.dart';
+import '../../utils/disabled_text_style.dart';
+import '../../utils/document_file_utils.dart';
import '../../widgets/buttons.dart';
import '../../widgets/key_value_text.dart';
import '../../widgets/simple_card.dart';
+import '../file_explorer/file_explorer_card.dart';
import '../file_explorer/file_explorer_page.dart';
class GrantedUriCard extends StatefulWidget {
@@ -55,19 +58,79 @@ class _GrantedUriCardState extends State {
);
}
+ List _getTreeAvailableOptions() {
+ return [
+ ActionButton(
+ 'Create sample file',
+ onTap: () => _appendSampleFile(
+ widget.permissionUri.uri,
+ ),
+ ),
+ ActionButton(
+ 'Open file picker here',
+ onTap: () => openDocumentTree(initialUri: widget.permissionUri.uri),
+ )
+ ];
+ }
+
+ @override
+ void didUpdateWidget(covariant GrantedUriCard oldWidget) {
+ super.didUpdateWidget(oldWidget);
+
+ documentFile = null;
+ loading = false;
+ error = null;
+ }
+
+ DocumentFile? documentFile;
+ bool loading = false;
+ String? error;
+
+ Future _loadDocumentFile() async {
+ loading = true;
+ setState(() {});
+
+ documentFile = await widget.permissionUri.uri.toDocumentFile();
+ loading = false;
+
+ if (mounted) setState(() {});
+ }
+
+ Future _showDocumentFileContents() async {
+ try {
+ final documentFile = await widget.permissionUri.uri.toDocumentFile();
+
+ if (mounted) documentFile?.showContents(context);
+ } catch (e) {
+ error = e.toString();
+ }
+ }
+
+ VoidCallback get _onTapHandler => widget.permissionUri.isTreeDocumentFile
+ ? _openListFilesPage
+ : _showDocumentFileContents;
+
+ List _getDocumentAvailableOptions() {
+ return [
+ ActionButton(
+ widget.permissionUri.isTreeDocumentFile
+ ? 'Open folder'
+ : 'Open document',
+ onTap: _onTapHandler,
+ ),
+ ActionButton(
+ 'Load extra document data linked to this permission',
+ onTap: _loadDocumentFile,
+ ),
+ ];
+ }
+
Widget _buildAvailableActions() {
return Wrap(
children: [
- ActionButton(
- 'Create Sample File',
- onTap: () => _appendSampleFile(
- widget.permissionUri.uri,
- ),
- ),
- ActionButton(
- 'Open Tree Here',
- onTap: () => openDocumentTree(initialUri: widget.permissionUri.uri),
- ),
+ if (widget.permissionUri.isTreeDocumentFile)
+ ..._getTreeAvailableOptions(),
+ ..._getDocumentAvailableOptions(),
Padding(padding: k2dp.all),
DangerButton(
'Revoke',
@@ -86,6 +149,7 @@ class _GrantedUriCardState extends State {
'isReadPermission': '${widget.permissionUri.isReadPermission}',
'persistedTime': '${widget.permissionUri.persistedTime}',
'uri': Uri.decodeFull('${widget.permissionUri.uri}'),
+ 'isTreeDocumentFile': '${widget.permissionUri.isTreeDocumentFile}',
},
);
}
@@ -93,10 +157,34 @@ class _GrantedUriCardState extends State {
@override
Widget build(BuildContext context) {
return SimpleCard(
- onTap: _openListFilesPage,
+ onTap: _onTapHandler,
children: [
+ Padding(
+ padding: k2dp.all.copyWith(top: k8dp, bottom: k8dp),
+ child: Icon(
+ widget.permissionUri.isTreeDocumentFile
+ ? Icons.folder
+ : Icons.file_copy_sharp,
+ color: disabledColor(),
+ ),
+ ),
_buildGrantedUriMetadata(),
_buildAvailableActions(),
+ if (loading)
+ const SizedBox(
+ height: 20,
+ width: 20,
+ child: CircularProgressIndicator(),
+ )
+ else if (error != null)
+ Text('Error was thrown: $error')
+ else if (documentFile != null)
+ FileExplorerCard(
+ documentFile: documentFile!,
+ didUpdateDocument: (updatedDocumentFile) {
+ documentFile = updatedDocumentFile;
+ },
+ )
],
);
}
diff --git a/example/lib/screens/granted_uris/granted_uris_page.dart b/example/lib/screens/granted_uris/granted_uris_page.dart
index 91eaeba..133759c 100644
--- a/example/lib/screens/granted_uris/granted_uris_page.dart
+++ b/example/lib/screens/granted_uris/granted_uris_page.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:shared_storage/shared_storage.dart';
import '../../theme/spacing.dart';
+import '../../utils/disabled_text_style.dart';
import '../../widgets/light_text.dart';
import 'granted_uri_card.dart';
@@ -13,7 +14,13 @@ class GrantedUrisPage extends StatefulWidget {
}
class _GrantedUrisPageState extends State {
- List? persistedPermissionUris;
+ List? __persistedPermissionUris;
+ List? get _persistedPermissionUris {
+ if (__persistedPermissionUris == null) return null;
+
+ return List.from(__persistedPermissionUris!)
+ ..sort((a, z) => z.persistedTime - a.persistedTime);
+ }
@override
void initState() {
@@ -23,7 +30,7 @@ class _GrantedUrisPageState extends State {
}
Future _loadPersistedUriPermissions() async {
- persistedPermissionUris = await persistedUriPermissions();
+ __persistedPermissionUris = await persistedUriPermissions();
if (mounted) setState(() => {});
}
@@ -41,11 +48,25 @@ class _GrantedUrisPageState extends State {
await _loadPersistedUriPermissions();
}
+ Future _openDocument() async {
+ const kDownloadsFolder =
+ 'content://com.android.externalstorage.documents/tree/primary%3ADownloads/document/primary%3ADownloads';
+
+ final List? selectedDocumentUris = await openDocument(
+ initialUri: Uri.parse(kDownloadsFolder),
+ multiple: true,
+ );
+
+ if (selectedDocumentUris == null) return;
+
+ await _loadPersistedUriPermissions();
+ }
+
Widget _buildNoFolderAllowedYetWarning() {
return Padding(
padding: k8dp.all,
child: const Center(
- child: LightText('No folders allowed yet'),
+ child: LightText('No folders or files allowed yet'),
),
);
}
@@ -66,22 +87,39 @@ class _GrantedUrisPageState extends State {
delegate: SliverChildListDelegate(
[
Center(
- child: TextButton(
- onPressed: _openDocumentTree,
- child: const Text('New allowed folder'),
+ child: Wrap(
+ alignment: WrapAlignment.center,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ runAlignment: WrapAlignment.center,
+ children: [
+ TextButton(
+ onPressed: _openDocumentTree,
+ child: const Text('New allowed folder'),
+ ),
+ const Padding(padding: EdgeInsets.all(k2dp)),
+ TextButton(
+ onPressed: _openDocument,
+ child: const Text('New allowed files'),
+ ),
+ ],
),
),
- if (persistedPermissionUris != null)
- if (persistedPermissionUris!.isEmpty)
+ if (_persistedPermissionUris != null)
+ if (_persistedPermissionUris!.isEmpty)
_buildNoFolderAllowedYetWarning()
else
- for (final permissionUri in persistedPermissionUris!)
+ for (final permissionUri in _persistedPermissionUris!)
GrantedUriCard(
permissionUri: permissionUri,
onChange: _loadPersistedUriPermissions,
)
else
- const Text('Loading...'),
+ Center(
+ child: Text(
+ 'Loading...',
+ style: disabledTextStyle(),
+ ),
+ ),
],
),
),
diff --git a/example/lib/utils/document_file_utils.dart b/example/lib/utils/document_file_utils.dart
new file mode 100644
index 0000000..0167d85
--- /dev/null
+++ b/example/lib/utils/document_file_utils.dart
@@ -0,0 +1,90 @@
+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 'disabled_text_style.dart';
+import 'mime_types.dart';
+
+extension ShowText on BuildContext {
+ Future showToast(String text, {Duration? duration}) {
+ return showTextToast(
+ text: text,
+ context: this,
+ duration: const Duration(seconds: 5),
+ );
+ }
+}
+
+extension OpenUriWithExternalApp on Uri {
+ Future openWithExternalApp() async {
+ final uri = this;
+
+ 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",
+ );
+ }
+ }
+}
+
+extension ShowDocumentFileContents on DocumentFile {
+ Future showContents(BuildContext context) async {
+ final mimeTypeOrEmpty = type ?? '';
+ final sizeInBytes = size ?? 0;
+
+ const k10mb = 1024 * 1024 * 10;
+
+ if (!mimeTypeOrEmpty.startsWith(kTextMime) &&
+ !mimeTypeOrEmpty.startsWith(kImageMime)) {
+ if (mimeTypeOrEmpty == kApkMime) {
+ return context.showToast(
+ 'Requesting to install a package (.apk) is not currently supported, to request this feature open an issue at github.com/alexrintt/shared-storage/issues',
+ );
+ }
+
+ return uri.openWithExternalApp();
+ }
+
+ // Too long, will take too much time to read
+ if (sizeInBytes > k10mb) {
+ return context.showToast('File too long to open');
+ }
+
+ final content = await getDocumentContent(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,
+ ),
+ );
+ },
+ );
+ }
+ }
+}
diff --git a/lib/src/environment/environment.dart b/lib/src/environment/environment.dart
index c22698a..e36df37 100644
--- a/lib/src/environment/environment.dart
+++ b/lib/src/environment/environment.dart
@@ -7,6 +7,9 @@ import 'environment_directory.dart';
/// Equivalent to `Environment.getRootDirectory`
///
/// [Refer to details](https://developer.android.com/reference/android/os/Environment#getRootDirectory%28%29)
+@Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+)
Future getRootDirectory() async {
const kGetRootDirectory = 'getRootDirectory';
@@ -49,6 +52,9 @@ Future getExternalStoragePublicDirectory(
/// Equivalent to `Environment.getExternalStorageDirectory`
///
/// [Refer to details](https://developer.android.com/reference/android/os/Environment#getExternalStorageDirectory%28%29)
+@Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+)
Future getExternalStorageDirectory() async {
const kGetExternalStorageDirectory = 'getExternalStorageDirectory';
@@ -58,6 +64,9 @@ Future getExternalStorageDirectory() async {
/// Equivalent to `Environment.getDataDirectory`
///
/// [Refer to details](https://developer.android.com/reference/android/os/Environment#getDataDirectory%28%29)
+@Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+)
Future getDataDirectory() async {
const kGetDataDirectory = 'getDataDirectory';
@@ -67,6 +76,9 @@ Future getDataDirectory() async {
/// Equivalent to `Environment.getDataDirectory`
///
/// [Refer to details](https://developer.android.com/reference/android/os/Environment#getDownloadCacheDirectory%28%29)
+@Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+)
Future getDownloadCacheDirectory() async {
const kGetDownloadCacheDirectory = 'getDownloadCacheDirectory';
@@ -76,6 +88,9 @@ Future getDownloadCacheDirectory() async {
/// Equivalent to `Environment.getStorageDirectory`
///
/// [Refer to details](https://developer.android.com/reference/android/os/Environment#getStorageDirectory%28%29)
+@Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+)
Future getStorageDirectory() {
const kGetStorageDirectory = 'getStorageDirectory';
diff --git a/lib/src/environment/environment_directory.dart b/lib/src/environment/environment_directory.dart
index 2b900f4..2e1657c 100644
--- a/lib/src/environment/environment_directory.dart
+++ b/lib/src/environment/environment_directory.dart
@@ -20,49 +20,76 @@ class EnvironmentDirectory {
/// Available for Android [4.1 to 9.0]
///
/// Equivalent to [Environment.DIRECTORY_ALARMS]
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
static const alarms = EnvironmentDirectory._('$_kPrefix.Alarms');
/// Available for Android [4.1 to 9]
///
/// Equivalent to:
/// - [Environment.DIRECTORY_DCIM] on Android [4.1 to 9]
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
static const dcim = EnvironmentDirectory._('$_kPrefix.DCIM');
/// Available for Android [4.1 to 9]
///
/// Equivalent to:
/// - [Environment.DIRECTORY_DOWNLOADS] on Android [4.1 to 9]
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
static const downloads = EnvironmentDirectory._('$_kPrefix.Downloads');
/// Available for Android [4.1 to 9]
///
/// - [Environment.DIRECTORY_MOVIES] on Android [4.1 to 9]
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
static const movies = EnvironmentDirectory._('$_kPrefix.Movies');
/// Available for Android [4.1 to 9]
///
/// - [Environment.DIRECTORY_MUSIC] on Android [4.1 to 9]
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
static const music = EnvironmentDirectory._('$_kPrefix.Music');
/// Available for Android [4.1 to 9]
///
/// - [Environment.DIRECTORY_NOTIFICATIONS] on Android [4.1 to 9]
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
static const notifications =
EnvironmentDirectory._('$_kPrefix.Notifications');
/// Available for Android [4.1 to 9]
///
/// - [Environment.DIRECTORY_PICTURES] on Android [4.1 to 9]
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
static const pictures = EnvironmentDirectory._('$_kPrefix.Pictures');
/// Available for Android [4.1 to 9]
///
/// - [Environment.DIRECTORY_PODCASTS] on Android [4.1 to 9]
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
static const podcasts = EnvironmentDirectory._('$_kPrefix.Podcasts');
/// Available for Android [4.1 to 9]
///
/// - [Environment.DIRECTORY_RINGTONES] on Android [4.1 to 9]
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
static const ringtones = EnvironmentDirectory._('$_kPrefix.Ringtones');
@override
diff --git a/lib/src/media_store/media_store.dart b/lib/src/media_store/media_store.dart
index 2a7b425..08a12df 100644
--- a/lib/src/media_store/media_store.dart
+++ b/lib/src/media_store/media_store.dart
@@ -6,6 +6,9 @@ import 'media_store_collection.dart';
/// Get the directory of a given [MediaStoreCollection]
///
/// [Refer to details](https://developer.android.com/reference/android/provider/MediaStore#summary)
+@Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+)
Future getMediaStoreContentDirectory(
MediaStoreCollection collection,
) async {
diff --git a/lib/src/media_store/media_store_collection.dart b/lib/src/media_store/media_store_collection.dart
index 6f571e3..ed694cc 100644
--- a/lib/src/media_store/media_store_collection.dart
+++ b/lib/src/media_store/media_store_collection.dart
@@ -12,24 +12,36 @@ class MediaStoreCollection {
///
/// Equivalent to:
/// - [MediaStore.Audio]
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
static const audio = MediaStoreCollection._('$_kPrefix.Audio');
/// Available for Android [10 to 12]
///
/// Equivalent to:
/// - [MediaStore.Downloads]
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
static const downloads = MediaStoreCollection._('$_kPrefix.Downloads');
/// Available for Android [10 to 12]
///
/// Equivalent to:
/// - [MediaStore.Images]
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
static const images = MediaStoreCollection._('$_kPrefix.Images');
/// Available for Android [10 to 12]
///
/// Equivalent to:
/// - [MediaStore.Video]
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
static const video = MediaStoreCollection._('$_kPrefix.Video');
@override
@@ -41,5 +53,8 @@ class MediaStoreCollection {
int get hashCode => id.hashCode;
@override
+ @Deprecated(
+ 'Android specific APIs will be removed soon in order to be replaced with a new set of original cross-platform APIs.',
+ )
String toString() => id;
}
diff --git a/lib/src/saf/saf.dart b/lib/src/saf/saf.dart
index 7357c21..b505bf4 100644
--- a/lib/src/saf/saf.dart
+++ b/lib/src/saf/saf.dart
@@ -20,12 +20,14 @@ import 'common.dart';
/// {@endtemplate}
Future openDocumentTree({
bool grantWritePermission = true,
+ bool persistablePermission = true,
Uri? initialUri,
}) async {
const kOpenDocumentTree = 'openDocumentTree';
final args = {
'grantWritePermission': grantWritePermission,
+ 'persistablePermission': persistablePermission,
if (initialUri != null) 'initialUri': '$initialUri',
};
@@ -35,6 +37,31 @@ Future openDocumentTree({
return selectedDirectoryUri?.apply((e) => Uri.parse(e));
}
+/// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT).
+Future?> openDocument({
+ Uri? initialUri,
+ bool grantWritePermission = true,
+ bool persistablePermission = true,
+ String mimeType = '*/*',
+ bool multiple = false,
+}) async {
+ const kOpenDocument = 'openDocument';
+
+ final args = {
+ if (initialUri != null) 'initialUri': '$initialUri',
+ 'grantWritePermission': grantWritePermission,
+ 'persistablePermission': persistablePermission,
+ 'mimeType': mimeType,
+ 'multiple': multiple,
+ };
+
+ final selectedUriList =
+ await kDocumentFileChannel.invokeListMethod(kOpenDocument, args);
+
+ return selectedUriList
+ ?.apply((e) => e.map((e) => Uri.parse(e as String)).toList());
+}
+
/// {@template sharedstorage.saf.persistedUriPermissions}
/// Returns an `List` with all persisted [Uri]
///
diff --git a/lib/src/saf/uri_permission.dart b/lib/src/saf/uri_permission.dart
index d0327ac..5ec93d9 100644
--- a/lib/src/saf/uri_permission.dart
+++ b/lib/src/saf/uri_permission.dart
@@ -2,7 +2,7 @@
/// This grants may have been created via `Intent#FLAG_GRANT_READ_URI_PERMISSION`,
/// etc when sending an `Intent`, or explicitly through `Context#grantUriPermission(String, android.net.Uri, int)`.
///
-/// [Refer to details](https://developer.android.com/reference/android/content/UriPermission)
+/// [Refer to details](https://developer.android.com/reference/android/content/UriPermission).
class UriPermission {
/// Even we allow create instances of this class avoid it and use
/// `persistedUriPermissions` API instead
@@ -11,6 +11,7 @@ class UriPermission {
required this.isWritePermission,
required this.persistedTime,
required this.uri,
+ required this.isTreeDocumentFile,
});
factory UriPermission.fromMap(Map map) {
@@ -19,6 +20,7 @@ class UriPermission {
isWritePermission: map['isWritePermission'] as bool,
persistedTime: map['persistedTime'] as int,
uri: Uri.parse(map['uri'] as String),
+ isTreeDocumentFile: map['isTreeDocumentFile'] as bool,
);
}
@@ -40,17 +42,33 @@ class UriPermission {
/// [Refer to details](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/content/UriPermission.java#56)
final Uri uri;
+ /// Whether or not a tree document file.
+ ///
+ /// Tree document files are granted through [openDocumentTree] method, that is, when the user select a folder-like tree document file.
+ /// Document files are granted through [openDocument] method, that is, when the user select (a) specific(s) document files.
+ ///
+ /// Roughly you may consider it as a property to verify if [this] permission is over a folder or a single-file.
+ final bool isTreeDocumentFile;
+
@override
bool operator ==(Object other) =>
other is UriPermission &&
isReadPermission == other.isReadPermission &&
isWritePermission == other.isWritePermission &&
persistedTime == other.persistedTime &&
- uri == other.uri;
+ uri == other.uri &&
+ isTreeDocumentFile == other.isTreeDocumentFile;
@override
- int get hashCode =>
- Object.hash(isReadPermission, isWritePermission, persistedTime, uri);
+ int get hashCode => Object.hashAll(
+ [
+ isReadPermission,
+ isWritePermission,
+ persistedTime,
+ uri,
+ isTreeDocumentFile,
+ ],
+ );
Map toMap() {
return {
@@ -58,6 +76,7 @@ class UriPermission {
'isWritePermission': isWritePermission,
'persistedTime': persistedTime,
'uri': '$uri',
+ 'isTreeDocumentFile': isTreeDocumentFile,
};
}
@@ -66,5 +85,6 @@ class UriPermission {
'isReadPermission: $isReadPermission, '
'isWritePermission: $isWritePermission, '
'persistedTime: $persistedTime, '
- 'uri: $uri)';
+ 'uri: $uri, '
+ 'isTreeDocumentFile: $isTreeDocumentFile)';
}
diff --git a/pubspec.yaml b/pubspec.yaml
index c83f734..69bcc78 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,6 +1,6 @@
name: shared_storage
description: "Flutter plugin to work with external storage and privacy-friendly APIs."
-version: 0.6.0
+version: 0.7.0
homepage: https://github.com/alexrintt/shared-storage
repository: https://github.com/alexrintt/shared-storage
issue_tracker: https://github.com/alexrintt/shared-storage/issues