├── .github ├── FUNDING.yml ├── workflows │ ├── static-analysis.yaml │ ├── build-docs.yaml │ └── publish.yaml └── ISSUE_TEMPLATE │ └── bug_report.md ├── android ├── settings.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ └── io │ │ └── alexrintt │ │ └── sharedstorage │ │ ├── utils │ │ ├── ActivityListener.kt │ │ ├── Listenable.kt │ │ ├── PluginConstant.kt │ │ └── Common.kt │ │ ├── deprecated │ │ ├── StorageAccessFrameworkApi.kt │ │ ├── lib │ │ │ ├── StorageAccessFrameworkConstant.kt │ │ │ ├── DocumentFileColumn.kt │ │ │ └── DocumentCommon.kt │ │ ├── DocumentFileHelperApi.kt │ │ └── DocumentsContractApi.kt │ │ └── SharedStoragePlugin.kt ├── .gitignore └── build.gradle ├── lib ├── shared_storage.dart ├── saf.dart ├── src │ ├── channels.dart │ ├── saf │ │ ├── common.dart │ │ ├── document_bitmap.dart │ │ ├── document_file_column.dart │ │ ├── uri_permission.dart │ │ ├── document_file.dart │ │ └── saf.dart │ └── common │ │ └── functional_extender.dart └── shared_storage_platform_interface.dart ├── example ├── android │ ├── gradle.properties │ ├── app │ │ ├── src │ │ │ ├── main │ │ │ │ ├── res │ │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── drawable │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ ├── drawable-v21 │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ ├── values │ │ │ │ │ │ └── styles.xml │ │ │ │ │ └── values-night │ │ │ │ │ │ └── styles.xml │ │ │ │ ├── kotlin │ │ │ │ │ └── io │ │ │ │ │ │ └── alexrintt │ │ │ │ │ │ └── sharedstorage │ │ │ │ │ │ └── example │ │ │ │ │ │ └── MainActivity.kt │ │ │ │ └── AndroidManifest.xml │ │ │ ├── debug │ │ │ │ └── AndroidManifest.xml │ │ │ └── profile │ │ │ │ └── AndroidManifest.xml │ │ └── build.gradle │ ├── gradle │ │ └── wrapper │ │ │ └── gradle-wrapper.properties │ ├── .gitignore │ ├── settings.gradle │ └── build.gradle ├── lib │ ├── utils │ │ ├── take_if.dart │ │ ├── mime_types.dart │ │ ├── disabled_text_style.dart │ │ ├── apply_if_not_null.dart │ │ ├── format_bytes.dart │ │ ├── inline_span.dart │ │ ├── confirm_decorator.dart │ │ └── document_file_utils.dart │ ├── widgets │ │ ├── light_text.dart │ │ ├── simple_card.dart │ │ ├── buttons.dart │ │ ├── confirmation_dialog.dart │ │ ├── key_value_text.dart │ │ └── text_field_dialog.dart │ ├── main.dart │ ├── theme │ │ └── spacing.dart │ └── screens │ │ ├── granted_uris │ │ ├── granted_uris_page.dart │ │ └── granted_uri_card.dart │ │ └── file_explorer │ │ ├── file_explorer_page.dart │ │ └── file_explorer_card.dart ├── README.md ├── analysis_options.yaml ├── .gitignore └── pubspec.yaml ├── .editorconfig ├── docs ├── Contributing │ ├── Ways to contribute.md │ └── Setup environment │ │ ├── Debugging plugin.md │ │ └── Setup local environment.md ├── Migrate notes │ ├── Migrate to v0.7.0.md │ ├── Migrate to v0.5.0.md │ ├── Migrate to v0.6.0.md │ └── Migrate to v0.3.0.md ├── Usage │ ├── Media Store.md │ ├── API Labeling.md │ └── Environment.md └── index.md ├── pubspec.yaml ├── analysis_options.yaml ├── .pubignore ├── .gitignore ├── LICENSE ├── mkdocs.yaml ├── README.md ├── CODE_OF_CONDUCT.md └── CHANGELOG.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [alexrintt] 2 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'sharedstorage' 2 | -------------------------------------------------------------------------------- /lib/shared_storage.dart: -------------------------------------------------------------------------------- 1 | library shared_storage; 2 | 3 | export './saf.dart'; 4 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /example/android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexcmgit/shared-storage/HEAD/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexcmgit/shared-storage/HEAD/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexcmgit/shared-storage/HEAD/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexcmgit/shared-storage/HEAD/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexcmgit/shared-storage/HEAD/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/lib/utils/take_if.dart: -------------------------------------------------------------------------------- 1 | extension TakeIf on T { 2 | T? takeIf(bool Function(T) predicate) { 3 | final T self = this; 4 | 5 | return predicate(self) ? this : null; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/saf.dart: -------------------------------------------------------------------------------- 1 | export './src/saf/document_bitmap.dart'; 2 | export './src/saf/document_file.dart'; 3 | export './src/saf/document_file_column.dart'; 4 | export './src/saf/saf.dart'; 5 | export './src/saf/uri_permission.dart'; 6 | -------------------------------------------------------------------------------- /example/android/app/src/main/kotlin/io/alexrintt/sharedstorage/example/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package io.alexrintt.sharedstorage.example 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip 6 | -------------------------------------------------------------------------------- /android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /example/lib/utils/mime_types.dart: -------------------------------------------------------------------------------- 1 | const kTextPlainMime = 'text/plain'; 2 | const kApkMime = 'application/vnd.android.package-archive'; 3 | const kImageMime = 'image/'; 4 | const kTextMime = 'text/'; 5 | const kDirectoryMime = 'vnd.android.document/directory'; 6 | const kVideoMime = 'video/'; 7 | -------------------------------------------------------------------------------- /example/android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Feb 20 00:30:33 BRT 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /example/lib/utils/disabled_text_style.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | TextStyle disabledTextStyle() { 4 | return TextStyle( 5 | color: disabledColor(), 6 | fontStyle: FontStyle.italic, 7 | ); 8 | } 9 | 10 | Color disabledColor() { 11 | return Colors.black26; 12 | } 13 | -------------------------------------------------------------------------------- /example/lib/utils/apply_if_not_null.dart: -------------------------------------------------------------------------------- 1 | extension ApplyIfNotNull on T? { 2 | R? apply(R Function(T) f) { 3 | // Local variable to allow automatic type promotion. Also see: 4 | // 5 | final T? self = this; 6 | return (self == null) ? null : f(self); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /example/android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /example/lib/utils/format_bytes.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | String formatBytes(int bytes, int decimals) { 4 | if (bytes <= 0) return '0 B'; 5 | 6 | const suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 7 | 8 | final i = (log(bytes) / log(1024)).floor(); 9 | 10 | return '${(bytes / pow(1024, i)).toStringAsFixed(decimals)} ${suffixes[i]}'; 11 | } 12 | -------------------------------------------------------------------------------- /example/android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /example/android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | # possible values: number (e.g. 2), "unset" (makes ktlint ignore indentation completely) 3 | indent_size=2 4 | # true (recommended) / false 5 | insert_final_newline=true 6 | # possible values: number (e.g. 120) (package name, imports & comments are ignored), "off" 7 | # it's automatically set to 100 on `ktlint --android ...` (per Android Kotlin Style Guide) 8 | max_line_length=80 -------------------------------------------------------------------------------- /android/src/main/kotlin/io/alexrintt/sharedstorage/utils/ActivityListener.kt: -------------------------------------------------------------------------------- 1 | package io.alexrintt.sharedstorage.utils 2 | 3 | /** 4 | * Interface shared across API classes to make intuitive and clean [init] and [dispose] plugin 5 | * lifecycle of [Activity] listener resources 6 | */ 7 | interface ActivityListener { 8 | fun startListeningToActivity() 9 | fun stopListeningToActivity() 10 | } 11 | -------------------------------------------------------------------------------- /docs/Contributing/Ways to contribute.md: -------------------------------------------------------------------------------- 1 | ### How can I contribute? 2 | 3 | Code is not the only thing matters when we talk about packages and open-source libraries, there's a lot of another tasks which are equally important: 4 | 5 | - Reporting bugs. 6 | - Creating show-cases. 7 | - Improving documentation. 8 | - Asking questions. 9 | - Answering questions. 10 | - Sharing ideas. 11 | - Reporting usage issues (Something looks wrong on API?). 12 | -------------------------------------------------------------------------------- /android/src/main/kotlin/io/alexrintt/sharedstorage/utils/Listenable.kt: -------------------------------------------------------------------------------- 1 | package io.alexrintt.sharedstorage.utils 2 | 3 | import io.flutter.plugin.common.BinaryMessenger 4 | 5 | /** 6 | * Interface shared across API classes to make intuitive and clean [init] and [dispose] plugin 7 | * lifecycle of [MethodCallHandler] resources 8 | */ 9 | interface Listenable { 10 | fun startListening(binaryMessenger: BinaryMessenger) 11 | fun stopListening() 12 | } 13 | -------------------------------------------------------------------------------- /example/lib/widgets/light_text.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | 3 | class LightText extends StatelessWidget { 4 | const LightText(this.text, {Key? key}) : super(key: key); 5 | 6 | final String text; 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Text( 11 | text, 12 | textAlign: TextAlign.center, 13 | style: TextStyle( 14 | color: const Color(0xFF000000).withOpacity(.2), 15 | ), 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example/android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | def properties = new Properties() 5 | 6 | assert localPropertiesFile.exists() 7 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | 9 | def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /docs/Migrate notes/Migrate to v0.7.0.md: -------------------------------------------------------------------------------- 1 | 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. 2 | 3 | Update your `pubspec.yaml`: 4 | 5 | ```yaml 6 | dependencies: 7 | shared_storage: ^0.7.0 8 | ``` 9 | 10 | ## Deprecation notices 11 | 12 | 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. 13 | -------------------------------------------------------------------------------- /example/lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'screens/granted_uris/granted_uris_page.dart'; 3 | 4 | /// TODO: Add examples using [Environment] and [MediaStore] API 5 | void main() => runApp(const Root()); 6 | 7 | class Root extends StatefulWidget { 8 | const Root({Key? key}) : super(key: key); 9 | 10 | @override 11 | _RootState createState() => _RootState(); 12 | } 13 | 14 | class _RootState extends State { 15 | @override 16 | Widget build(BuildContext context) { 17 | return const MaterialApp(home: GrantedUrisPage()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/channels.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/services.dart'; 2 | 3 | const String kRootChannel = 'io.alexrintt.plugins/sharedstorage'; 4 | 5 | const MethodChannel kDocumentFileChannel = 6 | MethodChannel('$kRootChannel/documentfile'); 7 | 8 | const MethodChannel kDocumentsContractChannel = 9 | MethodChannel('$kRootChannel/documentscontract'); 10 | 11 | const MethodChannel kDocumentFileHelperChannel = 12 | MethodChannel('$kRootChannel/documentfilehelper'); 13 | 14 | const EventChannel kDocumentFileEventChannel = 15 | EventChannel('$kRootChannel/event/documentfile'); 16 | -------------------------------------------------------------------------------- /lib/src/saf/common.dart: -------------------------------------------------------------------------------- 1 | import '../channels.dart'; 2 | import 'document_file.dart'; 3 | 4 | /// Helper method to invoke a native SAF method and return a document file 5 | /// if not null, shouldn't be called directly from non-package code 6 | Future invokeMapMethod( 7 | String method, 8 | Map args, 9 | ) async { 10 | final Map? documentMap = 11 | await kDocumentFileChannel.invokeMapMethod(method, args); 12 | 13 | if (documentMap == null) return null; 14 | 15 | return DocumentFile.fromMap(documentMap); 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yaml: -------------------------------------------------------------------------------- 1 | name: Perform static code analysis through Dart CLI 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | - release 9 | 10 | jobs: 11 | static_analysis: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: subosito/flutter-action@v2 17 | with: 18 | flutter-version: "3.7.7" 19 | channel: "stable" 20 | - run: | 21 | flutter --version 22 | flutter pub get 23 | flutter analyze --fatal-infos 24 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # sharedstorage_example 2 | 3 | Demonstrates how to use the shared_storage plugin. 4 | 5 | ## Getting Started 6 | 7 | This project is a starting point for a Flutter application. 8 | 9 | A few resources to get you started if this is your first Flutter project: 10 | 11 | - [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) 12 | - [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) 13 | 14 | For help getting started with Flutter, view our 15 | [online documentation](https://flutter.dev/docs), which offers tutorials, 16 | samples, guidance on mobile development, and a full API reference. 17 | -------------------------------------------------------------------------------- /example/android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.8.10' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:7.2.0' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | } 12 | } 13 | 14 | allprojects { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | 21 | rootProject.buildDir = '../build' 22 | subprojects { 23 | project.buildDir = "${rootProject.buildDir}/${project.name}" 24 | project.evaluationDependsOn(':app') 25 | } 26 | 27 | task clean(type: Delete) { 28 | delete rootProject.buildDir 29 | } 30 | -------------------------------------------------------------------------------- /example/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lint/analysis_options.yaml 2 | 3 | # Packages, that may be distributed (i.e. via pub.dev) should use the package 4 | # version, resulting in a better pub score. 5 | # include: package:lint/analysis_options_package.yaml 6 | 7 | # You might want to exclude auto-generated files from dart analysis 8 | analyzer: 9 | exclude: 10 | #- '**.freezed.dart' 11 | 12 | # You can customize the lint rules set to your own liking. A list of all rules 13 | # can be found at https://dart-lang.github.io/linter/lints/options/options.html 14 | linter: 15 | rules: 16 | sort_constructors_first: true 17 | prefer_single_quotes: true 18 | prefer_relative_imports: true 19 | always_use_package_imports: false 20 | avoid_relative_lib_imports: false 21 | avoid_print: false 22 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: shared_storage 2 | description: "Flutter plugin to work with external storage and privacy-friendly APIs." 3 | version: 0.7.0 4 | homepage: https://github.com/alexrintt/shared-storage 5 | repository: https://github.com/alexrintt/shared-storage 6 | issue_tracker: https://github.com/alexrintt/shared-storage/issues 7 | documentation: https://github.com/alexrintt/shared-storage 8 | 9 | environment: 10 | sdk: ">=2.16.0 <3.0.0" 11 | flutter: ">=2.5.0" 12 | 13 | dependencies: 14 | flutter: 15 | sdk: flutter 16 | plugin_platform_interface: ^2.0.2 17 | 18 | dev_dependencies: 19 | flutter_test: 20 | sdk: flutter 21 | lint: ^1.8.2 22 | 23 | flutter: 24 | plugin: 25 | platforms: 26 | android: 27 | package: io.alexrintt.sharedstorage 28 | pluginClass: SharedStoragePlugin 29 | -------------------------------------------------------------------------------- /android/src/main/kotlin/io/alexrintt/sharedstorage/utils/PluginConstant.kt: -------------------------------------------------------------------------------- 1 | package io.alexrintt.sharedstorage.utils 2 | 3 | import android.os.Build 4 | 5 | /** Generic exceptions */ 6 | const val EXCEPTION_NOT_SUPPORTED = "EXCEPTION_NOT_SUPPORTED" 7 | 8 | /** API level constants by version codes */ 9 | const val API_18 = Build.VERSION_CODES.JELLY_BEAN_MR2 10 | const val API_19 = Build.VERSION_CODES.KITKAT 11 | const val API_20 = Build.VERSION_CODES.KITKAT_WATCH 12 | const val API_21 = Build.VERSION_CODES.LOLLIPOP 13 | const val API_23 = Build.VERSION_CODES.M 14 | const val API_24 = Build.VERSION_CODES.N 15 | const val API_25 = Build.VERSION_CODES.N_MR1 16 | const val API_26 = Build.VERSION_CODES.O 17 | const val API_29 = Build.VERSION_CODES.Q 18 | const val API_28 = Build.VERSION_CODES.P 19 | const val API_30 = Build.VERSION_CODES.R 20 | -------------------------------------------------------------------------------- /.github/workflows/build-docs.yaml: -------------------------------------------------------------------------------- 1 | name: Build MkDocs 2 | on: 3 | workflow_dispatch: 4 | workflow_run: 5 | workflows: ["Publish new plugin version"] 6 | types: 7 | - completed 8 | push: 9 | branches: 10 | - release 11 | 12 | jobs: 13 | sync: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: subosito/flutter-action@v2 19 | with: 20 | flutter-version: "3.7.7" 21 | channel: "stable" 22 | - run: | 23 | flutter --version 24 | flutter pub get 25 | flutter analyze --fatal-infos 26 | 27 | - name: Deploy docs 28 | uses: mhausenblas/mkdocs-deploy-gh-pages@master 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | CONFIG_FILE: mkdocs.yaml 32 | EXTRA_PACKAGES: build-base 33 | -------------------------------------------------------------------------------- /example/lib/widgets/simple_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class SimpleCard extends StatefulWidget { 4 | const SimpleCard({Key? key, required this.onTap, required this.children}) 5 | : super(key: key); 6 | 7 | final VoidCallback onTap; 8 | final List children; 9 | 10 | @override 11 | _SimpleCardState createState() => _SimpleCardState(); 12 | } 13 | 14 | class _SimpleCardState extends State { 15 | @override 16 | Widget build(BuildContext context) { 17 | return GestureDetector( 18 | onTap: widget.onTap, 19 | child: Card( 20 | child: Padding( 21 | padding: const EdgeInsets.all(12), 22 | child: Column( 23 | crossAxisAlignment: CrossAxisAlignment.start, 24 | children: widget.children, 25 | ), 26 | ), 27 | ), 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lint/analysis_options.yaml 2 | 3 | # Packages, that may be distributed (i.e. via pub.dev) should use the package 4 | # version, resulting in a better pub score. 5 | # include: package:lint/analysis_options_package.yaml 6 | 7 | # You might want to exclude auto-generated files from dart analysis 8 | analyzer: 9 | exclude: 10 | #- '**.freezed.dart' 11 | 12 | # You can customize the lint rules set to your own liking. A list of all rules 13 | # can be found at https://dart-lang.github.io/linter/lints/options/options.html 14 | linter: 15 | rules: 16 | sort_constructors_first: true 17 | prefer_single_quotes: true 18 | prefer_relative_imports: true 19 | always_use_package_imports: false 20 | avoid_relative_lib_imports: false 21 | avoid_print: false 22 | always_specify_types: true 23 | avoid_classes_with_only_static_members: false 24 | -------------------------------------------------------------------------------- /android/src/main/kotlin/io/alexrintt/sharedstorage/utils/Common.kt: -------------------------------------------------------------------------------- 1 | package io.alexrintt.sharedstorage.utils 2 | 3 | import android.os.Build 4 | import io.flutter.plugin.common.MethodChannel 5 | 6 | fun MethodChannel.Result.notSupported( 7 | method: String, 8 | minSdk: Int, 9 | debug: Map = emptyMap() 10 | ) { 11 | error( 12 | EXCEPTION_NOT_SUPPORTED, 13 | "Unsupported API. Current API: ${Build.VERSION.SDK_INT} | Required: $minSdk", 14 | mapOf("method" to method, *debug.toList().toTypedArray()) 15 | ) 16 | } 17 | 18 | inline fun > valueOf(type: String?): T? { 19 | if (type == null) return null 20 | 21 | return try { 22 | java.lang.Enum.valueOf(T::class.java, type) 23 | } catch (e: Exception) { 24 | null 25 | } 26 | } 27 | 28 | inline fun > valueOf(type: String?, default: T): T = valueOf(type) ?: default 29 | -------------------------------------------------------------------------------- /.pubignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | .github/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | */**/local.properties 19 | loca.properties 20 | .vscode/ 21 | site/ 22 | docs/ 23 | *venv/ 24 | mkdocs.yaml 25 | 26 | */**/.metadata 27 | .metadata 28 | 29 | # Flutter/Dart/Pub related 30 | **/doc/api/ 31 | **/ios/Flutter/.last_build_id 32 | .dart_tool/ 33 | .flutter-plugins 34 | .flutter-plugins-dependencies 35 | .packages 36 | .pub-cache/ 37 | .pub/ 38 | /build/ 39 | */**/build 40 | 41 | # Web related 42 | lib/generated_plugin_registrant.dart 43 | 44 | # Symbolication related 45 | app.*.symbols 46 | 47 | # Obfuscation related 48 | app.*.map.json 49 | 50 | # Android Studio will place build artifacts here 51 | /android/app/debug 52 | /android/app/profile 53 | /android/app/release 54 | -------------------------------------------------------------------------------- /example/lib/utils/inline_span.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | InlineSpan Function(Object) customStyleDecorator(TextStyle textStyle) { 4 | InlineSpan applyStyles(Object data) { 5 | if (data is String) { 6 | return TextSpan( 7 | text: data, 8 | style: textStyle, 9 | ); 10 | } 11 | 12 | if (data is TextSpan) { 13 | return TextSpan( 14 | text: data.text, 15 | style: (data.style ?? const TextStyle()).merge(textStyle), 16 | ); 17 | } 18 | 19 | return data as InlineSpan; 20 | } 21 | 22 | return applyStyles; 23 | } 24 | 25 | final bold = customStyleDecorator(const TextStyle(fontWeight: FontWeight.bold)); 26 | final italic = 27 | customStyleDecorator(const TextStyle(fontStyle: FontStyle.italic)); 28 | final red = customStyleDecorator(const TextStyle(color: Colors.red)); 29 | final normal = customStyleDecorator(const TextStyle()); 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Desktop (please complete the following information):** 23 | - OS: [e.g. iOS] 24 | - Browser [e.g. chrome, safari] 25 | - Version [e.g. 22] 26 | 27 | **Smartphone (please complete the following information):** 28 | - Device: [e.g. iPhone6] 29 | - OS: [e.g. iOS8.1] 30 | - Browser [e.g. stock browser, safari] 31 | - Version [e.g. 22] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | pubspec.lock 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | **/ios/Flutter/.last_build_id 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | 34 | # Web related 35 | lib/generated_plugin_registrant.dart 36 | 37 | # Symbolication related 38 | app.*.symbols 39 | 40 | # Obfuscation related 41 | app.*.map.json 42 | 43 | # Android Studio will place build artifacts here 44 | /android/app/debug 45 | /android/app/profile 46 | /android/app/release 47 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish new plugin version to pub.dev 2 | run-name: >- 3 | [shared_storage] package publish (${{ github.ref_name }}) triggered by @${{ github.actor }} 4 | on: 5 | push: 6 | tags: 7 | - "v[0-9]+.[0-9]+.[0-9]+" 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | id-token: write # This is required for requesting the JWT 13 | defaults: 14 | run: 15 | working-directory: ./ 16 | steps: 17 | # Checkout repository 18 | - uses: actions/checkout@v4 19 | - uses: subosito/flutter-action@v2 20 | with: 21 | channel: 'stable' # or: 'beta', 'dev', 'master' (or 'main') 22 | - run: flutter --version 23 | 24 | - name: Install dependencies 25 | run: flutter pub get 26 | 27 | - name: Run Dart analyzer 28 | run: flutter analyze --fatal-infos 29 | 30 | - name: Publish to pub dev 31 | run: dart pub publish --force 32 | -------------------------------------------------------------------------------- /docs/Contributing/Setup environment/Debugging plugin.md: -------------------------------------------------------------------------------- 1 | > First you need follow the guide [`Setup local environment`](./Setup%20local%20environment.md) in order to read this guide 2 | 3 | Since this is an Android plugin, you'll need to use Android Studio in order to have a full intellisense and debugging features for Kotlin. 4 | 5 | You can also use Visual Studio Code for Dart and Android Studio for Kotlin. 6 | 7 | ## Android Side 8 | 9 | > All android plugin is inside `/android` folder. 10 | 11 | There a few steps in order to start debugging this plugin: 12 | 13 | - Open the project inside Android Studio. 14 | - Right click on `/android` folder. 15 | - `Flutter` > `Open Android module in Android Studio` > `New Window`. 16 | 17 | Done, all completing features will be available. 18 | 19 | See [Flutter docs for details](https://docs.flutter.dev/development/tools/android-studio). 20 | 21 | ## Dart Side 22 | 23 | There's no additional step. Just open the directory inside your preferable editor. 24 | 25 | Happy hacking. 26 | -------------------------------------------------------------------------------- /docs/Migrate notes/Migrate to v0.5.0.md: -------------------------------------------------------------------------------- 1 | There's major breaking changes when updating to `v0.5.0`, be careful. 2 | 3 | Update your `pubspec.yaml`: 4 | 5 | ```yaml 6 | dependencies: 7 | shared_storage: ^0.5.0 8 | ``` 9 | 10 | ## Return type of `listFiles` 11 | 12 | Instead of: 13 | 14 | ```dart 15 | Stream fileStream = listFiles(uri); 16 | ``` 17 | 18 | use: 19 | 20 | ```dart 21 | Stream fileStream = listFiles(uri); 22 | ``` 23 | 24 | And when reading data from each file: 25 | 26 | ```dart 27 | // Old. 28 | PartialDocumentFile file = ... 29 | 30 | String displayName = file.data![DocumentFileColumn.displayName] as String; 31 | DateTime lastModified = DateTime.fromMillisecondsSinceEpoch(file.data![DocumentFileColumn.lastModified] as int); 32 | 33 | // New. 34 | DocumentFile file = ... 35 | 36 | String displayName = file.name; 37 | DateTime lastModified = file.lastModified; 38 | ``` 39 | 40 | It now parses all fields as class fields instead `Map` hash map. 41 | -------------------------------------------------------------------------------- /docs/Migrate notes/Migrate to v0.6.0.md: -------------------------------------------------------------------------------- 1 | There's major breaking changes when updating to `v0.6.0`, be careful. 2 | 3 | Update your `pubspec.yaml`: 4 | 5 | ```yaml 6 | dependencies: 7 | shared_storage: ^0.6.0 8 | ``` 9 | 10 | ## Import statement 11 | 12 | Instead of: 13 | 14 | ```dart 15 | import 'package:shared_storage/environment.dart' as environment; 16 | import 'package:shared_storage/media_store.dart' as environment; 17 | import 'package:shared_storage/saf.dart' as environment; 18 | ``` 19 | 20 | Import as: 21 | 22 | ```dart 23 | import 'package:shared_storage/shared_storage' as shared_storage; 24 | ``` 25 | 26 | It's now has all APIs available under `shared_storage` key. 27 | 28 | ## `getContent()` and `getContentAsString()` 29 | 30 | Wrongly the previous versions required an unused parameter called `destination`: 31 | 32 | ```dart 33 | uri.getContentAsString(uri); 34 | uri.getContent(uri); 35 | ``` 36 | 37 | It now has been removed: 38 | 39 | ```dart 40 | uri.getContentAsString(); 41 | uri.getContent(); 42 | ``` 43 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | */**/pubspec.lock 13 | pubspec.lock 14 | venv/ 15 | site/ 16 | .vscode/ 17 | 18 | # IntelliJ related 19 | *.iml 20 | *.ipr 21 | *.iws 22 | .idea/ 23 | */**/local.properties 24 | loca.properties 25 | # The .vscode folder contains launch configuration and tasks you configure in 26 | # VS Code which you may wish to be included in version control, so this line 27 | # is commented out by default. 28 | #.vscode/ 29 | 30 | */**/.metadata 31 | .metadata 32 | 33 | # Flutter/Dart/Pub related 34 | **/doc/api/ 35 | **/ios/Flutter/.last_build_id 36 | .dart_tool/ 37 | .flutter-plugins 38 | .flutter-plugins-dependencies 39 | .packages 40 | .pub-cache/ 41 | .pub/ 42 | /build/ 43 | */**/build 44 | 45 | # Web related 46 | lib/generated_plugin_registrant.dart 47 | 48 | # Symbolication related 49 | app.*.symbols 50 | 51 | # Obfuscation related 52 | app.*.map.json 53 | 54 | # Android Studio will place build artifacts here 55 | /android/app/debug 56 | /android/app/profile 57 | /android/app/release -------------------------------------------------------------------------------- /example/lib/utils/confirm_decorator.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../widgets/confirmation_dialog.dart'; 4 | import 'inline_span.dart'; 5 | 6 | Future Function() confirm( 7 | BuildContext context, 8 | String action, 9 | VoidCallback callback, { 10 | List? message, 11 | String? text, 12 | }) { 13 | assert( 14 | text != null || message != null, 15 | '''You should provide at least one [message] or [text]''', 16 | ); 17 | Future openConfirmationDialog() async { 18 | final result = await showDialog( 19 | context: context, 20 | builder: (context) => ConfirmationDialog( 21 | color: Colors.red, 22 | actionName: action, 23 | body: Text.rich( 24 | TextSpan( 25 | children: [ 26 | if (text != null) normal(text) else ...message!, 27 | ], 28 | ), 29 | ), 30 | ), 31 | ); 32 | 33 | final confirmed = result == true; 34 | 35 | if (confirmed) callback(); 36 | 37 | return confirmed; 38 | } 39 | 40 | return openConfirmationDialog; 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alex Rintt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/src/common/functional_extender.dart: -------------------------------------------------------------------------------- 1 | extension FunctionalExtender on T? { 2 | /// ```dart 3 | /// final String? myNullableVar = ... 4 | /// 5 | /// // Really annoying repetitive condition 6 | /// if (myNullableVar != null) return null; 7 | /// 8 | /// return doSomethingElseWith(myNullableVar); 9 | /// ``` 10 | /// 11 | /// This extension allow an alternative usage: 12 | /// ``` 13 | /// final String? myNullableVar = ... 14 | /// 15 | /// return myNullableVar?.apply((m) => doSomethingElseWith(m)); 16 | /// ``` 17 | R? apply(R Function(T) f) { 18 | // Local variable to allow automatic type promotion. Also see: 19 | // 20 | final T? self = this; 21 | 22 | return self == null ? null : f(self); 23 | } 24 | 25 | T? takeIf(bool Function(T) f) { 26 | final T? self = this; 27 | 28 | return self != null && f(self) ? self : null; 29 | } 30 | } 31 | 32 | const Deprecated willbemovedsoon = Deprecated( 33 | 'This method will be moved to another package in a next release.\nBe aware this method will not be removed but moved to another module outside of [saf].', 34 | ); 35 | -------------------------------------------------------------------------------- /example/lib/theme/spacing.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | 3 | extension EdgeInsetsAlias on num { 4 | EdgeInsets get all => EdgeInsets.all(this / 1); 5 | EdgeInsets get lr => EdgeInsets.symmetric(horizontal: this / 1); 6 | EdgeInsets get tb => EdgeInsets.symmetric(vertical: this / 1); 7 | EdgeInsets get ol => EdgeInsets.only(left: this / 1); 8 | EdgeInsets get or => EdgeInsets.only(left: this / 1); 9 | EdgeInsets get lb => EdgeInsets.only(left: this / 1, bottom: this / 1); 10 | EdgeInsets get lt => EdgeInsets.only(left: this / 1, top: this / 1); 11 | EdgeInsets get rt => EdgeInsets.only(right: this / 1, top: this / 1); 12 | EdgeInsets get et => 13 | EdgeInsets.only(left: this / 1, right: this / 1, bottom: this / 1); 14 | EdgeInsets get eb => 15 | EdgeInsets.only(left: this / 1, right: this / 1, top: this / 1); 16 | EdgeInsets get el => 17 | EdgeInsets.only(right: this / 1, top: this / 1, bottom: this / 1); 18 | EdgeInsets get er => 19 | EdgeInsets.only(left: this / 1, top: this / 1, bottom: this / 1); 20 | } 21 | 22 | const k8dp = 16.0; 23 | const k6dp = 12.0; 24 | const k4dp = 8.0; 25 | const k2dp = 4.0; 26 | const k0dp = 0.0; 27 | -------------------------------------------------------------------------------- /docs/Migrate notes/Migrate to v0.3.0.md: -------------------------------------------------------------------------------- 1 | There's some breaking changes from `v0.2.x` then be careful when updating on `pubspec.yaml` 2 | 3 | `pubspec.yaml` dependecy manager file: 4 | 5 | ```yaml 6 | dependencies: 7 | shared_storage: v0.3.0 8 | ``` 9 | 10 | ## SDK constraint 11 | 12 | In `android\app\build.gradle` set `android.defaultConfig.minSdkVersion` to `19`: 13 | 14 | ```gradle 15 | android { 16 | ... 17 | defaultConfig { 18 | ... 19 | minSdkVersion 19 20 | } 21 | ... 22 | } 23 | ``` 24 | 25 | ## Plugin import 26 | 27 | Although this import is still supported: 28 | 29 | ```dart 30 | import 'package:shared_storage/shared_storage.dart' as shared_storage; 31 | ``` 32 | 33 | This should be renamed to any of them or all: 34 | 35 | ```dart 36 | import 'package:shared_storage/saf.dart' as saf; 37 | import 'package:shared_storage/media_store.dart' as media_store; 38 | import 'package:shared_storage/environment.dart' as environment; 39 | ``` 40 | 41 | Choose which modules/imports one you want to include inside in your project. 42 | 43 | ## Media Store `getMediaStoreContentDirectory` 44 | 45 | The method `getMediaStoreContentDirectory` now returns the right class `Uri` instead of a `Directory`. 46 | 47 | Be sure to update all ocurrences. 48 | 49 | This `Uri` is used to represent a directory. 50 | -------------------------------------------------------------------------------- /example/lib/widgets/buttons.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class Button extends StatelessWidget { 4 | const Button( 5 | this.text, { 6 | Key? key, 7 | this.color, 8 | required this.onTap, 9 | }) : super(key: key); 10 | 11 | final Color? color; 12 | final String text; 13 | final VoidCallback onTap; 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return TextButton( 18 | style: TextButton.styleFrom(foregroundColor: color), 19 | onPressed: onTap, 20 | child: Text(text), 21 | ); 22 | } 23 | } 24 | 25 | class DangerButton extends StatelessWidget { 26 | const DangerButton( 27 | this.text, { 28 | Key? key, 29 | required this.onTap, 30 | }) : super(key: key); 31 | 32 | final String text; 33 | final VoidCallback onTap; 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | return Button(text, onTap: onTap, color: Colors.red); 38 | } 39 | } 40 | 41 | class ActionButton extends StatelessWidget { 42 | const ActionButton( 43 | this.text, { 44 | Key? key, 45 | required this.onTap, 46 | }) : super(key: key); 47 | 48 | final String text; 49 | final VoidCallback onTap; 50 | 51 | @override 52 | Widget build(BuildContext context) { 53 | return Button(text, onTap: onTap, color: Colors.blue); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /example/lib/widgets/confirmation_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'buttons.dart'; 4 | 5 | class ConfirmationDialog extends StatefulWidget { 6 | const ConfirmationDialog({ 7 | Key? key, 8 | required this.color, 9 | this.message, 10 | this.body, 11 | required this.actionName, 12 | }) : assert( 13 | message != null || body != null, 14 | '''You should at least provde [message] or body to explain to the user the context of this confirmation''', 15 | ), 16 | super(key: key); 17 | 18 | final Color color; 19 | final String? message; 20 | final Widget? body; 21 | final String actionName; 22 | 23 | @override 24 | State createState() => _ConfirmationDialogState(); 25 | } 26 | 27 | class _ConfirmationDialogState extends State { 28 | @override 29 | Widget build(BuildContext context) { 30 | return AlertDialog( 31 | content: widget.body ?? Text(widget.message!), 32 | title: const Text('Are you sure?'), 33 | actions: [ 34 | Button('Cancel', onTap: () => Navigator.pop(context, false)), 35 | DangerButton( 36 | widget.actionName, 37 | onTap: () { 38 | Navigator.pop(context, true); 39 | }, 40 | ), 41 | ], 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | group 'io.alexrintt.sharedstorage' 2 | version '1.0-SNAPSHOT' 3 | 4 | buildscript { 5 | ext.kotlin_version = '1.8.10' 6 | repositories { 7 | google() 8 | mavenCentral() 9 | maven { url "https://oss.sonatype.org/content/repositories/snapshots" } 10 | } 11 | 12 | dependencies { 13 | classpath 'com.android.tools.build:gradle:7.2.0' 14 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 15 | } 16 | } 17 | 18 | rootProject.allprojects { 19 | repositories { 20 | google() 21 | mavenCentral() 22 | } 23 | } 24 | 25 | apply plugin: 'com.android.library' 26 | apply plugin: 'kotlin-android' 27 | 28 | android { 29 | compileSdkVersion 33 30 | 31 | sourceSets { 32 | main.java.srcDirs += 'src/main/kotlin' 33 | } 34 | defaultConfig { 35 | minSdkVersion 19 36 | } 37 | } 38 | 39 | dependencies { 40 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 41 | implementation "androidx.documentfile:documentfile:1.0.1" 42 | 43 | /** 44 | * Allow usage of `CoroutineScope` to run heavy 45 | * computation and queries outside the Main (UI) Thread 46 | */ 47 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1" 48 | 49 | /** 50 | * `SimpleStorage` library 51 | */ 52 | implementation "com.anggrayudi:storage:1.3.0" 53 | } 54 | -------------------------------------------------------------------------------- /example/lib/widgets/key_value_text.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// Use the entry value as [Widget] to use a [WidgetSpan] and [Text] to use a [TextSpan] 4 | class KeyValueText extends StatefulWidget { 5 | const KeyValueText({Key? key, required this.entries}) : super(key: key); 6 | 7 | final Map entries; 8 | 9 | @override 10 | _KeyValueTextState createState() => _KeyValueTextState(); 11 | } 12 | 13 | class _KeyValueTextState extends State { 14 | TextSpan _buildTextSpan(String key, Object value) { 15 | return TextSpan( 16 | children: [ 17 | TextSpan( 18 | text: '$key: ', 19 | ), 20 | if (value is Widget) 21 | WidgetSpan( 22 | child: value, 23 | alignment: PlaceholderAlignment.middle, 24 | ) 25 | else if (value is String) 26 | TextSpan( 27 | text: value, 28 | style: const TextStyle( 29 | fontWeight: FontWeight.bold, 30 | decoration: TextDecoration.underline, 31 | ), 32 | ), 33 | const TextSpan(text: '\n'), 34 | ], 35 | ); 36 | } 37 | 38 | @override 39 | Widget build(BuildContext context) { 40 | return Text.rich( 41 | TextSpan( 42 | children: [ 43 | for (final key in widget.entries.keys) 44 | _buildTextSpan( 45 | key, 46 | widget.entries[key]!, 47 | ), 48 | ], 49 | ), 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /android/src/main/kotlin/io/alexrintt/sharedstorage/deprecated/StorageAccessFrameworkApi.kt: -------------------------------------------------------------------------------- 1 | package io.alexrintt.sharedstorage.deprecated 2 | 3 | import io.flutter.plugin.common.* 4 | import io.alexrintt.sharedstorage.SharedStoragePlugin 5 | import io.alexrintt.sharedstorage.utils.ActivityListener 6 | import io.alexrintt.sharedstorage.utils.Listenable 7 | 8 | class StorageAccessFrameworkApi(plugin: SharedStoragePlugin) : Listenable, ActivityListener { 9 | private val documentFileApi = DocumentFileApi(plugin) 10 | private val documentsContractApi = DocumentsContractApi(plugin) 11 | private val documentFileHelperApi = DocumentFileHelperApi(plugin) 12 | 13 | override fun startListening(binaryMessenger: BinaryMessenger) { 14 | documentFileApi.startListening(binaryMessenger) 15 | documentsContractApi.startListening(binaryMessenger) 16 | documentFileHelperApi.startListening(binaryMessenger) 17 | } 18 | 19 | override fun stopListening() { 20 | documentFileApi.stopListening() 21 | documentsContractApi.stopListening() 22 | documentFileHelperApi.stopListening() 23 | } 24 | 25 | override fun startListeningToActivity() { 26 | documentFileApi.startListeningToActivity() 27 | documentsContractApi.startListeningToActivity() 28 | documentFileHelperApi.startListeningToActivity() 29 | } 30 | 31 | override fun stopListeningToActivity() { 32 | documentFileApi.stopListeningToActivity() 33 | documentsContractApi.stopListeningToActivity() 34 | documentFileHelperApi.stopListeningToActivity() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /mkdocs.yaml: -------------------------------------------------------------------------------- 1 | site_name: Shared Storage 2 | 3 | site_url: https://alexrintt.github.io/shared-storage/ 4 | site_author: Alex Rintt 5 | site_description: "Flutter plugin with low abstraction to work with native Android storage based APIs: Environment, Media Store and Storage Access Framework. Android 4.4+ (API Level 19+)" 6 | 7 | repo_name: alexrintt/shared-storage 8 | repo_url: https://github.com/alexrintt/shared-storage 9 | 10 | theme: material 11 | markdown_extensions: 12 | - markdown.extensions.admonition 13 | - markdown.extensions.attr_list 14 | - markdown.extensions.def_list 15 | - markdown.extensions.footnotes 16 | - markdown.extensions.meta 17 | - markdown.extensions.toc: 18 | permalink: true 19 | - pymdownx.arithmatex: 20 | generic: true 21 | - pymdownx.betterem: 22 | smart_enable: all 23 | - pymdownx.caret 24 | - pymdownx.critic 25 | - pymdownx.details 26 | - pymdownx.emoji: 27 | emoji_index: !!python/name:materialx.emoji.twemoji 28 | emoji_generator: !!python/name:materialx.emoji.to_svg 29 | - pymdownx.highlight 30 | - pymdownx.inlinehilite 31 | - pymdownx.keys 32 | - pymdownx.magiclink: 33 | repo_url_shorthand: true 34 | user: squidfunk 35 | repo: mkdocs-material 36 | - pymdownx.mark 37 | - pymdownx.smartsymbols 38 | - pymdownx.snippets: 39 | check_paths: true 40 | - pymdownx.superfences: 41 | custom_fences: 42 | - name: mermaid 43 | class: mermaid 44 | format: !!python/name:pymdownx.superfences.fence_code_format 45 | - pymdownx.tabbed 46 | - pymdownx.tasklist: 47 | custom_checkbox: true 48 | - pymdownx.tilde 49 | -------------------------------------------------------------------------------- /lib/shared_storage_platform_interface.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:plugin_platform_interface/plugin_platform_interface.dart'; 4 | 5 | class SharedStorage { 6 | static Stream startWatchingUri(Uri uri) => 7 | SharedStoragePlatformInterface.instance.startWatchingUri(uri); 8 | } 9 | 10 | abstract class SharedStoragePlatformInterface extends PlatformInterface { 11 | /// Constructs a SharedStoragePlatformInterface. 12 | SharedStoragePlatformInterface() : super(token: _token); 13 | 14 | static final Object _token = Object(); 15 | 16 | static SharedStoragePlatformInterface _instance = 17 | SharedStoragePlatformInterfaceMethodChannel(); 18 | 19 | /// The default instance of [SharedStoragePlatformInterface] to use. 20 | /// 21 | /// Defaults to [SharedStoragePlatformInterfaceMethodChannel]. 22 | static SharedStoragePlatformInterface get instance => _instance; 23 | 24 | /// Platform-specific implementations should set this with their own 25 | /// platform-specific class that extends [SharedStoragePlatformInterface] when 26 | /// they register themselves. 27 | static set instance(SharedStoragePlatformInterface instance) { 28 | PlatformInterface.verifyToken(instance, _token); 29 | _instance = instance; 30 | } 31 | 32 | Stream startWatchingUri(Uri uri); 33 | } 34 | 35 | class SharedStoragePlatformInterfaceMethodChannel 36 | extends SharedStoragePlatformInterface { 37 | @override 38 | Stream startWatchingUri(Uri uri) { 39 | throw UnsupportedError( 40 | 'Android does not support this API, please instead consider handling cases where the file does not exists instead of relying to this API to be aware when some change happens.', 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docs/Usage/Media Store.md: -------------------------------------------------------------------------------- 1 | > **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. 2 | 3 | ## Import package 4 | 5 | ```dart 6 | import 'package:shared_storage/shared_storage.dart' as shared_storage; 7 | ``` 8 | 9 | Usage sample: 10 | 11 | ```dart 12 | shared_storage.getMediaStoreContentDirectory(...); 13 | ``` 14 | 15 | But if you import without alias `import '...';` (Not recommeded because can conflict with other method/package names) you should use directly as functions: 16 | 17 | ```dart 18 | getMediaStoreContentDirectory(...); 19 | ``` 20 | 21 | ## API reference 22 | 23 | Original API. These methods exists only in this package. 24 | 25 | Because methods are an abstraction from native API, for example: `getMediaStoreContentDirectory` is an abstraction because there's no such method in native Android, there you can access these directories synchronously and directly from the `MediaStore` nested classes which is not the goal of this package (re-create all Android APIs) but provide a powerful fully-configurable API to call these APIs. 26 | 27 | ### getMediaStoreContentDirectory 28 | 29 | Get the **directory** of a given Media Store Collection. 30 | 31 | The directory follows the **Uri** format 32 | 33 | To see all available collections see `MediaStoreCollection` class 34 | 35 | ```dart 36 | final Uri directory = getMediaStoreContentDirectory(MediaStoreCollection.downloads); 37 | ``` 38 | 39 | ## Android Official Documentation 40 | 41 | The **Media Store** [official documentation is available here.](https://developer.android.com/reference/android/provider/MediaStore) 42 | 43 | All the APIs listed in this plugin module are derivated from the official docs. 44 | -------------------------------------------------------------------------------- /example/lib/widgets/text_field_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../utils/disabled_text_style.dart'; 4 | import 'buttons.dart'; 5 | 6 | class TextFieldDialog extends StatefulWidget { 7 | const TextFieldDialog({ 8 | Key? key, 9 | required this.labelText, 10 | required this.hintText, 11 | this.suffixText, 12 | required this.actionText, 13 | }) : super(key: key); 14 | 15 | final String labelText; 16 | final String hintText; 17 | final String? suffixText; 18 | final String actionText; 19 | 20 | @override 21 | _TextFieldDialogState createState() => _TextFieldDialogState(); 22 | } 23 | 24 | class _TextFieldDialogState extends State { 25 | late TextEditingController _textFieldController = TextEditingController(); 26 | 27 | @override 28 | void initState() { 29 | super.initState(); 30 | 31 | _textFieldController = TextEditingController(); 32 | } 33 | 34 | @override 35 | void dispose() { 36 | _textFieldController.dispose(); 37 | 38 | super.dispose(); 39 | } 40 | 41 | @override 42 | Widget build(BuildContext context) { 43 | return AlertDialog( 44 | content: TextField( 45 | controller: _textFieldController, 46 | decoration: InputDecoration( 47 | labelText: widget.labelText, 48 | hintText: widget.hintText, 49 | suffixText: widget.suffixText, 50 | suffixStyle: disabledTextStyle(), 51 | ), 52 | ), 53 | actions: [ 54 | Button( 55 | 'Cancel', 56 | onTap: () => Navigator.pop(context), 57 | ), 58 | Button( 59 | widget.actionText, 60 | onTap: () => 61 | Navigator.pop(context, _textFieldController.text), 62 | ), 63 | ], 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /example/android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file("local.properties") 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader("UTF-8") { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty("flutter.sdk") 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty("flutter.versionCode") 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = "1" 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty("flutter.versionName") 20 | if (flutterVersionName == null) { 21 | flutterVersionName = "1.0" 22 | } 23 | 24 | apply plugin: "com.android.application" 25 | apply plugin: "kotlin-android" 26 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 27 | 28 | android { 29 | compileSdkVersion 33 30 | 31 | sourceSets { 32 | main.java.srcDirs += "src/main/kotlin" 33 | } 34 | 35 | defaultConfig { 36 | applicationId "io.alexrintt.sharedstorage.example" 37 | minSdkVersion 19 38 | targetSdkVersion 33 39 | versionCode flutterVersionCode.toInteger() 40 | versionName flutterVersionName 41 | } 42 | 43 | buildTypes { 44 | release { 45 | // TODO: Add your own signing config for the release build. 46 | // Signing with the debug keys for now, so `flutter run --release` works. 47 | signingConfig signingConfigs.debug 48 | } 49 | } 50 | } 51 | 52 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 53 | 54 | flutter { 55 | source "../.." 56 | } 57 | 58 | dependencies { 59 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 60 | } 61 | -------------------------------------------------------------------------------- /android/src/main/kotlin/io/alexrintt/sharedstorage/SharedStoragePlugin.kt: -------------------------------------------------------------------------------- 1 | package io.alexrintt.sharedstorage 2 | 3 | import android.content.Context 4 | import io.alexrintt.sharedstorage.deprecated.StorageAccessFrameworkApi 5 | import io.flutter.embedding.engine.plugins.FlutterPlugin 6 | import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding 7 | import io.flutter.embedding.engine.plugins.activity.ActivityAware 8 | import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding 9 | 10 | const val ROOT_CHANNEL = "io.alexrintt.plugins/sharedstorage" 11 | 12 | /** Flutter plugin Kotlin implementation `SharedStoragePlugin` */ 13 | class SharedStoragePlugin : FlutterPlugin, ActivityAware { 14 | /** `DocumentFile` API channel */ 15 | private val storageAccessFrameworkApi = StorageAccessFrameworkApi(this) 16 | 17 | lateinit var context: Context 18 | var binding: ActivityPluginBinding? = null 19 | 20 | /** Setup all APIs */ 21 | override fun onAttachedToEngine(flutterPluginBinding: FlutterPluginBinding) { 22 | context = flutterPluginBinding.applicationContext 23 | 24 | storageAccessFrameworkApi.startListening(flutterPluginBinding.binaryMessenger) 25 | } 26 | 27 | override fun onAttachedToActivity(binding: ActivityPluginBinding) { 28 | this.binding = binding 29 | 30 | storageAccessFrameworkApi.startListeningToActivity() 31 | } 32 | 33 | override fun onDetachedFromEngine(binding: FlutterPluginBinding) { 34 | storageAccessFrameworkApi.stopListening() 35 | } 36 | 37 | override fun onDetachedFromActivityForConfigChanges() { 38 | storageAccessFrameworkApi.stopListeningToActivity() 39 | } 40 | 41 | override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { 42 | this.binding = binding 43 | } 44 | 45 | override fun onDetachedFromActivity() { 46 | binding = null 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /docs/Contributing/Setup environment/Setup local environment.md: -------------------------------------------------------------------------------- 1 | > If you already have Flutter configured you can skip this step 2 | 3 | ## Setting up your local environment 4 | 5 | All you need is to make sure you can run Flutter apps in your machine from your shell, by `flutter run` inside your Flutter project as well you can also run your Flutter project inside Android Studio IDE. Since this IDE support Android process debugging. 6 | 7 | ### Configuring Flutter 8 | 9 | You should configure your Flutter local environment properly. There's several online resources can help you do that as well the official documentation: 10 | 11 | - [Flutter Official documentation](https://docs.flutter.dev/get-started/install) 12 | - [How to Install and Set Up Flutter on Ubuntu 16.04+](https://www.freecodecamp.org/news/how-to-install-and-setup-flutter-on-ubuntu/) 13 | - [Flutter – Installation on macOS](https://www.geeksforgeeks.org/flutter-installation-on-macos/) 14 | - [How to Install Flutter on Windows?](https://www.geeksforgeeks.org/how-to-install-flutter-on-windows/) 15 | 16 | In summary: all you need to do is to setup Android plus the Flutter binaries available globally through your CLI interface. 17 | 18 | To ensure everything is working, type `flutter doctor` in your shell, you should see something like this: 19 | 20 | ```md 21 | Doctor summary (to see all details, run flutter doctor -v): 22 | [√] Flutter (Channel stable, 2.10.0, on Microsoft Windows [Version 10.0.19043.1645], locale en-US) 23 | [√] Android toolchain - develop for Android devices (Android SDK version 31.0.0) 24 | [√] Chrome - develop for the web 25 | [√] Visual Studio - develop for Windows (Visual Studio Build Tools 2019 16.11.13) 26 | [√] Android Studio (version 2020.3) 27 | [√] IntelliJ IDEA Community Edition (version 2021.3) 28 | [√] Connected device (2 available) 29 | ! Device RX8M40FQ3KF is offline. 30 | [√] HTTP Host Availability 31 | 32 | • No issues found! 33 | ``` 34 | -------------------------------------------------------------------------------- /lib/src/saf/document_bitmap.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | /// Represent the bitmap/image of a document. 4 | /// 5 | /// Usually the thumbnail of the document. 6 | /// 7 | /// The bitmap is represented as byte array [Uint8List]. 8 | /// 9 | /// Should be used to show a list/grid preview of a file list. 10 | /// 11 | /// See also [getDocumentThumbnail]. 12 | class DocumentBitmap { 13 | const DocumentBitmap({ 14 | required this.bytes, 15 | required this.uri, 16 | required this.width, 17 | required this.height, 18 | required this.byteCount, 19 | required this.density, 20 | }); 21 | 22 | factory DocumentBitmap.fromMap(Map map) { 23 | return DocumentBitmap( 24 | uri: (() { 25 | final String? uri = map['uri'] as String?; 26 | 27 | if (uri == null) return null; 28 | 29 | return Uri.parse(uri); 30 | })(), 31 | width: map['width'] as int?, 32 | height: map['height'] as int?, 33 | bytes: map['bytes'] as Uint8List?, 34 | byteCount: map['byteCount'] as int?, 35 | density: map['density'] as int?, 36 | ); 37 | } 38 | 39 | final Uint8List? bytes; 40 | final Uri? uri; 41 | final int? width; 42 | final int? height; 43 | final int? byteCount; 44 | final int? density; 45 | 46 | Map toMap() { 47 | return { 48 | 'uri': '$uri', 49 | 'width': width, 50 | 'height': height, 51 | 'bytes': bytes, 52 | 'byteCount': byteCount, 53 | 'density': density, 54 | }; 55 | } 56 | 57 | @override 58 | bool operator ==(Object other) { 59 | if (other is! DocumentBitmap) return false; 60 | 61 | return other.byteCount == byteCount && 62 | other.width == width && 63 | other.height == height && 64 | other.uri == uri && 65 | other.density == density && 66 | other.bytes == bytes; 67 | } 68 | 69 | @override 70 | int get hashCode => 71 | Object.hash(width, height, uri, density, byteCount, bytes); 72 | } 73 | -------------------------------------------------------------------------------- /android/src/main/kotlin/io/alexrintt/sharedstorage/deprecated/lib/StorageAccessFrameworkConstant.kt: -------------------------------------------------------------------------------- 1 | package io.alexrintt.sharedstorage.deprecated.lib 2 | 3 | /** 4 | * Exceptions 5 | */ 6 | const val EXCEPTION_MISSING_PERMISSIONS = "EXCEPTION_MISSING_PERMISSIONS" 7 | const val EXCEPTION_CANT_OPEN_DOCUMENT_FILE = 8 | "EXCEPTION_CANT_OPEN_DOCUMENT_FILE" 9 | const val EXCEPTION_ACTIVITY_NOT_FOUND = "EXCEPTION_ACTIVITY_NOT_FOUND" 10 | const val EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY = 11 | "EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY" 12 | 13 | /** 14 | * Others 15 | */ 16 | const val DOCUMENTS_CONTRACT_EXTRA_INITIAL_URI = 17 | "android.provider.extra.INITIAL_URI" 18 | 19 | /** 20 | * Available DocumentFile Method Channel APIs 21 | */ 22 | const val OPEN_DOCUMENT = "openDocument" 23 | const val OPEN_DOCUMENT_TREE = "openDocumentTree" 24 | const val PERSISTED_URI_PERMISSIONS = "persistedUriPermissions" 25 | const val RELEASE_PERSISTABLE_URI_PERMISSION = "releasePersistableUriPermission" 26 | const val CREATE_FILE = "createFile" 27 | const val WRITE_TO_FILE = "writeToFile" 28 | const val FROM_TREE_URI = "fromTreeUri" 29 | const val CAN_WRITE = "canWrite" 30 | const val CAN_READ = "canRead" 31 | const val RENAME_TO = "renameTo" 32 | const val LENGTH = "length" 33 | const val EXISTS = "exists" 34 | const val PARENT_FILE = "parentFile" 35 | const val CREATE_DIRECTORY = "createDirectory" 36 | const val DELETE = "delete" 37 | const val FIND_FILE = "findFile" 38 | const val COPY = "copy" 39 | const val LAST_MODIFIED = "lastModified" 40 | const val GET_DOCUMENT_THUMBNAIL = "getDocumentThumbnail" 41 | const val CHILD = "child" 42 | 43 | /** 44 | * Available DocumentFileHelper Method Channel APIs 45 | */ 46 | const val OPEN_DOCUMENT_FILE = "openDocumentFile" 47 | const val SHARE_URI = "shareUri" 48 | 49 | /** 50 | * Available Event Channels APIs 51 | */ 52 | const val LIST_FILES = "listFiles" 53 | const val GET_DOCUMENT_CONTENT = "getDocumentContent" 54 | 55 | /** 56 | * Intent Request Codes 57 | */ 58 | const val OPEN_DOCUMENT_TREE_CODE = 10 59 | const val OPEN_DOCUMENT_CODE = 11 60 | -------------------------------------------------------------------------------- /docs/Usage/API Labeling.md: -------------------------------------------------------------------------------- 1 | ## Warning 2 | 3 | This labeling will be removed soon, I it will be replaced with a full original API as described in [#56](https://github.com/alexrintt/shared-storage/issues/56). 4 | 5 | ## Labeling 6 | 7 | When refering to the docs you'll usually see some labels before the method/class names. 8 | 9 | They are label which identifies where the API came from. 10 | 11 | This package is intended to be a mirror of native Android APIs. Which means all methods and classes are just a re-implementation of native APIs, but some places we can't do that due technical reasons. So we put a label to identify when it'll happen. 12 | 13 | You are fully encouraged to understand/learn the native Android APIs to use this package. All packages (not only this one) are derivated from native APIs depending on the platform (Windows, iOS, Android, Unix, Web, etc.), to have a understing about it can help not only here but on all your Flutter journey, and even in other frameworks. 14 | 15 | | **Label** | Description | 16 | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | 17 | | **Internal** | New internal type (class). Usually they are only to keep a safe typing and are not usually intended to be instantiated for the package user. | 18 | | **Original** | Original API which only exists inside this package and doesn't mirror any Android API (an abstraction). | 19 | | **Mirror** | Pure mirror API (method/class) which was re-implemented in Dart from a native original API. | 20 | | **Alias** | Convenient methods. They do not implement anything new but create a new abstraction from an existing API. | 21 | | **External** | API from third-part Android libraries. | 22 | | **Extension** | These are most alias methods implemented through Dart extensions. | 23 | -------------------------------------------------------------------------------- /example/android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: shared_storage_example 2 | description: Demonstrates how to use the shared_storage plugin. 3 | 4 | # The following line prevents the package from being accidentally published to 5 | # pub.dev using `pub publish`. This is preferred for private packages. 6 | publish_to: "none" # Remove this line if you wish to publish to pub.dev 7 | 8 | environment: 9 | sdk: ">=2.12.0 <3.0.0" 10 | 11 | dependencies: 12 | fl_toast: ^3.1.0 13 | flutter: 14 | sdk: flutter 15 | shared_storage: 16 | # When depending on this package from a real application you should use: 17 | # shared_storage: ^x.y.z 18 | # See https://dart.dev/tools/pub/dependencies#version-constraints 19 | # The example app is bundled with the plugin so we use a path dependency on 20 | # the parent directory to use the current plugin's version. 21 | path: ../ 22 | 23 | dev_dependencies: 24 | flutter_test: 25 | sdk: flutter 26 | lint: ^1.8.2 27 | 28 | # For information on the generic Dart part of this file, see the 29 | # following page: https://dart.dev/tools/pub/pubspec 30 | 31 | # The following section is specific to Flutter. 32 | flutter: 33 | # The following line ensures that the Material Icons font is 34 | # included with your application, so that you can use the icons in 35 | # the material Icons class. 36 | uses-material-design: true 37 | 38 | # To add assets to your application, add an assets section, like this: 39 | # assets: 40 | # - images/a_dot_burr.jpeg 41 | # - images/a_dot_ham.jpeg 42 | 43 | # An image asset can refer to one or more resolution-specific "variants", see 44 | # https://flutter.dev/assets-and-images/#resolution-aware. 45 | 46 | # For details regarding adding assets from package dependencies, see 47 | # https://flutter.dev/assets-and-images/#from-packages 48 | 49 | # To add custom fonts to your application, add a fonts section here, 50 | # in this "flutter" section. Each entry in this list should have a 51 | # "family" key with the font family name, and a "fonts" key with a 52 | # list giving the asset and other descriptors for the font. For 53 | # example: 54 | # fonts: 55 | # - family: Schyler 56 | # fonts: 57 | # - asset: fonts/Schyler-Regular.ttf 58 | # - asset: fonts/Schyler-Italic.ttf 59 | # style: italic 60 | # - family: Trajan Pro 61 | # fonts: 62 | # - asset: fonts/TrajanPro.ttf 63 | # - asset: fonts/TrajanPro_Bold.ttf 64 | # weight: 700 65 | # 66 | # For details regarding fonts from package dependencies, 67 | # see https://flutter.dev/custom-fonts/#from-packages 68 | -------------------------------------------------------------------------------- /example/lib/utils/document_file_utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:fl_toast/fl_toast.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter/services.dart'; 6 | import 'package:shared_storage/saf.dart'; 7 | 8 | import '../theme/spacing.dart'; 9 | import 'disabled_text_style.dart'; 10 | import 'mime_types.dart'; 11 | 12 | extension ShowText on BuildContext { 13 | Future showToast(String text, {Duration? duration}) { 14 | return showTextToast( 15 | text: text, 16 | context: this, 17 | duration: const Duration(seconds: 5), 18 | ); 19 | } 20 | } 21 | 22 | extension OpenUriWithExternalApp on Uri { 23 | Future openWithExternalApp() async { 24 | final uri = this; 25 | 26 | try { 27 | final launched = await openDocumentFile(uri); 28 | 29 | if (launched ?? false) { 30 | print('Successfully opened $uri'); 31 | } else { 32 | print('Failed to launch $uri'); 33 | } 34 | } on PlatformException { 35 | print( 36 | "There's no activity associated with the file type of this Uri: $uri", 37 | ); 38 | } 39 | } 40 | } 41 | 42 | extension ShowDocumentFileContents on DocumentFile { 43 | Future showContents(BuildContext context) async { 44 | final mimeTypeOrEmpty = type ?? ''; 45 | final sizeInBytes = size ?? 0; 46 | 47 | const k10mb = 1024 * 1024 * 10; 48 | 49 | if (!mimeTypeOrEmpty.startsWith(kTextMime) && 50 | !mimeTypeOrEmpty.startsWith(kImageMime)) { 51 | return uri.openWithExternalApp(); 52 | } 53 | 54 | // Too long, will take too much time to read 55 | if (sizeInBytes > k10mb) { 56 | return context.showToast('File too long to open'); 57 | } 58 | 59 | final content = await getDocumentContent(uri); 60 | 61 | if (content != null) { 62 | final isImage = mimeTypeOrEmpty.startsWith(kImageMime); 63 | 64 | if (context.mounted) { 65 | await showModalBottomSheet( 66 | context: context, 67 | builder: (context) { 68 | if (isImage) { 69 | return Image.memory(content); 70 | } 71 | 72 | final contentAsString = utf8.decode(content); 73 | 74 | final fileIsEmpty = contentAsString.isEmpty; 75 | 76 | return Container( 77 | padding: k8dp.all, 78 | child: Text( 79 | fileIsEmpty ? 'This file is empty' : contentAsString, 80 | style: fileIsEmpty ? disabledTextStyle() : null, 81 | ), 82 | ); 83 | }, 84 | ); 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/src/saf/document_file_column.dart: -------------------------------------------------------------------------------- 1 | /// Representation of the available columns of `DocumentsContract.Document.` 2 | /// 3 | /// [Refer to details](https://developer.android.com/reference/android/provider/DocumentsContract.Document) 4 | class DocumentFileColumn { 5 | const DocumentFileColumn._(this._id); 6 | 7 | final String _id; 8 | 9 | static const String _kPrefix = 'DocumentFileColumn'; 10 | 11 | /// Equivalent to [`COLUMN_DOCUMENT_ID`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_DOCUMENT_ID) 12 | static const DocumentFileColumn id = 13 | DocumentFileColumn._('$_kPrefix.COLUMN_DOCUMENT_ID'); 14 | 15 | /// Equivalent to [`COLUMN_DISPLAY_NAME`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_DISPLAY_NAME) 16 | static const DocumentFileColumn displayName = 17 | DocumentFileColumn._('$_kPrefix.COLUMN_DISPLAY_NAME'); 18 | 19 | /// Equivalent to [`COLUMN_MIME_TYPE`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_MIME_TYPE) 20 | static const DocumentFileColumn mimeType = 21 | DocumentFileColumn._('$_kPrefix.COLUMN_MIME_TYPE'); 22 | 23 | /// Equivalent to [`COLUMN_LAST_MODIFIED`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_LAST_MODIFIED) 24 | static const DocumentFileColumn lastModified = 25 | DocumentFileColumn._('$_kPrefix.COLUMN_LAST_MODIFIED'); 26 | 27 | /// Equivalent to [`COLUMN_SIZE`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_SIZE) 28 | static const DocumentFileColumn size = 29 | DocumentFileColumn._('$_kPrefix.COLUMN_SIZE'); 30 | 31 | /// Equivalent to [`COLUMN_SUMMARY`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_SUMMARY) 32 | static const DocumentFileColumn summary = 33 | DocumentFileColumn._('$_kPrefix.COLUMN_SUMMARY'); 34 | 35 | /// Equivalent to [`COLUMN_FLAGS`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_FLAGS) 36 | static const DocumentFileColumn flags = 37 | DocumentFileColumn._('$_kPrefix.COLUMN_FLAGS'); 38 | 39 | /// Equivalent to [`COLUMN_ICON`](https://developer.android.com/reference/android/provider/DocumentsContract.Document#COLUMN_ICON) 40 | static const DocumentFileColumn icon = 41 | DocumentFileColumn._('$_kPrefix.COLUMN_FLAGS'); 42 | 43 | @override 44 | bool operator ==(Object other) { 45 | return other is DocumentFileColumn && other._id == _id; 46 | } 47 | 48 | static const List values = [ 49 | id, 50 | displayName, 51 | mimeType, 52 | lastModified, 53 | size, 54 | summary, 55 | flags, 56 | icon, 57 | ]; 58 | 59 | @override 60 | int get hashCode => _id.hashCode; 61 | 62 | @override 63 | String toString() => _id; 64 | } 65 | -------------------------------------------------------------------------------- /lib/src/saf/uri_permission.dart: -------------------------------------------------------------------------------- 1 | /// Description of a single Uri permission grant. 2 | /// This grants may have been created via `Intent#FLAG_GRANT_READ_URI_PERMISSION`, 3 | /// etc when sending an `Intent`, or explicitly through `Context#grantUriPermission(String, android.net.Uri, int)`. 4 | /// 5 | /// [Refer to details](https://developer.android.com/reference/android/content/UriPermission). 6 | class UriPermission { 7 | /// Even we allow create instances of this class avoid it and use 8 | /// `persistedUriPermissions` API instead 9 | const UriPermission({ 10 | required this.isReadPermission, 11 | required this.isWritePermission, 12 | required this.persistedTime, 13 | required this.uri, 14 | required this.isTreeDocumentFile, 15 | }); 16 | 17 | factory UriPermission.fromMap(Map map) { 18 | return UriPermission( 19 | isReadPermission: map['isReadPermission'] as bool, 20 | isWritePermission: map['isWritePermission'] as bool, 21 | persistedTime: map['persistedTime'] as int, 22 | uri: Uri.parse(map['uri'] as String), 23 | isTreeDocumentFile: map['isTreeDocumentFile'] as bool, 24 | ); 25 | } 26 | 27 | /// Whether an [UriPermission] is created with [`FLAG_GRANT_READ_URI_PERMISSION`](https://developer.android.com/reference/android/content/Intent#FLAG_GRANT_READ_URI_PERMISSION) 28 | final bool isReadPermission; 29 | 30 | /// Whether an [UriPermission] is created with [`FLAG_GRANT_WRITE_URI_PERMISSION`](https://developer.android.com/reference/android/content/Intent#FLAG_GRANT_WRITE_URI_PERMISSION) 31 | final bool isWritePermission; 32 | 33 | /// Return the time when this permission was first persisted, in milliseconds 34 | /// since January 1, 1970 00:00:00.0 UTC. Returns `INVALID_TIME` if 35 | /// not persisted. 36 | /// 37 | /// [Refer to details](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/content/UriPermission.java#77) 38 | final int persistedTime; 39 | 40 | /// Return the Uri this permission pertains to. 41 | /// 42 | /// [Refer to details](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/content/UriPermission.java#56) 43 | final Uri uri; 44 | 45 | /// Whether or not a tree document file. 46 | /// 47 | /// Tree document files are granted through [openDocumentTree] method, that is, when the user select a folder-like tree document file. 48 | /// Document files are granted through [openDocument] method, that is, when the user select (a) specific(s) document files. 49 | /// 50 | /// Roughly you may consider it as a property to verify if [this] permission is over a folder or a single-file. 51 | final bool isTreeDocumentFile; 52 | 53 | @override 54 | bool operator ==(Object other) => 55 | other is UriPermission && 56 | isReadPermission == other.isReadPermission && 57 | isWritePermission == other.isWritePermission && 58 | persistedTime == other.persistedTime && 59 | uri == other.uri && 60 | isTreeDocumentFile == other.isTreeDocumentFile; 61 | 62 | @override 63 | int get hashCode => Object.hashAll( 64 | [ 65 | isReadPermission, 66 | isWritePermission, 67 | persistedTime, 68 | uri, 69 | isTreeDocumentFile, 70 | ], 71 | ); 72 | 73 | Map toMap() { 74 | return { 75 | 'isReadPermission': isReadPermission, 76 | 'isWritePermission': isWritePermission, 77 | 'persistedTime': persistedTime, 78 | 'uri': '$uri', 79 | 'isTreeDocumentFile': isTreeDocumentFile, 80 | }; 81 | } 82 | 83 | @override 84 | String toString() => 'UriPermission(' 85 | 'isReadPermission: $isReadPermission, ' 86 | 'isWritePermission: $isWritePermission, ' 87 | 'persistedTime: $persistedTime, ' 88 | 'uri: $uri, ' 89 | 'isTreeDocumentFile: $isTreeDocumentFile)'; 90 | } 91 | -------------------------------------------------------------------------------- /android/src/main/kotlin/io/alexrintt/sharedstorage/deprecated/lib/DocumentFileColumn.kt: -------------------------------------------------------------------------------- 1 | package io.alexrintt.sharedstorage.deprecated.lib 2 | 3 | import android.database.Cursor 4 | import android.provider.DocumentsContract 5 | import java.lang.NullPointerException 6 | 7 | private const val PREFIX = "DocumentFileColumn" 8 | 9 | enum class DocumentFileColumn { 10 | ID, 11 | DISPLAY_NAME, 12 | MIME_TYPE, 13 | SUMMARY, 14 | LAST_MODIFIED, 15 | SIZE 16 | } 17 | 18 | enum class DocumentFileColumnType { 19 | LONG, 20 | STRING, 21 | INT 22 | } 23 | 24 | fun parseDocumentFileColumn(column: String): DocumentFileColumn? { 25 | val values = mapOf( 26 | "$PREFIX.COLUMN_DOCUMENT_ID" to DocumentFileColumn.ID, 27 | "$PREFIX.COLUMN_DISPLAY_NAME" to DocumentFileColumn.DISPLAY_NAME, 28 | "$PREFIX.COLUMN_MIME_TYPE" to DocumentFileColumn.MIME_TYPE, 29 | "$PREFIX.COLUMN_SIZE" to DocumentFileColumn.SIZE, 30 | "$PREFIX.COLUMN_SUMMARY" to DocumentFileColumn.SUMMARY, 31 | "$PREFIX.COLUMN_LAST_MODIFIED" to DocumentFileColumn.LAST_MODIFIED 32 | ) 33 | 34 | return values[column] 35 | } 36 | 37 | fun documentFileColumnToRawString(column: DocumentFileColumn): String? { 38 | val values = mapOf( 39 | DocumentFileColumn.ID to "$PREFIX.COLUMN_DOCUMENT_ID", 40 | DocumentFileColumn.DISPLAY_NAME to "$PREFIX.COLUMN_DISPLAY_NAME", 41 | DocumentFileColumn.MIME_TYPE to "$PREFIX.COLUMN_MIME_TYPE", 42 | DocumentFileColumn.SIZE to "$PREFIX.COLUMN_SIZE", 43 | DocumentFileColumn.SUMMARY to "$PREFIX.COLUMN_SUMMARY", 44 | DocumentFileColumn.LAST_MODIFIED to "$PREFIX.COLUMN_LAST_MODIFIED" 45 | ) 46 | 47 | return values[column] 48 | } 49 | 50 | fun parseDocumentFileColumn(column: DocumentFileColumn): String { 51 | val values = mapOf( 52 | DocumentFileColumn.ID to DocumentsContract.Document.COLUMN_DOCUMENT_ID, 53 | DocumentFileColumn.DISPLAY_NAME to DocumentsContract.Document.COLUMN_DISPLAY_NAME, 54 | DocumentFileColumn.MIME_TYPE to DocumentsContract.Document.COLUMN_MIME_TYPE, 55 | DocumentFileColumn.SIZE to DocumentsContract.Document.COLUMN_SIZE, 56 | DocumentFileColumn.SUMMARY to DocumentsContract.Document.COLUMN_SUMMARY, 57 | DocumentFileColumn.LAST_MODIFIED to DocumentsContract.Document.COLUMN_LAST_MODIFIED 58 | ) 59 | 60 | return values[column]!! 61 | } 62 | 63 | /// `column` must be a constant String from `DocumentsContract.Document.COLUMN*` 64 | fun typeOfColumn(column: String): DocumentFileColumnType? { 65 | val values = mapOf( 66 | DocumentsContract.Document.COLUMN_DOCUMENT_ID to DocumentFileColumnType.STRING, 67 | DocumentsContract.Document.COLUMN_DISPLAY_NAME to DocumentFileColumnType.STRING, 68 | DocumentsContract.Document.COLUMN_MIME_TYPE to DocumentFileColumnType.STRING, 69 | DocumentsContract.Document.COLUMN_SIZE to DocumentFileColumnType.LONG, 70 | DocumentsContract.Document.COLUMN_SUMMARY to DocumentFileColumnType.STRING, 71 | DocumentsContract.Document.COLUMN_LAST_MODIFIED to DocumentFileColumnType.LONG, 72 | DocumentsContract.Document.COLUMN_FLAGS to DocumentFileColumnType.INT 73 | ) 74 | 75 | return values[column] 76 | } 77 | 78 | fun cursorHandlerOf(type: DocumentFileColumnType): (Cursor, Int) -> Any? { 79 | when (type) { 80 | DocumentFileColumnType.LONG -> { 81 | return { cursor, index -> 82 | try { 83 | cursor.getLong(index) 84 | } catch (e: NullPointerException) { 85 | null 86 | } 87 | } 88 | } 89 | DocumentFileColumnType.STRING -> { 90 | return { cursor, index -> 91 | try { 92 | cursor.getString(index) 93 | } catch (e: NullPointerException) { 94 | null 95 | } 96 | } 97 | } 98 | DocumentFileColumnType.INT -> { 99 | return { cursor, index -> 100 | try { 101 | cursor.getInt(index) 102 | } catch (e: NullPointerException) { 103 | null 104 | } 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |
#flutter, #package, #android, #saf, #storage
6 |

Shared Storage

7 | 8 |
9 | 10 | Access Android Storage Access Framework, Media Store and Environment APIs through your Flutter Apps 11 | 12 |
13 | 14 |

15 | 16 | 17 | 18 | 19 |

20 | 21 |

Install It

22 | 23 | ## Documentation 24 | 25 | See the website for [documentation](https://alexrintt.github.io/shared-storage). 26 | 27 | All documentation is also available under `/docs` to each released version which is the data source of the website. 28 | 29 | You can contribute to the documentation by just editing these files. 30 | 31 | To check all ways you can contribute to this package see [Contributing/Ways to contribute](https://alexrintt.github.io/shared-storage/Contributing/Ways%20to%20contribute/). 32 | 33 | **To start developing, use `release` branch as base**, `master` is used for experimentation only and is likely to be not working. 34 | 35 | All other branches are derivated from issues, new features or bug fixes. 36 | 37 | ## Supporters 38 | 39 | - [aplicatii-romanesti](https://www.bibliotecaortodoxa.ro/) who bought me a whole month of caffeine! 40 | 41 | ## Contributors 42 | 43 | - [Tamerlanchiques](https://github.com/Tamerlanchiques) thanks a lot for the thoughtful bug reports. 44 | - [limshengli](https://github.com/limshengli) updated Android Gradle build version and Kotlin version on pull https://github.com/alexrintt/shared-storage/pull/115, thanks! 45 | - [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! 46 | - [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! 47 | - [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! 48 | - [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! 49 | - 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! 50 | - [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! 51 | - 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)! 52 | - 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)! 53 | -------------------------------------------------------------------------------- /example/lib/screens/granted_uris/granted_uris_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:shared_storage/shared_storage.dart'; 3 | 4 | import '../../theme/spacing.dart'; 5 | import '../../utils/disabled_text_style.dart'; 6 | import '../../widgets/light_text.dart'; 7 | import 'granted_uri_card.dart'; 8 | 9 | class GrantedUrisPage extends StatefulWidget { 10 | const GrantedUrisPage({Key? key}) : super(key: key); 11 | 12 | @override 13 | _GrantedUrisPageState createState() => _GrantedUrisPageState(); 14 | } 15 | 16 | class _GrantedUrisPageState extends State { 17 | List? __persistedPermissionUris; 18 | List? get _persistedPermissionUris { 19 | if (__persistedPermissionUris == null) return null; 20 | 21 | return List.from(__persistedPermissionUris!) 22 | ..sort((a, z) => z.persistedTime - a.persistedTime); 23 | } 24 | 25 | @override 26 | void initState() { 27 | super.initState(); 28 | 29 | _loadPersistedUriPermissions(); 30 | } 31 | 32 | Future _loadPersistedUriPermissions() async { 33 | __persistedPermissionUris = await persistedUriPermissions(); 34 | 35 | if (mounted) setState(() => {}); 36 | } 37 | 38 | /// Prompt user with a folder picker (Available for Android 5.0+) 39 | Future _openDocumentTree() async { 40 | /// Sample initial directory (WhatsApp status directory) 41 | const kWppStatusFolder = 42 | 'content://com.android.externalstorage.documents/tree/primary%3AAndroid%2Fmedia/document/primary%3AAndroid%2Fmedia%2Fcom.whatsapp%2FWhatsApp%2FMedia%2F.Statuses'; 43 | 44 | /// If the folder don't exist, the OS will ignore the initial directory 45 | await openDocumentTree(initialUri: Uri.parse(kWppStatusFolder)); 46 | 47 | /// TODO: Add broadcast listener to be aware when a Uri permission changes 48 | await _loadPersistedUriPermissions(); 49 | } 50 | 51 | Future _openDocument() async { 52 | const kDownloadsFolder = 53 | 'content://com.android.externalstorage.documents/tree/primary%3ADownloads/document/primary%3ADownloads'; 54 | 55 | final List? selectedDocumentUris = await openDocument( 56 | initialUri: Uri.parse(kDownloadsFolder), 57 | multiple: true, 58 | ); 59 | 60 | if (selectedDocumentUris == null) return; 61 | 62 | await _loadPersistedUriPermissions(); 63 | } 64 | 65 | Widget _buildNoFolderAllowedYetWarning() { 66 | return Padding( 67 | padding: k8dp.all, 68 | child: const Center( 69 | child: LightText('No folders or files allowed yet'), 70 | ), 71 | ); 72 | } 73 | 74 | @override 75 | Widget build(BuildContext context) { 76 | return Scaffold( 77 | appBar: AppBar( 78 | title: const Text('Shared Storage Sample'), 79 | ), 80 | body: RefreshIndicator( 81 | onRefresh: _loadPersistedUriPermissions, 82 | child: CustomScrollView( 83 | slivers: [ 84 | SliverPadding( 85 | padding: k6dp.all, 86 | sliver: SliverList( 87 | delegate: SliverChildListDelegate( 88 | [ 89 | Center( 90 | child: Wrap( 91 | alignment: WrapAlignment.center, 92 | crossAxisAlignment: WrapCrossAlignment.center, 93 | runAlignment: WrapAlignment.center, 94 | children: [ 95 | TextButton( 96 | onPressed: _openDocumentTree, 97 | child: const Text('New allowed folder'), 98 | ), 99 | const Padding(padding: EdgeInsets.all(k2dp)), 100 | TextButton( 101 | onPressed: _openDocument, 102 | child: const Text('New allowed files'), 103 | ), 104 | ], 105 | ), 106 | ), 107 | if (_persistedPermissionUris != null) 108 | if (_persistedPermissionUris!.isEmpty) 109 | _buildNoFolderAllowedYetWarning() 110 | else 111 | for (final permissionUri in _persistedPermissionUris!) 112 | GrantedUriCard( 113 | permissionUri: permissionUri, 114 | onChange: _loadPersistedUriPermissions, 115 | ) 116 | else 117 | Center( 118 | child: Text( 119 | 'Loading...', 120 | style: disabledTextStyle(), 121 | ), 122 | ), 123 | ], 124 | ), 125 | ), 126 | ), 127 | ], 128 | ), 129 | ), 130 | ); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /docs/Usage/Environment.md: -------------------------------------------------------------------------------- 1 | > **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. 2 | 3 | ## Import package 4 | 5 | ```dart 6 | import 'package:shared_storage/shared_storage.dart' as shared_storage; 7 | ``` 8 | 9 | Usage sample: 10 | 11 | ```dart 12 | shared_storage.getRootDirectory(...); 13 | shared_storage.getExternalStoragePublicDirectory(...); 14 | ``` 15 | 16 | But if you import without alias `import '...';` (Not recommeded because can conflict with other method/package names) you should use directly as functions: 17 | 18 | ```dart 19 | getRootDirectory(...); 20 | getExternalStoragePublicDirectory(...); 21 | ``` 22 | 23 | ## Mirror methods 24 | 25 | Mirror methods are available to provide an way to call a native method without using any abstraction, available mirror methods: 26 | 27 | ### getRootDirectory 28 | 29 | Mirror of [`Environment.getRootDirectory`]() 30 | 31 | Return **root of the "system"** partition holding the core Android OS. Always present and mounted read-only. 32 | 33 | > **Warning** Some new Android versions return null because `SAF` is the new API to handle storage. 34 | 35 | ```dart 36 | final Directory? rootDir = await getRootDirectory(); 37 | ``` 38 | 39 | ### getExternalStoragePublicDirectory 40 | 41 | Mirror of [`Environment.getExternalStoragePublicDirectory`]() 42 | 43 | Get a top-level shared/external storage directory for placing files of a particular type. This is where the user will typically place and manage their own files, **so you should be careful about what you put here to ensure you don't erase their files or get in the way of their own organization.** 44 | 45 | > **Warning** Some new Android versions return null because `SAF` is the new API to handle storage. 46 | 47 | ```dart 48 | final Directory? externalPublicDir = await getExternalStoragePublicDirectory(EnvironmentDirectory.downloads); 49 | ``` 50 | 51 | ### getExternalStorageDirectory 52 | 53 | Mirror of [`Environment.getExternalStorageDirectory`]() 54 | 55 | Return the primary shared/external storage directory. This directory may not currently be accessible if it has been mounted by the user on their computer, has been removed from the device, or some other problem has happened. 56 | 57 | > **Warning** Some new Android versions return null because `SAF` is the new API to handle storage. 58 | 59 | ```dart 60 | final Directory? externalDir = await getExternalStorageDirectory(); 61 | ``` 62 | 63 | ### getDataDirectory 64 | 65 | Mirror of [`Environment.getDataDirectory`]() 66 | 67 | Return the user data directory. 68 | 69 | > **Info** What may not be obvious is that the "user data directory" returned by `Environment.getDataDirectory` is the system-wide data directory (i.e, typically so far `/data`) and not an application specific directory. Applications of course are not allowed to write to the overall data directory, but only to their particular folder inside it or other select locations whose owner has granted access. [Reference](https://stackoverflow.com/questions/21230629/getfilesdir-vs-environment-getdatadirectory) by [Chris Stratton](https://stackoverflow.com/users/429063/chris-stratton) 70 | 71 | > **Warning** Some new Android versions return null because `SAF` is the new API to handle storage. 72 | 73 | ```dart 74 | final Directory? dataDir = await getDataDirectory(); 75 | ``` 76 | 77 | ### getDownloadCacheDirectory 78 | 79 | Mirror of [`Environment.getDownloadCacheDirectory`]() 80 | 81 | Return the download/cache content directory. 82 | 83 | Typically the `/data/cache` directory. 84 | 85 | > **Warning** Some new Android versions return null because `SAF` is the new API to handle storage. 86 | 87 | ```dart 88 | final Directory? downloadCacheDir = await getDownloadCacheDirectory(); 89 | ``` 90 | 91 | ### getStorageDirectory 92 | 93 | Mirror of [`Environment.getStorageDirectory`]() 94 | 95 | Return root directory where all external storage devices will be mounted. For example, `getExternalStorageDirectory()` will appear under this location. 96 | 97 | > **Warning** Some new Android versions return null because `SAF` is the new API to handle storage. 98 | 99 | ```dart 100 | final Directory? storageDir = await getStorageDirectory(); 101 | ``` 102 | 103 | ## Android Official Documentation 104 | 105 | The **Environment** [official documentation is available here.](https://developer.android.com/reference/android/os/Environment) 106 | 107 | All the APIs listed in this plugin module are derivated from the official docs. 108 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | alexrintt@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /example/lib/screens/granted_uris/granted_uri_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:shared_storage/shared_storage.dart'; 3 | 4 | import '../../theme/spacing.dart'; 5 | import '../../utils/disabled_text_style.dart'; 6 | import '../../utils/document_file_utils.dart'; 7 | import '../../widgets/buttons.dart'; 8 | import '../../widgets/key_value_text.dart'; 9 | import '../../widgets/simple_card.dart'; 10 | import '../file_explorer/file_explorer_card.dart'; 11 | import '../file_explorer/file_explorer_page.dart'; 12 | 13 | class GrantedUriCard extends StatefulWidget { 14 | const GrantedUriCard({ 15 | Key? key, 16 | required this.permissionUri, 17 | required this.onChange, 18 | }) : super(key: key); 19 | 20 | final UriPermission permissionUri; 21 | final VoidCallback onChange; 22 | 23 | @override 24 | _GrantedUriCardState createState() => _GrantedUriCardState(); 25 | } 26 | 27 | class _GrantedUriCardState extends State { 28 | Future _appendSampleFile(Uri parentUri) async { 29 | /// Create a new file inside the `parentUri` 30 | final documentFile = await parentUri.toDocumentFile(); 31 | 32 | const kFilename = 'Sample File'; 33 | 34 | final child = await documentFile?.child(kFilename); 35 | 36 | if (child == null) { 37 | documentFile?.createFileAsString( 38 | mimeType: 'text/plain', 39 | content: 'Sample File Content', 40 | displayName: kFilename, 41 | ); 42 | } else { 43 | print('This File Already Exists'); 44 | } 45 | } 46 | 47 | Future _revokeUri(Uri uri) async { 48 | await releasePersistableUriPermission(uri); 49 | 50 | widget.onChange(); 51 | } 52 | 53 | void _openListFilesPage() { 54 | Navigator.of(context).push( 55 | MaterialPageRoute( 56 | builder: (context) => FileExplorerPage(uri: widget.permissionUri.uri), 57 | ), 58 | ); 59 | } 60 | 61 | List _getTreeAvailableOptions() { 62 | return [ 63 | ActionButton( 64 | 'Create sample file', 65 | onTap: () => _appendSampleFile( 66 | widget.permissionUri.uri, 67 | ), 68 | ), 69 | ActionButton( 70 | 'Open file picker here', 71 | onTap: () => openDocumentTree(initialUri: widget.permissionUri.uri), 72 | ) 73 | ]; 74 | } 75 | 76 | @override 77 | void didUpdateWidget(covariant GrantedUriCard oldWidget) { 78 | super.didUpdateWidget(oldWidget); 79 | 80 | documentFile = null; 81 | loading = false; 82 | error = null; 83 | } 84 | 85 | DocumentFile? documentFile; 86 | bool loading = false; 87 | String? error; 88 | 89 | Future _loadDocumentFile() async { 90 | loading = true; 91 | setState(() {}); 92 | 93 | documentFile = await widget.permissionUri.uri.toDocumentFile(); 94 | loading = false; 95 | 96 | if (mounted) setState(() {}); 97 | } 98 | 99 | Future _showDocumentFileContents() async { 100 | try { 101 | final documentFile = await widget.permissionUri.uri.toDocumentFile(); 102 | 103 | if (mounted) documentFile?.showContents(context); 104 | } catch (e) { 105 | error = e.toString(); 106 | } 107 | } 108 | 109 | VoidCallback get _onTapHandler => widget.permissionUri.isTreeDocumentFile 110 | ? _openListFilesPage 111 | : _showDocumentFileContents; 112 | 113 | List _getDocumentAvailableOptions() { 114 | return [ 115 | ActionButton( 116 | widget.permissionUri.isTreeDocumentFile 117 | ? 'Open folder' 118 | : 'Open document', 119 | onTap: _onTapHandler, 120 | ), 121 | ActionButton( 122 | 'Load extra document data linked to this permission', 123 | onTap: _loadDocumentFile, 124 | ), 125 | ]; 126 | } 127 | 128 | Widget _buildAvailableActions() { 129 | return Wrap( 130 | children: [ 131 | if (widget.permissionUri.isTreeDocumentFile) 132 | ..._getTreeAvailableOptions(), 133 | ..._getDocumentAvailableOptions(), 134 | Padding(padding: k2dp.all), 135 | DangerButton( 136 | 'Revoke', 137 | onTap: () => _revokeUri( 138 | widget.permissionUri.uri, 139 | ), 140 | ), 141 | ], 142 | ); 143 | } 144 | 145 | Widget _buildGrantedUriMetadata() { 146 | return KeyValueText( 147 | entries: { 148 | 'isWritePermission': '${widget.permissionUri.isWritePermission}', 149 | 'isReadPermission': '${widget.permissionUri.isReadPermission}', 150 | 'persistedTime': '${widget.permissionUri.persistedTime}', 151 | 'uri': Uri.decodeFull('${widget.permissionUri.uri}'), 152 | 'isTreeDocumentFile': '${widget.permissionUri.isTreeDocumentFile}', 153 | }, 154 | ); 155 | } 156 | 157 | @override 158 | Widget build(BuildContext context) { 159 | return SimpleCard( 160 | onTap: _onTapHandler, 161 | children: [ 162 | Padding( 163 | padding: k2dp.all.copyWith(top: k8dp, bottom: k8dp), 164 | child: Icon( 165 | widget.permissionUri.isTreeDocumentFile 166 | ? Icons.folder 167 | : Icons.file_copy_sharp, 168 | color: disabledColor(), 169 | ), 170 | ), 171 | _buildGrantedUriMetadata(), 172 | _buildAvailableActions(), 173 | if (loading) 174 | const SizedBox( 175 | height: 20, 176 | width: 20, 177 | child: CircularProgressIndicator(), 178 | ) 179 | else if (error != null) 180 | Text('Error was thrown: $error') 181 | else if (documentFile != null) 182 | FileExplorerCard( 183 | documentFile: documentFile!, 184 | didUpdateDocument: (updatedDocumentFile) { 185 | documentFile = updatedDocumentFile; 186 | }, 187 | ) 188 | ], 189 | ); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | Check out [pub.dev/shared_storage](https://pub.dev/packages/shared_storage) 2 | 3 | ## Stability 4 | 5 | The latest version is a Beta release, which means all these APIs can change over a short period of time without prior notice. 6 | 7 | So, please be aware that this is plugin is not intended for production usage yet, since the API is currently in development. 8 | 9 | ## Features 10 | 11 | Current supported features are detailed below. 12 | 13 | ### Summary 14 | 15 | - [x] Read and write to files. 16 | - [x] Pick files using a filter (e.g image/png). 17 | - [x] Single or multiple file picks. 18 | - [x] Picking directories. 19 | - [x] Load file data immediately into memory (Uint8List) if needed. 20 | - [x] Delete files/directories. 21 | - [x] Getting file thumbnails as `Image.memory` bytes (Uint8List). 22 | - [x] Launch file with third apps. 23 | - [x] Request install APKs. 24 | - [x] List directory contents recursively (aka file-explorer like experience). 25 | 26 | ### Detailed 27 | 28 | - [x] **No runtime permissions are required**, this package doesn't rely on `MANAGE_EXTERNAL_STORAGE` or any other runtime permission, only normal permissions (`READ_EXTERNAL_STORAGE`, `WRITE_EXTERNAL_STORAGE`) are implicitly used and added to your Android project. 29 | - [x] Read file content as Future. 30 | - [ ] Read file content as Stream (planned). 31 | - [x] Get file's thumbnail (APK file icons are also supported but not recommended due it's poor performance limited by SAF and PackageManager API). 32 | - [x] Request install apk (requires `REQUEST_INSTALL_PACKAGE` permission and it's entirely optional). 33 | - [x] Open and persist folders granted by the user ("Select folder" use-case). 34 | - [x] Open and persist files granted by the user ("Select file" use-case). 35 | - [x] Different default type filtering (media, image, video, audio or any). 36 | - [x] List files inside a folder with Streams. 37 | - [x] Copy file. 38 | - [x] Open file with third-party apps (aka "Open with" use-case). 39 | - [x] Folders and files granted can be persisted across device reboots (optional). 40 | - [x] Delete file. 41 | - [x] Delete folder. 42 | - [x] Edit file contents. 43 | - [ ] Edit file contents through lazy streams (planned). 44 | - [x] Move file (it's a copy + delete). 45 | 46 | ## Installation 47 | 48 | ![Package version badge](https://img.shields.io/pub/v/shared_storage.svg?style=for-the-badge&color=22272E&showLabel=false&labelColor=15191f&logo=dart&logoColor=blue) 49 | 50 | Use latest version when installing this plugin: 51 | 52 | ```bash 53 | flutter pub add shared_storage 54 | ``` 55 | 56 | or 57 | 58 | ```yaml 59 | dependencies: 60 | shared_storage: ^latest # Pickup the latest version either from the pub.dev page or doc badge 61 | ``` 62 | 63 | ## Plugin 64 | 65 | This plugin include **partial** support for the following APIs: 66 | 67 | ### Partial Support for [Environment](./Usage/Environment.md) 68 | 69 | Mirror API from [Environment](https://developer.android.com/reference/android/os/Environment) 70 | 71 | ```dart 72 | import 'package:shared_storage/environment.dart' as environment; 73 | ``` 74 | 75 | ### Partial Support for [Media Store](./Usage/Media%20Store.md) 76 | 77 | Mirror API from [MediaStore provider](https://developer.android.com/reference/android/provider/MediaStore) 78 | 79 | ```dart 80 | import 'package:shared_storage/media_store.dart' as mediastore; 81 | ``` 82 | 83 | ### Partial Support for [Storage Access Framework](./Usage/Storage%20Access%20Framework.md) 84 | 85 | Mirror API from [Storage Access Framework](https://developer.android.com/guide/topics/providers/document-provider) 86 | 87 | ```dart 88 | import 'package:shared_storage/saf.dart' as saf; 89 | ``` 90 | 91 | All these APIs are module based, which means they are implemented separadely and so you need to import those you want use. 92 | 93 | > To request support for some API that is not currently included open a issue explaining your usecase and the API you want to make available, the same applies for new methods or activities for the current APIs. 94 | 95 | ## Contribute 96 | 97 | If you have ideas to share, bugs to report or need support, you can open an issue. 98 | 99 | ## Android APIs 100 | 101 | Most Flutter plugins use Android API's under the hood. So this plugin does the same, and to call native Android storage APIs the following API's are being used: 102 | 103 | [`🔗android.os.Environment`](https://developer.android.com/reference/android/os/Environment#summary) [`🔗android.provider.MediaStore`](https://developer.android.com/reference/android/provider/MediaStore#summary) [`🔗android.provider.DocumentsProvider`](https://developer.android.com/guide/topics/providers/document-provider) 104 | 105 | ## Supporters 106 | 107 | - [aplicatii-romanesti](https://www.bibliotecaortodoxa.ro/) who bought me a whole month of caffeine! 108 | 109 | ## Contributors 110 | 111 | - [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! 112 | - [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! 113 | - [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! 114 | - [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! 115 | - 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! 116 | - [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! 117 | - 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)! 118 | - 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)! 119 | 120 | -------------------------------------------------------------------------------- /example/lib/screens/file_explorer/file_explorer_page.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:shared_storage/shared_storage.dart'; 6 | 7 | import '../../theme/spacing.dart'; 8 | import '../../widgets/buttons.dart'; 9 | import '../../widgets/light_text.dart'; 10 | import '../../widgets/simple_card.dart'; 11 | import '../../widgets/text_field_dialog.dart'; 12 | import 'file_explorer_card.dart'; 13 | 14 | class FileExplorerPage extends StatefulWidget { 15 | const FileExplorerPage({ 16 | Key? key, 17 | required this.uri, 18 | }) : super(key: key); 19 | 20 | final Uri uri; 21 | 22 | @override 23 | _FileExplorerPageState createState() => _FileExplorerPageState(); 24 | } 25 | 26 | class _FileExplorerPageState extends State { 27 | List? _files; 28 | 29 | late bool _hasPermission; 30 | 31 | StreamSubscription? _listener; 32 | 33 | Future _grantAccess() async { 34 | final uri = await openDocumentTree(initialUri: widget.uri); 35 | 36 | if (uri == null) return; 37 | 38 | _files = null; 39 | 40 | _loadFiles(); 41 | } 42 | 43 | Widget _buildNoPermissionWarning() { 44 | return SliverPadding( 45 | padding: k6dp.eb, 46 | sliver: SliverList( 47 | delegate: SliverChildListDelegate( 48 | [ 49 | SimpleCard( 50 | onTap: () => {}, 51 | children: [ 52 | Center( 53 | child: LightText( 54 | 'No permission granted to this folder\n\n${widget.uri}\n', 55 | ), 56 | ), 57 | Center( 58 | child: ActionButton( 59 | 'Grant Access', 60 | onTap: _grantAccess, 61 | ), 62 | ), 63 | ], 64 | ), 65 | ], 66 | ), 67 | ), 68 | ); 69 | } 70 | 71 | Future _createCustomDocument() async { 72 | final filename = await showDialog( 73 | context: context, 74 | builder: (context) => const TextFieldDialog( 75 | hintText: 'File name:', 76 | labelText: 'My Text File', 77 | suffixText: '.txt', 78 | actionText: 'Create', 79 | ), 80 | ); 81 | 82 | if (filename == null) return; 83 | 84 | final createdFile = await createFile( 85 | widget.uri, 86 | mimeType: 'text/plain', 87 | displayName: filename, 88 | ); 89 | 90 | if (createdFile != null) { 91 | _files?.add(createdFile); 92 | 93 | if (mounted) setState(() {}); 94 | } 95 | } 96 | 97 | Widget _buildCreateDocumentButton() { 98 | return SliverPadding( 99 | padding: k6dp.eb, 100 | sliver: SliverList( 101 | delegate: SliverChildListDelegate( 102 | [ 103 | Center( 104 | child: ActionButton( 105 | 'Create a custom document', 106 | onTap: _createCustomDocument, 107 | ), 108 | ), 109 | ], 110 | ), 111 | ), 112 | ); 113 | } 114 | 115 | void _didUpdateDocument( 116 | DocumentFile before, 117 | DocumentFile? after, 118 | ) { 119 | if (after == null) { 120 | _files?.removeWhere((doc) => doc.id == before.id); 121 | 122 | if (mounted) setState(() {}); 123 | } 124 | } 125 | 126 | Widget _buildDocumentList() { 127 | return SliverPadding( 128 | padding: k6dp.et, 129 | sliver: SliverList( 130 | delegate: SliverChildBuilderDelegate( 131 | (context, index) { 132 | final file = _files![index]; 133 | 134 | return FileExplorerCard( 135 | documentFile: file, 136 | didUpdateDocument: (document) => 137 | _didUpdateDocument(file, document), 138 | ); 139 | }, 140 | childCount: _files!.length, 141 | ), 142 | ), 143 | ); 144 | } 145 | 146 | Widget _buildEmptyFolderWarning() { 147 | return SliverPadding( 148 | padding: k6dp.eb, 149 | sliver: SliverList( 150 | delegate: SliverChildListDelegate( 151 | [ 152 | SimpleCard( 153 | onTap: () => {}, 154 | children: const [ 155 | Center( 156 | child: LightText( 157 | 'Empty folder', 158 | ), 159 | ), 160 | ], 161 | ), 162 | ], 163 | ), 164 | ), 165 | ); 166 | } 167 | 168 | Widget _buildFileList() { 169 | return CustomScrollView( 170 | slivers: [ 171 | if (!_hasPermission) 172 | _buildNoPermissionWarning() 173 | else ...[ 174 | _buildCreateDocumentButton(), 175 | if (_files!.isNotEmpty) 176 | _buildDocumentList() 177 | else 178 | _buildEmptyFolderWarning(), 179 | ] 180 | ], 181 | ); 182 | } 183 | 184 | @override 185 | void initState() { 186 | super.initState(); 187 | 188 | _loadFiles(); 189 | } 190 | 191 | @override 192 | void dispose() { 193 | _listener?.cancel(); 194 | 195 | super.dispose(); 196 | } 197 | 198 | Future _loadFiles() async { 199 | _hasPermission = await canRead(widget.uri) ?? false; 200 | 201 | if (!_hasPermission) { 202 | return setState(() => _files = []); 203 | } 204 | 205 | final folderUri = widget.uri; 206 | 207 | const columns = [ 208 | DocumentFileColumn.displayName, 209 | DocumentFileColumn.size, 210 | DocumentFileColumn.lastModified, 211 | DocumentFileColumn.mimeType, 212 | // The column below is a optional column 213 | // you can wether include or not here and 214 | // it will be always available on the results 215 | DocumentFileColumn.id, 216 | ]; 217 | 218 | final fileListStream = listFiles(folderUri, columns: columns); 219 | 220 | _listener = fileListStream.listen( 221 | (file) { 222 | /// Append new files to the current file list 223 | _files = [...?_files, file]; 224 | 225 | /// Update the state only if the widget is currently showing 226 | if (mounted) { 227 | setState(() {}); 228 | } else { 229 | _listener?.cancel(); 230 | } 231 | }, 232 | onDone: () => setState(() => _files = [...?_files]), 233 | ); 234 | } 235 | 236 | @override 237 | Widget build(BuildContext context) { 238 | return Scaffold( 239 | appBar: AppBar(title: Text('Inside ${widget.uri.pathSegments.last}')), 240 | body: _files == null 241 | ? const Center(child: CircularProgressIndicator()) 242 | : _buildFileList(), 243 | ); 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /android/src/main/kotlin/io/alexrintt/sharedstorage/deprecated/lib/DocumentCommon.kt: -------------------------------------------------------------------------------- 1 | package io.alexrintt.sharedstorage.deprecated.lib 2 | 3 | import android.content.ContentResolver 4 | import android.content.Context 5 | import android.graphics.Bitmap 6 | import android.net.Uri 7 | import android.os.Build 8 | import android.provider.DocumentsContract 9 | import android.util.Base64 10 | import androidx.annotation.RequiresApi 11 | import androidx.documentfile.provider.DocumentFile 12 | import io.alexrintt.sharedstorage.utils.API_21 13 | import io.alexrintt.sharedstorage.utils.API_24 14 | import java.io.ByteArrayOutputStream 15 | import java.io.Closeable 16 | 17 | /** 18 | * Generate the `DocumentFile` reference from string `uri` 19 | */ 20 | @RequiresApi(API_21) 21 | fun documentFromUri(context: Context, uri: String): DocumentFile? = 22 | documentFromUri(context, Uri.parse(uri)) 23 | 24 | /** 25 | * Generate the `DocumentFile` reference from URI `uri` 26 | */ 27 | @RequiresApi(API_21) 28 | fun documentFromUri( 29 | context: Context, 30 | uri: Uri 31 | ): DocumentFile? { 32 | return if (isTreeUri(uri)) { 33 | DocumentFile.fromTreeUri(context, uri) 34 | } else { 35 | DocumentFile.fromSingleUri(context, uri) 36 | } 37 | } 38 | 39 | 40 | /** 41 | * Convert a [DocumentFile] using the default method for map encoding 42 | */ 43 | fun createDocumentFileMap(documentFile: DocumentFile?): Map? { 44 | if (documentFile == null) return null 45 | 46 | return createDocumentFileMap( 47 | DocumentsContract.getDocumentId(documentFile.uri), 48 | parentUri = documentFile.parentFile?.uri, 49 | isDirectory = documentFile.isDirectory, 50 | isFile = documentFile.isFile, 51 | isVirtual = documentFile.isVirtual, 52 | name = documentFile.name, 53 | type = documentFile.type, 54 | uri = documentFile.uri, 55 | exists = documentFile.exists(), 56 | size = documentFile.length(), 57 | lastModified = documentFile.lastModified() 58 | ) 59 | } 60 | 61 | /** 62 | * Standard map encoding of a `DocumentFile` and must be used before returning any `DocumentFile` 63 | * from plugin results, like: 64 | * ```dart 65 | * result.success(createDocumentFileMap(documentFile)) 66 | * ``` 67 | */ 68 | fun createDocumentFileMap( 69 | id: String?, 70 | parentUri: Uri?, 71 | isDirectory: Boolean?, 72 | isFile: Boolean?, 73 | isVirtual: Boolean?, 74 | name: String?, 75 | type: String?, 76 | uri: Uri, 77 | exists: Boolean?, 78 | size: Long?, 79 | lastModified: Long? 80 | ): Map { 81 | return mapOf( 82 | "id" to id, 83 | "parentUri" to "$parentUri", 84 | "isDirectory" to isDirectory, 85 | "isFile" to isFile, 86 | "isVirtual" to isVirtual, 87 | "name" to name, 88 | "type" to type, 89 | "uri" to "$uri", 90 | "exists" to exists, 91 | "size" to size, 92 | "lastModified" to lastModified 93 | ) 94 | } 95 | 96 | /** 97 | * Util method to close a closeable 98 | */ 99 | fun closeQuietly(closeable: Closeable?) { 100 | if (closeable != null) { 101 | try { 102 | closeable.close() 103 | } catch (e: RuntimeException) { 104 | throw e 105 | } catch (ignore: Exception) { 106 | } 107 | } 108 | } 109 | 110 | @RequiresApi(API_21) 111 | fun traverseDirectoryEntries( 112 | contentResolver: ContentResolver, 113 | targetUri: Uri, 114 | columns: Array, 115 | rootOnly: Boolean, 116 | block: (data: Map, isLast: Boolean) -> Unit 117 | ): Boolean { 118 | val documentId = try { 119 | DocumentsContract.getDocumentId(targetUri) 120 | } catch(e: IllegalArgumentException) { 121 | DocumentsContract.getTreeDocumentId(targetUri) 122 | } 123 | val treeDocumentId = DocumentsContract.getTreeDocumentId(targetUri) 124 | 125 | val rootUri = DocumentsContract.buildTreeDocumentUri( 126 | targetUri.authority, 127 | treeDocumentId 128 | ) 129 | val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( 130 | rootUri, 131 | documentId 132 | ) 133 | 134 | // Keep track of our directory hierarchy 135 | val dirNodes = mutableListOf(Pair(targetUri, childrenUri)) 136 | 137 | while (dirNodes.isNotEmpty()) { 138 | val (parent, children) = dirNodes.removeAt(0) 139 | 140 | val requiredColumns = 141 | if (rootOnly) emptyArray() else arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE) 142 | 143 | val intrinsicColumns = 144 | arrayOf( 145 | DocumentsContract.Document.COLUMN_DOCUMENT_ID, 146 | DocumentsContract.Document.COLUMN_FLAGS 147 | ) 148 | 149 | val projection = arrayOf( 150 | *columns, 151 | *requiredColumns, 152 | *intrinsicColumns 153 | ).toSet().toTypedArray() 154 | 155 | val cursor = contentResolver.query( 156 | children, 157 | projection, 158 | // TODO: Add support for `selection`, `selectionArgs` and `sortOrder` 159 | null, 160 | null, 161 | null 162 | ) ?: return false 163 | 164 | try { 165 | if (cursor.count == 0) { 166 | return false 167 | } 168 | 169 | while (cursor.moveToNext()) { 170 | val data = mutableMapOf() 171 | 172 | for (column in projection) { 173 | val columnValue: Any? = cursorHandlerOf(typeOfColumn(column)!!)( 174 | cursor, 175 | cursor.getColumnIndexOrThrow(column) 176 | ) 177 | 178 | data[column] = columnValue 179 | } 180 | 181 | val mimeType = 182 | data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String? 183 | 184 | val id = 185 | data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String 186 | 187 | val isDirectory = if (mimeType != null) isDirectory(mimeType) else null 188 | 189 | val uri = DocumentsContract.buildDocumentUriUsingTree( 190 | rootUri, 191 | DocumentsContract.getDocumentId( 192 | DocumentsContract.buildDocumentUri(parent.authority, id) 193 | ) 194 | ) 195 | 196 | if (isDirectory == true && !rootOnly) { 197 | val nextChildren = 198 | DocumentsContract.buildChildDocumentsUriUsingTree(targetUri, id) 199 | 200 | val nextNode = Pair(uri, nextChildren) 201 | 202 | dirNodes.add(nextNode) 203 | } 204 | 205 | block( 206 | createDocumentFileMap( 207 | parentUri = parent, 208 | uri = uri, 209 | name = data[DocumentsContract.Document.COLUMN_DISPLAY_NAME] as String?, 210 | exists = true, 211 | id = data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String, 212 | isDirectory = isDirectory == true, 213 | isFile = isDirectory == false, 214 | isVirtual = if (Build.VERSION.SDK_INT >= API_24) { 215 | (data[DocumentsContract.Document.COLUMN_FLAGS] as Int and DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT) != 0 216 | } else { 217 | false 218 | }, 219 | type = data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String?, 220 | size = data[DocumentsContract.Document.COLUMN_SIZE] as Long?, 221 | lastModified = data[DocumentsContract.Document.COLUMN_LAST_MODIFIED] as Long? 222 | ), 223 | dirNodes.isEmpty() && cursor.isLast 224 | ) 225 | } 226 | } finally { 227 | closeQuietly(cursor) 228 | } 229 | } 230 | 231 | return true 232 | } 233 | 234 | private fun isDirectory(mimeType: String): Boolean { 235 | return DocumentsContract.Document.MIME_TYPE_DIR == mimeType 236 | } 237 | 238 | fun bitmapToBase64(bitmap: Bitmap): String { 239 | val outputStream = ByteArrayOutputStream() 240 | 241 | val fullQuality = 100 242 | 243 | bitmap.compress(Bitmap.CompressFormat.PNG, fullQuality, outputStream) 244 | 245 | return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) 246 | } 247 | 248 | /** 249 | * Trick to verify if is a tree URI even not in API 26+ 250 | */ 251 | fun isTreeUri(uri: Uri): Boolean { 252 | if (Build.VERSION.SDK_INT >= API_24) { 253 | return DocumentsContract.isTreeUri(uri) 254 | } 255 | 256 | val paths = uri.pathSegments 257 | 258 | return paths.size >= 2 && "tree" == paths[0] 259 | } 260 | -------------------------------------------------------------------------------- /android/src/main/kotlin/io/alexrintt/sharedstorage/deprecated/DocumentFileHelperApi.kt: -------------------------------------------------------------------------------- 1 | package io.alexrintt.sharedstorage.deprecated 2 | 3 | import android.content.ActivityNotFoundException 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Build 7 | import android.util.Log 8 | import androidx.core.app.ShareCompat 9 | import com.anggrayudi.storage.file.isTreeDocumentFile 10 | import com.anggrayudi.storage.file.mimeType 11 | import io.alexrintt.sharedstorage.ROOT_CHANNEL 12 | import io.alexrintt.sharedstorage.SharedStoragePlugin 13 | import io.alexrintt.sharedstorage.deprecated.lib.* 14 | import io.alexrintt.sharedstorage.utils.ActivityListener 15 | import io.alexrintt.sharedstorage.utils.Listenable 16 | import io.flutter.plugin.common.* 17 | import io.flutter.plugin.common.EventChannel.StreamHandler 18 | import java.net.URLConnection 19 | 20 | 21 | /** 22 | * Aimed to be a class which takes the `DocumentFile` API and implement some APIs not supported 23 | * natively by Android. 24 | * 25 | * This is why it is separated from the original and raw `DocumentFileApi` which is the class that 26 | * only exposes the APIs without modifying them (Mirror API). 27 | * 28 | * Then here is where we can implement the main abstractions/use-cases which would be available 29 | * globally without modifying the strict APIs. 30 | */ 31 | internal class DocumentFileHelperApi(private val plugin: SharedStoragePlugin) : 32 | MethodChannel.MethodCallHandler, 33 | PluginRegistry.ActivityResultListener, 34 | Listenable, 35 | ActivityListener, 36 | StreamHandler { 37 | private val pendingResults: MutableMap> = 38 | mutableMapOf() 39 | private var channel: MethodChannel? = null 40 | private var eventChannel: EventChannel? = null 41 | private var eventSink: EventChannel.EventSink? = null 42 | 43 | companion object { 44 | private const val CHANNEL = "documentfilehelper" 45 | } 46 | 47 | override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { 48 | when (call.method) { 49 | OPEN_DOCUMENT_FILE -> openDocumentFile(call, result) 50 | SHARE_URI -> shareUri(call, result) 51 | else -> result.notImplemented() 52 | } 53 | } 54 | 55 | private fun openDocumentFile(call: MethodCall, result: MethodChannel.Result) { 56 | val uri = Uri.parse(call.argument("uri")!!) 57 | val type = 58 | call.argument("type") ?: plugin.context.contentResolver.getType( 59 | uri 60 | ) 61 | 62 | try { 63 | val isApk: Boolean = type == "application/vnd.android.package-archive" 64 | 65 | Log.d("sharedstorage", "Trying to open uri $uri with type $type") 66 | 67 | val intent = 68 | Intent(Intent.ACTION_VIEW).apply { 69 | var flags = Intent.FLAG_GRANT_READ_URI_PERMISSION 70 | 71 | if (isApk) 72 | flags = flags or Intent.FLAG_ACTIVITY_NEW_TASK 73 | 74 | setDataAndType(uri, type) 75 | setFlags(flags) 76 | } 77 | 78 | plugin.binding?.activity?.startActivity(intent, null) 79 | 80 | Log.d( 81 | "sharedstorage", 82 | "Successfully launched uri $uri as single|file uri." 83 | ) 84 | 85 | result.success(null) 86 | } catch (e: ActivityNotFoundException) { 87 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 88 | Log.d( 89 | "sharedstorage", 90 | "No activity is defined to handle $uri, trying to recover from error and interpret as tree." 91 | ) 92 | try { 93 | val file = documentFromUri(plugin.context, uri) 94 | if (file?.isTreeDocumentFile == true) { 95 | val intent = Intent(Intent.ACTION_VIEW) 96 | 97 | intent.setDataAndType(uri, "vnd.android.document/root") 98 | 99 | plugin.binding?.activity?.startActivity(intent, null) 100 | 101 | Log.d( 102 | "sharedstorage", 103 | "Successfully launched uri $uri as tree uri." 104 | ) 105 | 106 | return 107 | } 108 | } catch (e: Exception) { 109 | Log.d( 110 | "sharedstorage", 111 | "Tried to recover from missing activity exception but did not work, exception: $e" 112 | ) 113 | } 114 | } 115 | 116 | result.error( 117 | EXCEPTION_ACTIVITY_NOT_FOUND, 118 | "There's no activity handler that can process the uri $uri of type $type", 119 | mapOf("uri" to "$uri", "type" to type) 120 | ) 121 | } catch (e: SecurityException) { 122 | result.error( 123 | EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY, 124 | "Missing read and write permissions for uri $uri of type $type to launch ACTION_VIEW activity", 125 | mapOf("uri" to "$uri", "type" to "$type") 126 | ) 127 | } catch (e: Throwable) { 128 | result.error( 129 | EXCEPTION_CANT_OPEN_DOCUMENT_FILE, 130 | "Couldn't start activity to open document file for uri: $uri", 131 | mapOf("uri" to "$uri") 132 | ) 133 | } 134 | } 135 | 136 | private fun shareUri(call: MethodCall, result: MethodChannel.Result) { 137 | val uri = Uri.parse(call.argument("uri")!!) 138 | val type = 139 | call.argument("type") 140 | ?: try { 141 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 142 | documentFromUri(plugin.context, uri)?.mimeType 143 | } else { 144 | null 145 | } 146 | } catch (e: Throwable) { 147 | null 148 | } 149 | ?: plugin.binding!!.activity.contentResolver.getType(uri) 150 | ?: URLConnection.guessContentTypeFromName(uri.lastPathSegment) 151 | ?: "application/octet-stream" 152 | 153 | try { 154 | Log.d("sharedstorage", "Trying to share uri $uri with type $type") 155 | 156 | ShareCompat 157 | .IntentBuilder(plugin.binding!!.activity) 158 | .setChooserTitle("Share") 159 | .setType(type) 160 | .setStream(uri) 161 | .startChooser() 162 | 163 | Log.d("sharedstorage", "Successfully shared uri $uri of type $type.") 164 | 165 | result.success(null) 166 | } catch (e: ActivityNotFoundException) { 167 | result.error( 168 | EXCEPTION_ACTIVITY_NOT_FOUND, 169 | "There's no activity handler that can process the uri $uri of type $type, error: $e.", 170 | mapOf("uri" to "$uri", "type" to type) 171 | ) 172 | } catch (e: SecurityException) { 173 | result.error( 174 | EXCEPTION_CANT_OPEN_FILE_DUE_SECURITY_POLICY, 175 | "Missing read and write permissions for uri $uri of type $type to launch ACTION_VIEW activity, error: $e.", 176 | mapOf("uri" to "$uri", "type" to type) 177 | ) 178 | } catch (e: Throwable) { 179 | result.error( 180 | EXCEPTION_CANT_OPEN_DOCUMENT_FILE, 181 | "Couldn't start activity to open document file for uri: $uri, error: $e.", 182 | mapOf("uri" to "$uri") 183 | ) 184 | } 185 | } 186 | 187 | override fun onActivityResult( 188 | requestCode: Int, 189 | resultCode: Int, 190 | data: Intent? 191 | ): Boolean { 192 | when (requestCode) { 193 | /** TODO(@alexrintt): Implement if required */ 194 | else -> return true 195 | } 196 | 197 | return false 198 | } 199 | 200 | override fun startListening(binaryMessenger: BinaryMessenger) { 201 | if (channel != null) stopListening() 202 | 203 | channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") 204 | channel?.setMethodCallHandler(this) 205 | 206 | eventChannel = EventChannel(binaryMessenger, "$ROOT_CHANNEL/event/$CHANNEL") 207 | eventChannel?.setStreamHandler(this) 208 | } 209 | 210 | override fun stopListening() { 211 | if (channel == null) return 212 | 213 | channel?.setMethodCallHandler(null) 214 | channel = null 215 | 216 | eventChannel?.setStreamHandler(null) 217 | eventChannel = null 218 | } 219 | 220 | override fun startListeningToActivity() { 221 | plugin.binding?.addActivityResultListener(this) 222 | } 223 | 224 | override fun stopListeningToActivity() { 225 | plugin.binding?.removeActivityResultListener(this) 226 | } 227 | 228 | override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { 229 | val args = arguments as Map<*, *> 230 | 231 | eventSink = events 232 | 233 | when (args["event"]) { 234 | /** TODO(@alexrintt): Implement if required */ 235 | } 236 | } 237 | 238 | override fun onCancel(arguments: Any?) { 239 | eventSink = null 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /android/src/main/kotlin/io/alexrintt/sharedstorage/deprecated/DocumentsContractApi.kt: -------------------------------------------------------------------------------- 1 | package io.alexrintt.sharedstorage.deprecated 2 | 3 | import android.content.pm.PackageInfo 4 | import android.content.pm.PackageManager 5 | import android.graphics.Bitmap 6 | import android.graphics.Canvas 7 | import android.graphics.Point 8 | import android.graphics.drawable.BitmapDrawable 9 | import android.graphics.drawable.Drawable 10 | import android.net.Uri 11 | import android.os.Build 12 | import android.provider.DocumentsContract 13 | import android.util.Log 14 | import io.alexrintt.sharedstorage.ROOT_CHANNEL 15 | import io.alexrintt.sharedstorage.SharedStoragePlugin 16 | import io.alexrintt.sharedstorage.deprecated.lib.GET_DOCUMENT_THUMBNAIL 17 | import io.alexrintt.sharedstorage.utils.API_21 18 | import io.alexrintt.sharedstorage.utils.ActivityListener 19 | import io.alexrintt.sharedstorage.utils.Listenable 20 | import io.alexrintt.sharedstorage.utils.notSupported 21 | import io.flutter.plugin.common.BinaryMessenger 22 | import io.flutter.plugin.common.MethodCall 23 | import io.flutter.plugin.common.MethodChannel 24 | import kotlinx.coroutines.CoroutineScope 25 | import kotlinx.coroutines.Dispatchers 26 | import kotlinx.coroutines.launch 27 | import kotlinx.coroutines.withContext 28 | import java.io.File 29 | import java.io.FileOutputStream 30 | import java.io.InputStream 31 | import java.nio.ByteBuffer 32 | import java.util.* 33 | 34 | const val APK_MIME_TYPE = "application/vnd.android.package-archive" 35 | 36 | internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : 37 | MethodChannel.MethodCallHandler, Listenable, ActivityListener { 38 | private var channel: MethodChannel? = null 39 | 40 | companion object { 41 | private const val CHANNEL = "documentscontract" 42 | } 43 | 44 | private fun createTempUriFile(sourceUri: Uri, callback: (File) -> Unit) { 45 | val destinationFilename: String = UUID.randomUUID().toString() 46 | 47 | val tempDestinationFile = 48 | File(plugin.context.cacheDir.path, destinationFilename) 49 | 50 | plugin.context.contentResolver.openInputStream(sourceUri)?.use { 51 | createFileFromStream(it, tempDestinationFile) 52 | } 53 | 54 | callback(tempDestinationFile) 55 | } 56 | 57 | private fun createFileFromStream(ins: InputStream, destination: File?) { 58 | FileOutputStream(destination).use { fileOutputStream -> 59 | val buffer = ByteArray(4096) 60 | var length: Int 61 | while (ins.read(buffer).also { length = it } > 0) { 62 | fileOutputStream.write(buffer, 0, length) 63 | } 64 | fileOutputStream.flush() 65 | } 66 | } 67 | 68 | override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { 69 | when (call.method) { 70 | GET_DOCUMENT_THUMBNAIL -> { 71 | val uri = Uri.parse(call.argument("uri")) 72 | val mimeType: String? = plugin.context.contentResolver.getType(uri) 73 | 74 | if (mimeType == APK_MIME_TYPE) { 75 | CoroutineScope(Dispatchers.IO).launch { 76 | createTempUriFile(uri) { 77 | val packageManager: PackageManager = plugin.context.packageManager 78 | val packageInfo: PackageInfo? = 79 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 80 | packageManager.getPackageArchiveInfo( 81 | it.path, 82 | PackageManager.PackageInfoFlags.of(0) 83 | ) 84 | } else { 85 | @Suppress("DEPRECATION") 86 | packageManager.getPackageArchiveInfo( 87 | it.path, 88 | 0 89 | ) 90 | } 91 | 92 | if (packageInfo == null) { 93 | if (it.exists()) it.delete() 94 | return@createTempUriFile result.success(null) 95 | } 96 | 97 | // the secret are these two lines.... 98 | packageInfo.applicationInfo.sourceDir = it.path 99 | packageInfo.applicationInfo.publicSourceDir = it.path 100 | 101 | val apkIcon: Drawable = 102 | packageInfo.applicationInfo.loadIcon(packageManager) 103 | 104 | val bitmap: Bitmap = drawableToBitmap(apkIcon) 105 | 106 | val bytes: ByteArray = bitmap.convertToByteArray() 107 | 108 | val data = 109 | mapOf( 110 | "bytes" to bytes, 111 | "uri" to "$uri", 112 | "width" to bitmap.width, 113 | "height" to bitmap.height, 114 | "byteCount" to bitmap.byteCount, 115 | "density" to bitmap.density 116 | ) 117 | 118 | if (it.exists()) it.delete() 119 | 120 | launch(Dispatchers.Main) { result.success(data) } 121 | } 122 | } 123 | } else { 124 | if (Build.VERSION.SDK_INT >= API_21) { 125 | getThumbnailForApi24(call, result) 126 | } else { 127 | result.notSupported(call.method, API_21) 128 | } 129 | } 130 | } 131 | } 132 | } 133 | 134 | private fun getThumbnailForApi24( 135 | call: MethodCall, 136 | result: MethodChannel.Result 137 | ) { 138 | CoroutineScope(Dispatchers.IO).launch { 139 | val uri = Uri.parse(call.argument("uri")) 140 | val width = call.argument("width")!! 141 | val height = call.argument("height")!! 142 | 143 | // run catching because [DocumentsContract.getDocumentThumbnail] 144 | // can throw a [FileNotFoundException]. 145 | kotlin.runCatching { 146 | val bitmap = DocumentsContract.getDocumentThumbnail( 147 | plugin.context.contentResolver, 148 | uri, 149 | Point(width, height), 150 | null 151 | ) 152 | 153 | if (bitmap != null) { 154 | val byteArray: ByteArray = bitmap.convertToByteArray() 155 | 156 | val data = 157 | mapOf( 158 | "bytes" to byteArray, 159 | "uri" to "$uri", 160 | "width" to bitmap.width, 161 | "height" to bitmap.height, 162 | "byteCount" to bitmap.byteCount, 163 | "density" to bitmap.density 164 | ) 165 | 166 | launch(Dispatchers.Main) { result.success(data) } 167 | } else { 168 | Log.d("GET_DOCUMENT_THUMBNAIL", "bitmap is null") 169 | launch(Dispatchers.Main) { result.success(null) } 170 | } 171 | } 172 | } 173 | } 174 | 175 | override fun startListening(binaryMessenger: BinaryMessenger) { 176 | if (channel != null) stopListening() 177 | 178 | channel = MethodChannel(binaryMessenger, "$ROOT_CHANNEL/$CHANNEL") 179 | channel?.setMethodCallHandler(this) 180 | } 181 | 182 | override fun stopListening() { 183 | if (channel == null) return 184 | 185 | channel?.setMethodCallHandler(null) 186 | channel = null 187 | } 188 | 189 | override fun startListeningToActivity() { 190 | /** Implement if needed */ 191 | } 192 | 193 | override fun stopListeningToActivity() { 194 | /** Implement if needed */ 195 | } 196 | } 197 | 198 | fun drawableToBitmap(drawable: Drawable): Bitmap { 199 | if (drawable is BitmapDrawable) { 200 | val bitmapDrawable: BitmapDrawable = drawable 201 | if (bitmapDrawable.bitmap != null) { 202 | return bitmapDrawable.bitmap 203 | } 204 | } 205 | val bitmap: Bitmap = 206 | if (drawable.intrinsicWidth <= 0 || drawable.intrinsicHeight <= 0) { 207 | Bitmap.createBitmap( 208 | 1, 209 | 1, 210 | Bitmap.Config.ARGB_8888 211 | ) // Single color bitmap will be created of 1x1 pixel 212 | } else { 213 | Bitmap.createBitmap( 214 | drawable.intrinsicWidth, 215 | drawable.intrinsicHeight, 216 | Bitmap.Config.ARGB_8888 217 | ) 218 | } 219 | val canvas = Canvas(bitmap) 220 | drawable.setBounds(0, 0, canvas.width, canvas.height) 221 | drawable.draw(canvas) 222 | return bitmap 223 | } 224 | 225 | /** 226 | * Convert bitmap to byte array using ByteBuffer. 227 | */ 228 | fun Bitmap.convertToByteArray(): ByteArray { 229 | //minimum number of bytes that can be used to store this bitmap's pixels 230 | val size: Int = this.byteCount 231 | 232 | //allocate new instances which will hold bitmap 233 | val buffer = ByteBuffer.allocate(size) 234 | val bytes = ByteArray(size) 235 | 236 | // copy the bitmap's pixels into the specified buffer 237 | this.copyPixelsToBuffer(buffer) 238 | 239 | // rewinds buffer (buffer position is set to zero and the mark is discarded) 240 | buffer.rewind() 241 | 242 | // transfer bytes from buffer into the given destination array 243 | buffer.get(bytes) 244 | 245 | // return bitmap's pixels 246 | return bytes 247 | } 248 | -------------------------------------------------------------------------------- /lib/src/saf/document_file.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'dart:typed_data'; 3 | 4 | import '../common/functional_extender.dart'; 5 | import 'saf.dart' as saf; 6 | 7 | extension UriDocumentFileUtils on Uri { 8 | /// {@macro sharedstorage.saf.fromTreeUri} 9 | Future toDocumentFile() => DocumentFile.fromTreeUri(this); 10 | 11 | /// {@macro sharedstorage.saf.openDocumentFile} 12 | Future openDocumentFile() => saf.openDocumentFile(this); 13 | } 14 | 15 | /// Equivalent to Android `DocumentFile` class 16 | /// 17 | /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile) 18 | class DocumentFile { 19 | const DocumentFile({ 20 | required this.id, 21 | required this.parentUri, 22 | required this.size, 23 | required this.name, 24 | required this.type, 25 | required this.uri, 26 | required this.isDirectory, 27 | required this.isFile, 28 | required this.isVirtual, 29 | required this.lastModified, 30 | }); 31 | 32 | factory DocumentFile.fromMap(Map map) { 33 | return DocumentFile( 34 | parentUri: 35 | (map['parentUri'] as String?)?.apply((String p) => Uri.parse(p)), 36 | id: map['id'] as String?, 37 | isDirectory: map['isDirectory'] as bool?, 38 | isFile: map['isFile'] as bool?, 39 | isVirtual: map['isVirtual'] as bool?, 40 | name: map['name'] as String?, 41 | type: map['type'] as String?, 42 | uri: Uri.parse(map['uri'] as String), 43 | size: map['size'] as int?, 44 | lastModified: (map['lastModified'] as int?) 45 | ?.apply((int l) => DateTime.fromMillisecondsSinceEpoch(l)), 46 | ); 47 | } 48 | 49 | /// Display name of this document file, useful to show as a title in a list of files. 50 | final String? name; 51 | 52 | /// Mimetype of this document file, useful to determine how to display it. 53 | final String? type; 54 | 55 | /// Path, URI, location of this document, it can exists or not, you should check by using `exists()` API. 56 | final Uri uri; 57 | 58 | /// Uri of the parent document of [this] document. 59 | final Uri? parentUri; 60 | 61 | /// Generally represented as `primary:/Some/Resource` and can be used to identify the current document file. 62 | /// 63 | /// See [this diagram](https://raw.githubusercontent.com/anggrayudi/SimpleStorage/master/art/terminology.png) for details, source: [anggrayudi/SimpleStorage](https://github.com/anggrayudi/SimpleStorage). 64 | final String? id; 65 | 66 | /// Size of a document in bytes 67 | final int? size; 68 | 69 | /// Whether this document is a directory or not. 70 | /// 71 | /// Since it's a [DocumentFile], it can represent a folder/directory rather than a file. 72 | final bool? isDirectory; 73 | 74 | /// Indicates if this [DocumentFile] represents a _file_. 75 | /// 76 | /// Be aware there are several differences between documents and traditional files: 77 | /// - Documents express their display name and MIME type as separate fields, instead of relying on file extensions. 78 | /// Some documents providers may still choose to append extensions to their display names, but that's an implementation detail. 79 | /// - A single document may appear as the child of multiple directories, so it doesn't inherently know who its parent is. 80 | /// That is, documents don't have a strong notion of path. 81 | /// You can easily traverse a tree of documents from parent to child, but not from child to parent. 82 | /// - Each document has a unique identifier within that provider. 83 | /// This identifier is an opaque implementation detail of the provider, and as such it must not be parsed. 84 | /// 85 | /// [Android Reference](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#:~:text=androidx.documentfile.provider.DocumentFile,but%20it%20has%20substantial%20overhead.() 86 | final bool? isFile; 87 | 88 | /// Indicates if this file represents a virtual document. 89 | /// 90 | /// What is a virtual document? 91 | /// - [Video answer](https://www.youtube.com/watch?v=4h7yCZt231Y) 92 | /// - [Text docs answer](https://developer.android.com/about/versions/nougat/android-7.0#virtual_files) 93 | final bool? isVirtual; 94 | 95 | /// {@macro sharedstorage.saf.fromTreeUri} 96 | static Future fromTreeUri(Uri uri) => saf.fromTreeUri(uri); 97 | 98 | /// {@macro sharedstorage.saf.child} 99 | @willbemovedsoon 100 | Future child( 101 | String path, { 102 | bool requiresWriteAccess = false, 103 | }) => 104 | saf.child(uri, path, requiresWriteAccess: requiresWriteAccess); 105 | 106 | /// {@macro sharedstorage.saf.openDocumentFile} 107 | Future openDocumentFile() => saf.openDocumentFile(uri); 108 | 109 | /// {@macro sharedstorage.saf.openDocumentFile} 110 | /// 111 | /// Alias/shortname for [openDocumentFile] 112 | Future open() => openDocumentFile(); 113 | 114 | /// {@macro sharedstorage.saf.canRead} 115 | Future canRead() async => saf.canRead(uri); 116 | 117 | /// {@macro sharedstorage.saf.canWrite} 118 | Future canWrite() async => saf.canWrite(uri); 119 | 120 | /// {@macro sharedstorage.saf.exists} 121 | Future exists() => saf.exists(uri); 122 | 123 | /// {@macro sharedstorage.saf.delete} 124 | Future delete() => saf.delete(uri); 125 | 126 | /// {@macro sharedstorage.saf.copy} 127 | Future copy(Uri destination) => saf.copy(uri, destination); 128 | 129 | /// {@macro sharedstorage.saf.getDocumentContent} 130 | Future getContent() => saf.getDocumentContent(uri); 131 | 132 | /// {@macro sharedstorage.saf.getContentAsString} 133 | Future getContentAsString() => saf.getDocumentContentAsString(uri); 134 | 135 | /// {@macro sharedstorage.saf.createDirectory} 136 | Future createDirectory(String displayName) => 137 | saf.createDirectory(uri, displayName); 138 | 139 | /// {@macro sharedstorage.saf.createFileAsBytes} 140 | Future createFileAsBytes({ 141 | required String mimeType, 142 | required String displayName, 143 | required Uint8List bytes, 144 | }) => 145 | saf.createFile( 146 | uri, 147 | mimeType: mimeType, 148 | displayName: displayName, 149 | bytes: bytes, 150 | ); 151 | 152 | /// {@macro sharedstorage.saf.createFile} 153 | Future createFile({ 154 | required String mimeType, 155 | required String displayName, 156 | String content = '', 157 | Uint8List? bytes, 158 | }) => 159 | saf.createFile( 160 | uri, 161 | mimeType: mimeType, 162 | displayName: displayName, 163 | content: content, 164 | bytes: bytes, 165 | ); 166 | 167 | /// Alias for [createFile] with [content] param 168 | Future createFileAsString({ 169 | required String mimeType, 170 | required String displayName, 171 | required String content, 172 | }) => 173 | saf.createFile( 174 | uri, 175 | mimeType: mimeType, 176 | displayName: displayName, 177 | content: content, 178 | ); 179 | 180 | /// {@macro sharedstorage.saf.writeToFileAsBytes} 181 | Future writeToFileAsBytes({ 182 | required Uint8List bytes, 183 | FileMode? mode, 184 | }) => 185 | saf.writeToFileAsBytes( 186 | uri, 187 | bytes: bytes, 188 | mode: mode, 189 | ); 190 | 191 | /// {@macro sharedstorage.saf.writeToFile} 192 | Future writeToFile({ 193 | String? content, 194 | Uint8List? bytes, 195 | FileMode? mode, 196 | }) => 197 | saf.writeToFile( 198 | uri, 199 | content: content, 200 | bytes: bytes, 201 | mode: mode, 202 | ); 203 | 204 | /// Alias for [writeToFile] with [content] param 205 | Future writeToFileAsString({ 206 | required String content, 207 | FileMode? mode, 208 | }) => 209 | saf.writeToFile( 210 | uri, 211 | content: content, 212 | mode: mode, 213 | ); 214 | 215 | /// {@macro sharedstorage.saf.lastModified} 216 | final DateTime? lastModified; 217 | 218 | /// {@macro sharedstorage.saf.findFile} 219 | Future findFile(String displayName) => 220 | saf.findFile(uri, displayName); 221 | 222 | /// {@macro sharedstorage.saf.renameTo} 223 | Future renameTo(String displayName) => 224 | saf.renameTo(uri, displayName); 225 | 226 | /// {@macro sharedstorage.saf.parentFile} 227 | Future parentFile() => saf.parentFile(uri); 228 | 229 | Map toMap() { 230 | return { 231 | 'id': id, 232 | 'uri': '$uri', 233 | 'parentUri': '$parentUri', 234 | 'isDirectory': isDirectory, 235 | 'isFile': isFile, 236 | 'isVirtual': isVirtual, 237 | 'name': name, 238 | 'type': type, 239 | 'size': size, 240 | 'lastModified': lastModified?.millisecondsSinceEpoch, 241 | }; 242 | } 243 | 244 | @override 245 | bool operator ==(Object other) { 246 | if (other is! DocumentFile) return false; 247 | 248 | return id == other.id && 249 | parentUri == other.parentUri && 250 | isDirectory == other.isDirectory && 251 | isFile == other.isFile && 252 | isVirtual == other.isVirtual && 253 | name == other.name && 254 | type == other.type && 255 | uri == other.uri; 256 | } 257 | 258 | @override 259 | int get hashCode => 260 | Object.hash(isDirectory, isFile, isVirtual, name, type, uri); 261 | } 262 | -------------------------------------------------------------------------------- /example/lib/screens/file_explorer/file_explorer_card.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | import 'dart:math'; 4 | import 'dart:typed_data'; 5 | 6 | import 'package:flutter/material.dart'; 7 | import 'package:shared_storage/shared_storage.dart'; 8 | 9 | import '../../utils/apply_if_not_null.dart'; 10 | import '../../utils/confirm_decorator.dart'; 11 | import '../../utils/disabled_text_style.dart'; 12 | import '../../utils/document_file_utils.dart'; 13 | import '../../utils/format_bytes.dart'; 14 | import '../../utils/inline_span.dart'; 15 | import '../../utils/mime_types.dart'; 16 | import '../../widgets/buttons.dart'; 17 | import '../../widgets/key_value_text.dart'; 18 | import '../../widgets/simple_card.dart'; 19 | import '../../widgets/text_field_dialog.dart'; 20 | import 'file_explorer_page.dart'; 21 | 22 | class FileExplorerCard extends StatefulWidget { 23 | const FileExplorerCard({ 24 | Key? key, 25 | required this.documentFile, 26 | required this.didUpdateDocument, 27 | }) : super(key: key); 28 | 29 | final DocumentFile documentFile; 30 | final void Function(DocumentFile?) didUpdateDocument; 31 | 32 | @override 33 | _FileExplorerCardState createState() => _FileExplorerCardState(); 34 | } 35 | 36 | class _FileExplorerCardState extends State { 37 | DocumentFile get _file => widget.documentFile; 38 | 39 | static const _expandedThumbnailSize = Size.square(150); 40 | 41 | Uint8List? _thumbnailImageBytes; 42 | Size? _thumbnailSize; 43 | 44 | int get _sizeInBytes => _file.size ?? 0; 45 | 46 | bool _expanded = false; 47 | String? get _displayName => _file.name; 48 | 49 | Future _loadThumbnailIfAvailable() async { 50 | final uri = _file.uri; 51 | 52 | final bitmap = await getDocumentThumbnail( 53 | uri: uri, 54 | width: _expandedThumbnailSize.width, 55 | height: _expandedThumbnailSize.height, 56 | ); 57 | 58 | if (bitmap == null) { 59 | _thumbnailImageBytes = Uint8List.fromList([]); 60 | _thumbnailSize = Size.zero; 61 | } else { 62 | _thumbnailImageBytes = bitmap.bytes; 63 | _thumbnailSize = Size(bitmap.width! / 1, bitmap.height! / 1); 64 | } 65 | 66 | if (mounted) setState(() {}); 67 | } 68 | 69 | StreamSubscription? _subscription; 70 | 71 | Future Function() _fileConfirmation( 72 | String action, 73 | VoidCallback callback, 74 | ) { 75 | return confirm( 76 | context, 77 | action, 78 | callback, 79 | message: [ 80 | normal('You are '), 81 | bold('writing'), 82 | normal(' to this file and it is '), 83 | bold('not a reversible action'), 84 | normal('. It can '), 85 | bold(red('corrupt the file')), 86 | normal(' or '), 87 | bold(red('cause data loss')), 88 | normal(', '), 89 | italic('be cautious'), 90 | normal('.'), 91 | ], 92 | ); 93 | } 94 | 95 | VoidCallback _directoryConfirmation(String action, VoidCallback callback) { 96 | return confirm( 97 | context, 98 | action, 99 | callback, 100 | message: [ 101 | normal('You are '), 102 | bold('deleting'), 103 | normal(' this folder, this is '), 104 | bold('not reversible'), 105 | normal(' and '), 106 | bold(red('can cause data loss ')), 107 | normal('or even'), 108 | bold(red(' corrupt some apps')), 109 | normal(' depending on which folder you are deleting, '), 110 | italic('be cautious.'), 111 | ], 112 | ); 113 | } 114 | 115 | Widget _buildMimeTypeIconThumbnail(String mimeType, {double? size}) { 116 | if (_isDirectory) { 117 | return Icon(Icons.folder, size: size, color: Colors.blueGrey); 118 | } 119 | 120 | if (mimeType == kApkMime) { 121 | return Icon(Icons.android, color: const Color(0xff3AD17D), size: size); 122 | } 123 | 124 | if (mimeType == kTextPlainMime) { 125 | return Icon(Icons.description, size: size, color: Colors.blue); 126 | } 127 | 128 | if (mimeType.startsWith(kVideoMime)) { 129 | return Icon(Icons.movie, size: size, color: Colors.deepOrange); 130 | } 131 | 132 | return Icon( 133 | Icons.browser_not_supported_outlined, 134 | size: size, 135 | color: disabledColor(), 136 | ); 137 | } 138 | 139 | @override 140 | void initState() { 141 | super.initState(); 142 | 143 | _loadThumbnailIfAvailable(); 144 | } 145 | 146 | @override 147 | void didUpdateWidget(covariant FileExplorerCard oldWidget) { 148 | super.didUpdateWidget(oldWidget); 149 | 150 | if (oldWidget.documentFile.id != widget.documentFile.id) { 151 | _loadThumbnailIfAvailable(); 152 | if (mounted) setState(() => _expanded = false); 153 | } 154 | } 155 | 156 | @override 157 | void dispose() { 158 | _subscription?.cancel(); 159 | super.dispose(); 160 | } 161 | 162 | void _openFolderFileListPage(Uri uri) { 163 | Navigator.of(context).push( 164 | MaterialPageRoute( 165 | builder: (context) => FileExplorerPage(uri: uri), 166 | ), 167 | ); 168 | } 169 | 170 | Uint8List? content; 171 | 172 | bool get _isDirectory => _file.isDirectory == true; 173 | 174 | int _generateLuckNumber() { 175 | final random = Random(); 176 | 177 | return random.nextInt(1000); 178 | } 179 | 180 | Widget _buildThumbnail({required double size}) { 181 | late Widget thumbnail; 182 | 183 | if (_thumbnailImageBytes == null) { 184 | thumbnail = const CircularProgressIndicator(); 185 | } else if (_thumbnailImageBytes!.isEmpty) { 186 | thumbnail = _buildMimeTypeIconThumbnail( 187 | _mimeTypeOrEmpty, 188 | size: size, 189 | ); 190 | } else { 191 | thumbnail = Image.memory( 192 | _thumbnailImageBytes!, 193 | fit: BoxFit.contain, 194 | ); 195 | 196 | if (!_expanded) { 197 | final width = _thumbnailSize?.width; 198 | final height = _thumbnailSize?.height; 199 | 200 | final aspectRatio = 201 | width != null && height != null ? width / height : 1.0; 202 | 203 | thumbnail = AspectRatio( 204 | aspectRatio: aspectRatio, 205 | child: thumbnail, 206 | ); 207 | } 208 | } 209 | 210 | return Padding( 211 | padding: const EdgeInsets.only(bottom: 12), 212 | child: Row( 213 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 214 | mainAxisSize: _expanded ? MainAxisSize.max : MainAxisSize.min, 215 | children: [ 216 | Align( 217 | alignment: _expanded ? Alignment.centerLeft : Alignment.center, 218 | child: ConstrainedBox( 219 | constraints: BoxConstraints(maxHeight: size, maxWidth: size), 220 | child: thumbnail, 221 | ), 222 | ), 223 | if (_expanded) _buildExpandButton(), 224 | ], 225 | ), 226 | ); 227 | } 228 | 229 | Widget _buildExpandButton() { 230 | return IconButton( 231 | onPressed: () => setState(() => _expanded = !_expanded), 232 | icon: _expanded 233 | ? const Icon(Icons.expand_less, color: Colors.grey) 234 | : const Icon(Icons.expand_more, color: Colors.grey), 235 | ); 236 | } 237 | 238 | Uri get _currentUri => widget.documentFile.uri; 239 | 240 | Widget _buildNotAvailableText() { 241 | return Text('Not available', style: disabledTextStyle()); 242 | } 243 | 244 | Widget _buildOpenWithButton() => 245 | Button('Open with', onTap: _currentUri.openWithExternalApp); 246 | 247 | Widget _buildDocumentSimplifiedTile() { 248 | return ListTile( 249 | dense: true, 250 | leading: _buildThumbnail(size: 25), 251 | title: Text( 252 | '$_displayName', 253 | style: const TextStyle(fontWeight: FontWeight.bold), 254 | ), 255 | subtitle: Text(formatBytes(_sizeInBytes, 2)), 256 | trailing: _buildExpandButton(), 257 | ); 258 | } 259 | 260 | Widget _buildDocumentMetadata() { 261 | return KeyValueText( 262 | entries: { 263 | 'name': '$_displayName', 264 | 'type': '${_file.type}', 265 | 'isVirtual': '${_file.isVirtual}', 266 | 'isDirectory': '${_file.isDirectory}', 267 | 'isFile': '${_file.isFile}', 268 | 'size': '${formatBytes(_sizeInBytes, 2)} ($_sizeInBytes bytes)', 269 | 'lastModified': '${(() { 270 | if (_file.lastModified == null) { 271 | return null; 272 | } 273 | 274 | return _file.lastModified!.toIso8601String(); 275 | })()}', 276 | 'id': '${_file.id}', 277 | 'parentUri': _file.parentUri?.apply((u) => Uri.decodeFull('$u')) ?? 278 | _buildNotAvailableText(), 279 | 'uri': Uri.decodeFull('${_file.uri}'), 280 | }, 281 | ); 282 | } 283 | 284 | Widget _buildAvailableActions() { 285 | return Wrap( 286 | children: [ 287 | if (_isDirectory) 288 | ActionButton( 289 | 'Open Directory', 290 | onTap: _openDirectory, 291 | ), 292 | _buildOpenWithButton(), 293 | DangerButton( 294 | 'Delete ${_isDirectory ? 'Directory' : 'File'}', 295 | onTap: _isDirectory 296 | ? _directoryConfirmation('Delete', _deleteDocument) 297 | : _fileConfirmation('Delete', _deleteDocument), 298 | ), 299 | if (!_isDirectory) ...[ 300 | DangerButton( 301 | 'Write to File', 302 | onTap: _fileConfirmation('Overwite', _overwriteFileContents), 303 | ), 304 | DangerButton( 305 | 'Append to file', 306 | onTap: _fileConfirmation('Append', _appendFileContents), 307 | ), 308 | DangerButton( 309 | 'Erase file content', 310 | onTap: _fileConfirmation('Erase', _eraseFileContents), 311 | ), 312 | DangerButton( 313 | 'Edit file contents', 314 | onTap: _editFileContents, 315 | ), 316 | ], 317 | ], 318 | ); 319 | } 320 | 321 | String get _mimeTypeOrEmpty => _file.type ?? ''; 322 | 323 | Future _deleteDocument() async { 324 | final deleted = await delete(_currentUri); 325 | 326 | if (deleted ?? false) { 327 | widget.didUpdateDocument(null); 328 | } 329 | } 330 | 331 | Future _overwriteFileContents() async { 332 | await writeToFile( 333 | _currentUri, 334 | content: 'Hello World! Your luck number is: ${_generateLuckNumber()}', 335 | mode: FileMode.write, 336 | ); 337 | } 338 | 339 | Future _appendFileContents() async { 340 | final contents = await getDocumentContentAsString( 341 | _currentUri, 342 | ); 343 | 344 | final prependWithNewLine = contents?.isNotEmpty ?? true; 345 | 346 | await writeToFile( 347 | _currentUri, 348 | content: 349 | "${prependWithNewLine ? '\n' : ''}You file got bigger! Here's your luck number: ${_generateLuckNumber()}", 350 | mode: FileMode.append, 351 | ); 352 | } 353 | 354 | Future _eraseFileContents() async { 355 | await writeToFile( 356 | _currentUri, 357 | content: '', 358 | mode: FileMode.write, 359 | ); 360 | } 361 | 362 | Future _editFileContents() async { 363 | final content = await showDialog( 364 | context: context, 365 | builder: (context) { 366 | return const TextFieldDialog( 367 | labelText: 'New file content:', 368 | hintText: 'Writing to this file', 369 | actionText: 'Edit', 370 | ); 371 | }, 372 | ); 373 | 374 | if (content != null) { 375 | _fileConfirmation( 376 | 'Overwrite', 377 | () => writeToFileAsString( 378 | _currentUri, 379 | content: content, 380 | mode: FileMode.write, 381 | ), 382 | )(); 383 | } 384 | } 385 | 386 | Future _openDirectory() async { 387 | if (_isDirectory) { 388 | _openFolderFileListPage(_file.uri); 389 | } 390 | } 391 | 392 | @override 393 | Widget build(BuildContext context) { 394 | return SimpleCard( 395 | onTap: _isDirectory ? _openDirectory : () => _file.showContents(context), 396 | children: [ 397 | if (_expanded) ...[ 398 | _buildThumbnail(size: 50), 399 | _buildDocumentMetadata(), 400 | _buildAvailableActions() 401 | ] else 402 | _buildDocumentSimplifiedTile(), 403 | ], 404 | ); 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.7.0 2 | 3 | - New APIs and options. 4 | - There's no major breaking changes when updating to `v0.7.0` but there are deprecation notices over Media Store and Environment API. 5 | 6 | ### New 7 | 8 | - `openDocument` API with single and multiple files support @honjow. 9 | - `openDocumentTree` it now also supports `persistablePermission` option which flags an one-time operation to avoid unused permission issues. 10 | 11 | ### Deprecation notices 12 | 13 | - 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. 14 | 15 | ### Example project 16 | 17 | - Added a new button that implements `openDocument` API. 18 | 19 | ## 0.6.0 20 | 21 | This release contains a severe API fixes and some minor doc changes: 22 | 23 | ### Breaking changes 24 | 25 | - Unused arguments in `DocumentFile.getContent` and `DocumentFile.getContentAsString`. [#107](https://github.com/alexrintt/shared-storage/issues/107) @clragon. 26 | - Package import it's now done through a single import. 27 | 28 | ## 0.5.0 29 | 30 | This release contains: 31 | 32 | - Major breaking changes. 33 | - New API to edit existing files. 34 | - Example project improvements. 35 | - Bug fixes. 36 | 37 | To see details, refer to rollup PR [#100](https://github.com/alexrintt/shared-storage/pull/100). 38 | 39 | ### New 40 | 41 | - Added `writeToFile`, `writeToFileAsString` and `writeToFileAsBytes` APIs to allow overwrite existing files by appending (`FileMode.append`) or truncating `FileMode.write` (@jfaltis). 42 | 43 | ### Breaking changes 44 | 45 | - `listFiles` it's now returns a `Stream` instead of `Stream`. 46 | - `DocumentFile.lastModified` it's now returns a `DateTime?` instead of `Future` (removed the asynchronous plugin call). 47 | - All `DocumentFile` class fields are now nullable except by `DocumentFile.uri`. 48 | - `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. 49 | 50 | ### Bug fixes 51 | 52 | - `DocumentFile.canRead` it's now calling the right API (`canRead`) instead of the similar one (`canWrite`). 53 | - [Fix](https://github.com/alexrintt/shared-storage/pull/100/files#diff-6f516633fcc1095b16ad5e0cc2a2c9711ee903cb115835d703f3c0ccfd6e0d31R38-R62) infinite loading of `getDocumentThumbnail` API when thumbnail is not available. 54 | 55 | ### Example project 56 | 57 | - 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. 58 | - File cards have now a expanded and collapsed state instead of showing all data at once. 59 | - 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. 60 | - 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**. 61 | - 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). 62 | - File card now shows the decoded uris to fix the visual pollution. 63 | 64 | ## 0.4.2 65 | 66 | Minimal hotfix: 67 | 68 | - Closes the `OutputStream` when creating a file [#61](https://github.com/alexrintt/shared-storage/issues/61), [#86](https://github.com/alexrintt/shared-storage/pull/86) (@jfaltis). 69 | 70 | ## 0.4.1 71 | 72 | Minimal hotfix of the example project: 73 | 74 | - Fix build error of the example project. Reported at [#70](https://github.com/alexrintt/shared-storage/issues/70) and fixed by [#72](https://github.com/alexrintt/shared-storage/pull/72) (@jfaltis). 75 | 76 | ## 0.4.0 77 | 78 | Fix the current behavior of `listFiles` and `openDocumentFile` API. 79 | 80 | ### Improvements 81 | 82 | - It's now possible to list contents of all subfolders of a granted Uri opened from `openDocumentTree` (@EternityForest). 83 | - Now `ACTION_VIEW` intent builder through `openDocumentFile` API was fixed. So it's now possible to open any file of any kind in third party apps without needing specify the mime type. 84 | 85 | ### Breaking changes 86 | 87 | - Removed Android specific APIs: 88 | - `DocumentFile.listFiles` (Now it's only available globally). 89 | - `buildDocumentUriUsingTree` removed due high coupling with Android API (Android specific API that are not useful on any other platforms). 90 | - `buildDocumentUri` removed due high coupling with Android API (Android specific API that are not useful on any other platforms). 91 | - `buildTreeDocumentUri` removed due high coupling with Android API (Android specific API that are not useful on any other platforms). 92 | - `getDocumentThumbnail` now receives only the `uri` param instead of a `rootUri` and a `documentId`. 93 | - `rootUri` field from `QueryMetadata` was removed due API ambiguity: there's no such concept in the Android API and this is not required by it to work well. 94 | 95 | ## 0.3.1 96 | 97 | Minor improvements and bug fixes: 98 | 99 | - Crash when ommiting `DocumentFileColumn.id` column on `listFiles` API. Thanks to @EternityForest. 100 | - Updated docs to info that now `DocumentFileColumn.id` column is optional when calling `listFiles`. 101 | 102 | ## 0.3.0 103 | 104 | Major release focused on support for `Storage Access Framework`. 105 | 106 | ### Breaking changes 107 | 108 | - `minSdkVersion` set to `19`. 109 | - `getMediaStoreContentDirectory` return type changed to `Uri`. 110 | - Import package directive path is now modular. Which means you need to import the modules you are using: 111 | - `import 'package:shared_storage/saf.dart' as saf;` to enable **Storage Access Framework** API. 112 | - `import 'package:shared_storage/environment.dart' as environment;` to enable **Environment** API. 113 | - `import 'package:shared_storage/media_store.dart' as mediastore;` to enable **Media Store** API. 114 | - `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). 115 | 116 | ### New 117 | 118 | See the label [reference here](/docs/Usage/API%20Labeling.md). 119 | 120 | - Original `listFiles`. This API does the same thing as `DocumentFile.listFiles` but through Android queries and not calling directly the `DocumentFile.listFiles` API for performance reasons. 121 | 122 | - Internal `DocumentFile` from [`DocumentFile`](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile) SAF class. 123 | 124 | - Internal `QueryMetadata` metadata of the queries used by `listFiles` API. 125 | 126 | - Internal `PartialDocumentFile`. Represents a partial document file returned by `listFiles` API. 127 | 128 | - `openDocumentTree` now accepts `grantWritePermission` and `initialUri` params which, respectively, sets whether or not grant write permission level and the initial uri location of the folder authorization picker. 129 | 130 | - Mirror `DocumentFileColumn` from [`DocumentsContract.Document.`](https://developer.android.com/reference/android/provider/DocumentsContract.Document) SAF class. 131 | 132 | - Mirror `canRead` from [`DocumentFile.canRead`](). Returns `true` if the caller can read the given `uri`. 133 | 134 | - Mirror `canWrite` from [`DocumentFile.canWrite`](). Returns `true` if the caller can write to the given `uri`. 135 | 136 | - Mirror `getDocumentThumbnail` from [`DocumentsContract.getDocumentThumbnail`](). Returns the image thumbnail of a given `uri`, if any (e.g documents that can show a preview, like image or pdf, otherwise `null`). 137 | 138 | - Mirror `exists` from [`DocumentsContract.exists`](). Returns `true` if a given `uri` exists. 139 | 140 | - Mirror `buildDocumentUriUsingTree` from [`DocumentsContract.buildDocumentUriUsingTree`](). 141 | 142 | - Mirror `buildDocumentUri` from [`DocumentsContract.buildDocumentUri`](). 143 | 144 | - Mirror `buildTreeDocumentUri` from [`DocumentsContract.buildTreeDocumentUri`](). 145 | 146 | - Mirror `delete` from [`DocumentFile.delete`](). Self explanatory. 147 | 148 | - Mirror `createDirectory` from [`DocumentFile.createDirectory`](). Creates a new child document file that represents a directory given the `displayName` (folder name). 149 | 150 | - Alias `createFile`. Alias for `createFileAsBytes` or `createFileAsString` depending which params are provided. 151 | 152 | - Mirror `createFileAsBytes` from [`DocumentFile.createFile`](). Given the parent uri, creates a new child document file that represents a single file given the `displayName`, `mimeType` and its `content` in bytes (file name, file type and file content in raw bytes, respectively). 153 | 154 | - Alias `createFileAsString`. Alias for `createFileAsBytes(bytes: Uint8List.fromList('file content...'.codeUnits))`. 155 | 156 | - Mirror `documentLength` from [`DocumentFile.length`](). Returns the length of the given file (uri) in bytes. Returns 0 if the file does not exist, or if the length is unknown. 157 | 158 | - Mirror `lastModified` from [`DocumentFile.lastModified`](). Returns the time when the given file (uri) was last modified, measured in milliseconds since January 1st, 1970, midnight. Returns 0 if the file does not exist, or if the modified time is unknown. 159 | 160 | - Mirror `findFile` from [`DocumentFile.findFile`](). Search through listFiles() for the first document matching the given display name, this method has a really poor performance for large data sets, prefer using `child` instead. 161 | 162 | - Mirror `fromTreeUri` from [`DocumentFile.fromTreeUri`](). 163 | 164 | - Mirror `renameTo` from [`DocumentFile.renameTo`](). Rename a document file given its `uri` to the given `displayName`. 165 | 166 | - Mirror `parentFile` from [`DocumentFile.parentFile`](). Get the parent document of the given document file from its uri. 167 | 168 | - Mirror `copy` from [`DocumentsContract.copyDocument`](). Copies the given document to the given `destination`. 169 | 170 | - Original `getDocumentContent`. Read a document file from its uri by opening a input stream and returning its bytes. 171 | 172 | - External `child` from [`com.anggrayudi.storage.file.DocumentFile.child`](https://github.com/anggrayudi/SimpleStorage/blob/551fae55641dc58a9d3d99cb58fdf51c3d312b2d/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt#L270). Find the child file of a given parent uri and child name, null if doesn't exists (faster than `findFile`). 173 | 174 | - Original `UNSTABLE` `openDocumentFile`. Open a file uri in a external app, by starting a new activity with `ACTION_VIEW` Intent. 175 | 176 | - Original `UNSTABLE` `getRealPathFromUri`. Return the real path to work with native old `File` API instead Uris, be aware this approach is no longer supported on Android 10+ (API 29+) and though new, this API is **marked as deprecated** and should be migrated to a _scoped-storage_ approach. 177 | 178 | - Alias `getDocumentContentAsString`. Alias for `getDocumentContent`. Convert all bytes returned by the original method into a `String`. 179 | 180 | - Internal `DocumentBitmap` class added. Commonly used as thumbnail image/bitmap of a `DocumentFile`. 181 | 182 | - Extension `UriDocumentFileUtils` on `Uri` (Accesible by `uri.extensionMethod(...)`). 183 | 184 | - Alias `toDocumentFile`. Alias for `DocumentFile.fromTreeUri(this)` which is an alias for `fromTreeUri`. method: convert `this` to the respective `DocumentFile` (if exists, otherwise `null`). 185 | - Alias `openDocumentFile`. Alias for `openDocumentFile`. 186 | 187 | - Mirror `getDownloadCacheDirectory` from [`Environment.getDataDirectory`](https://developer.android.com/reference/android/os/Environment#getDownloadCacheDirectory%28%29). 188 | 189 | - Mirror `getStorageDirectory` from [`Environment.getStorageDirectory`](https://developer.android.com/reference/android/os/Environment#getStorageDirectory%28%29). 190 | 191 | ### Deprecation notices 192 | 193 | - `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. 194 | 195 | ## 0.2.0 196 | 197 | Add basic support for `Storage Access Framework` and `targetSdk 31`. 198 | 199 | - The package now supports basic intents from `Storage Access Framework`. 200 | - Your App needs update the `build.gradle` by targeting the current sdk to `31`. 201 | 202 | ## 0.1.1 203 | 204 | Minor improvements on `pub.dev` documentation. 205 | 206 | - Add `example/` folder. 207 | - Add missing `pubspec.yaml` properties. 208 | 209 | ## 0.1.0 210 | 211 | Initial release. 212 | -------------------------------------------------------------------------------- /lib/src/saf/saf.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | import 'dart:typed_data'; 5 | 6 | import '../../saf.dart'; 7 | import '../channels.dart'; 8 | import '../common/functional_extender.dart'; 9 | import 'common.dart'; 10 | 11 | /// {@template sharedstorage.saf.openDocumentTree} 12 | /// Start Activity Action: Allow the user to pick a directory subtree. 13 | /// 14 | /// When invoked, the system will display the various `DocumentsProvider` 15 | /// instances installed on the device, letting the user navigate through them. 16 | /// Apps can fully manage documents within the returned directory. 17 | /// 18 | /// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT_TREE). 19 | /// 20 | /// support the initial directory of the directory picker. 21 | /// {@endtemplate} 22 | Future openDocumentTree({ 23 | bool grantWritePermission = true, 24 | bool persistablePermission = true, 25 | Uri? initialUri, 26 | }) async { 27 | const String kOpenDocumentTree = 'openDocumentTree'; 28 | 29 | final Map args = { 30 | 'grantWritePermission': grantWritePermission, 31 | 'persistablePermission': persistablePermission, 32 | if (initialUri != null) 'initialUri': '$initialUri', 33 | }; 34 | 35 | final String? selectedDirectoryUri = 36 | await kDocumentFileChannel.invokeMethod(kOpenDocumentTree, args); 37 | 38 | return selectedDirectoryUri?.apply((String e) => Uri.parse(e)); 39 | } 40 | 41 | /// [Refer to details](https://developer.android.com/reference/android/content/Intent#ACTION_OPEN_DOCUMENT). 42 | Future?> openDocument({ 43 | Uri? initialUri, 44 | bool grantWritePermission = true, 45 | bool persistablePermission = true, 46 | String mimeType = '*/*', 47 | bool multiple = false, 48 | }) async { 49 | const String kOpenDocument = 'openDocument'; 50 | 51 | final Map args = { 52 | if (initialUri != null) 'initialUri': '$initialUri', 53 | 'grantWritePermission': grantWritePermission, 54 | 'persistablePermission': persistablePermission, 55 | 'mimeType': mimeType, 56 | 'multiple': multiple, 57 | }; 58 | 59 | final List? selectedUriList = 60 | await kDocumentFileChannel.invokeListMethod(kOpenDocument, args); 61 | 62 | return selectedUriList?.apply( 63 | (List e) => e.map((dynamic e) => Uri.parse(e as String)).toList(), 64 | ); 65 | } 66 | 67 | /// {@template sharedstorage.saf.persistedUriPermissions} 68 | /// Returns an `List` with all persisted [Uri] 69 | /// 70 | /// To persist an [Uri] call `openDocumentTree`. 71 | /// 72 | /// To remove an persisted [Uri] call `releasePersistableUriPermission`. 73 | /// {@endtemplate} 74 | Future?> persistedUriPermissions() async { 75 | final List? persistedUriPermissions = 76 | await kDocumentFileChannel.invokeListMethod('persistedUriPermissions'); 77 | 78 | return persistedUriPermissions?.apply( 79 | (List p) => p 80 | .map( 81 | (dynamic e) => UriPermission.fromMap( 82 | Map.from(e as Map), 83 | ), 84 | ) 85 | .toList(), 86 | ); 87 | } 88 | 89 | /// {@template sharedstorage.saf.releasePersistableUriPermission} 90 | /// Will revoke an persistable Uri. 91 | /// 92 | /// Call this when your App no longer wants the permission of an [Uri] returned 93 | /// by `openDocumentTree` method. 94 | /// 95 | /// To get the current persisted [Uri]s call `persistedUriPermissions`. 96 | /// 97 | /// [Refer to details](https://developer.android.com/reference/android/content/ContentResolver#releasePersistableUriPermission(android.net.Uri,%20int)). 98 | /// {@endtemplate} 99 | Future releasePersistableUriPermission(Uri directory) async { 100 | await kDocumentFileChannel.invokeMethod( 101 | 'releasePersistableUriPermission', 102 | {'uri': '$directory'}, 103 | ); 104 | } 105 | 106 | /// {@template sharedstorage.saf.isPersistedUri} 107 | /// Convenient method to verify if a given [uri]. 108 | /// is allowed to be write or read from SAF API's. 109 | /// 110 | /// This uses the `releasePersistableUriPermission` method to get the List 111 | /// of allowed [Uri]s then will verify if the [uri] is included in. 112 | /// {@endtemplate} 113 | Future isPersistedUri(Uri uri) async { 114 | final List? persistedUris = await persistedUriPermissions(); 115 | 116 | return persistedUris 117 | ?.any((UriPermission persistedUri) => persistedUri.uri == uri) ?? 118 | false; 119 | } 120 | 121 | /// {@template sharedstorage.saf.canRead} 122 | /// Equivalent to `DocumentFile.canRead`. 123 | /// 124 | /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#canRead()). 125 | /// {@endtemplate} 126 | Future canRead(Uri uri) async => kDocumentFileChannel 127 | .invokeMethod('canRead', {'uri': '$uri'}); 128 | 129 | /// {@template sharedstorage.saf.canWrite} 130 | /// Equivalent to `DocumentFile.canWrite`. 131 | /// 132 | /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#canWrite()). 133 | /// {@endtemplate} 134 | Future canWrite(Uri uri) async => kDocumentFileChannel 135 | .invokeMethod('canWrite', {'uri': '$uri'}); 136 | 137 | /// {@template sharedstorage.saf.getDocumentThumbnail} 138 | /// Equivalent to `DocumentsContract.getDocumentThumbnail`. 139 | /// 140 | /// [Refer to details](https://developer.android.com/reference/android/provider/DocumentsContract#getDocumentThumbnail(android.content.ContentResolver,%20android.net.Uri,%20android.graphics.Point,%20android.os.CancellationSignal)). 141 | /// {@endtemplate} 142 | Future getDocumentThumbnail({ 143 | required Uri uri, 144 | required double width, 145 | required double height, 146 | }) async { 147 | final Map args = { 148 | 'uri': '$uri', 149 | 'width': width, 150 | 'height': height, 151 | }; 152 | 153 | final Map? bitmap = await kDocumentsContractChannel 154 | .invokeMapMethod('getDocumentThumbnail', args); 155 | 156 | return bitmap?.apply((Map b) => DocumentBitmap.fromMap(b)); 157 | } 158 | 159 | /// {@template sharedstorage.saf.listFiles} 160 | /// **Important**: Ensure you have read permission by calling `canRead` before calling `listFiles`. 161 | /// 162 | /// Emits a new event for each child document file. 163 | /// 164 | /// Works with small and large data file sets. 165 | /// 166 | /// ```dart 167 | /// /// Usage: 168 | /// 169 | /// final myState = []; 170 | /// 171 | /// final onDocumentFile = listFiles(myUri, [DocumentFileColumn.id]); 172 | /// 173 | /// onDocumentFile.listen((document) { 174 | /// myState.add(document); 175 | /// 176 | /// final documentId = document.data?[DocumentFileColumn.id] 177 | /// 178 | /// print('$documentId was added to state'); 179 | /// }); 180 | /// ``` 181 | /// 182 | /// [Refer to details](https://stackoverflow.com/questions/41096332/issues-traversing-through-directory-hierarchy-with-android-storage-access-framew). 183 | /// {@endtemplate} 184 | Stream listFiles( 185 | Uri uri, { 186 | required List columns, 187 | }) { 188 | final Map args = { 189 | 'uri': '$uri', 190 | 'event': 'listFiles', 191 | 'columns': columns.map((DocumentFileColumn e) => '$e').toList(), 192 | }; 193 | 194 | final Stream onCursorRowResult = 195 | kDocumentFileEventChannel.receiveBroadcastStream(args); 196 | 197 | return onCursorRowResult.map( 198 | (dynamic e) => DocumentFile.fromMap( 199 | Map.from(e as Map), 200 | ), 201 | ); 202 | } 203 | 204 | /// {@template sharedstorage.saf.exists} 205 | /// Equivalent to `DocumentFile.exists`. 206 | /// 207 | /// Verify wheter or not a given [uri] exists. 208 | /// 209 | /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#exists()). 210 | /// {@endtemplate} 211 | Future exists(Uri uri) async => kDocumentFileChannel 212 | .invokeMethod('exists', {'uri': '$uri'}); 213 | 214 | /// {@template sharedstorage.saf.delete} 215 | /// Equivalent to `DocumentFile.delete`. 216 | /// 217 | /// Returns `true` if deleted successfully. 218 | /// 219 | /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#delete%28%29). 220 | /// {@endtemplate} 221 | Future delete(Uri uri) async => kDocumentFileChannel 222 | .invokeMethod('delete', {'uri': '$uri'}); 223 | 224 | /// {@template sharedstorage.saf.createDirectory} 225 | /// Create a direct child document tree named `displayName` given a parent `parentUri`. 226 | /// 227 | /// Equivalent to `DocumentFile.createDirectory`. 228 | /// 229 | /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#createDirectory%28java.lang.String%29). 230 | /// {@endtemplate} 231 | Future createDirectory(Uri parentUri, String displayName) async { 232 | final Map args = { 233 | 'uri': '$parentUri', 234 | 'displayName': displayName, 235 | }; 236 | 237 | final Map? createdDocumentFile = await kDocumentFileChannel 238 | .invokeMapMethod('createDirectory', args); 239 | 240 | return createdDocumentFile 241 | ?.apply((Map c) => DocumentFile.fromMap(c)); 242 | } 243 | 244 | /// {@template sharedstorage.saf.createFile} 245 | /// Convenient method to create files using either [String] or raw bytes [Uint8List]. 246 | /// 247 | /// Under the hood this method calls `createFileAsString` or `createFileAsBytes` 248 | /// depending on which argument is passed. 249 | /// 250 | /// If both (bytes and content) are passed, the bytes will be used and the content will be ignored. 251 | /// {@endtemplate} 252 | Future createFile( 253 | Uri parentUri, { 254 | required String mimeType, 255 | required String displayName, 256 | Uint8List? bytes, 257 | String content = '', 258 | }) { 259 | return bytes != null 260 | ? createFileAsBytes( 261 | parentUri, 262 | mimeType: mimeType, 263 | displayName: displayName, 264 | bytes: bytes, 265 | ) 266 | : createFileAsString( 267 | parentUri, 268 | mimeType: mimeType, 269 | displayName: displayName, 270 | content: content, 271 | ); 272 | } 273 | 274 | /// {@template sharedstorage.saf.createFileAsBytes} 275 | /// Create a direct child document of `parentUri`. 276 | /// - `mimeType` is the type of document following [this specs](https://www.iana.org/assignments/media-types/media-types.xhtml). 277 | /// - `displayName` is the name of the document, must be a valid file name. 278 | /// - `bytes` is the content of the document as a list of bytes `Uint8List`. 279 | /// 280 | /// Returns the created file as a `DocumentFile`. 281 | /// 282 | /// Mirror of [`DocumentFile.createFile`](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#createFile(java.lang.String,%20java.lang.String)) 283 | /// {@endtemplate} 284 | Future createFileAsBytes( 285 | Uri parentUri, { 286 | required String mimeType, 287 | required String displayName, 288 | required Uint8List bytes, 289 | }) async { 290 | final String directoryUri = '$parentUri'; 291 | 292 | final Map args = { 293 | 'mimeType': mimeType, 294 | 'content': bytes, 295 | 'displayName': displayName, 296 | 'directoryUri': directoryUri, 297 | }; 298 | 299 | return invokeMapMethod('createFile', args); 300 | } 301 | 302 | /// {@template sharedstorage.saf.createFileAsString} 303 | /// Convenient method to create a file. 304 | /// using `content` as String instead Uint8List. 305 | /// {@endtemplate} 306 | Future createFileAsString( 307 | Uri parentUri, { 308 | required String mimeType, 309 | required String displayName, 310 | required String content, 311 | }) { 312 | return createFileAsBytes( 313 | parentUri, 314 | displayName: displayName, 315 | mimeType: mimeType, 316 | bytes: Uint8List.fromList(utf8.encode(content)), 317 | ); 318 | } 319 | 320 | /// {@template sharedstorage.saf.writeToFile} 321 | /// Convenient method to write to a file using either [String] or raw bytes [Uint8List]. 322 | /// 323 | /// Under the hood this method calls `writeToFileAsString` or `writeToFileAsBytes` 324 | /// depending on which argument is passed. 325 | /// 326 | /// If both (bytes and content) are passed, the bytes will be used and the content will be ignored. 327 | /// {@endtemplate} 328 | Future writeToFile( 329 | Uri uri, { 330 | Uint8List? bytes, 331 | String? content, 332 | FileMode? mode, 333 | }) { 334 | assert( 335 | bytes != null || content != null, 336 | '''Either [bytes] or [content] should be provided''', 337 | ); 338 | 339 | return bytes != null 340 | ? writeToFileAsBytes( 341 | uri, 342 | bytes: bytes, 343 | mode: mode, 344 | ) 345 | : writeToFileAsString( 346 | uri, 347 | content: content!, 348 | mode: mode, 349 | ); 350 | } 351 | 352 | /// {@template sharedstorage.saf.writeToFileAsBytes} 353 | /// Write to a file. 354 | /// - `uri` is the URI of the file. 355 | /// - `bytes` is the content of the document as a list of bytes `Uint8List`. 356 | /// - `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. 357 | /// 358 | /// Returns `true` if the file was successfully written to. 359 | /// {@endtemplate} 360 | Future writeToFileAsBytes( 361 | Uri uri, { 362 | required Uint8List bytes, 363 | FileMode? mode, 364 | }) async { 365 | final String writeMode = 366 | mode == FileMode.append || mode == FileMode.writeOnlyAppend ? 'wa' : 'wt'; 367 | 368 | final Map args = { 369 | 'uri': '$uri', 370 | 'content': bytes, 371 | 'mode': writeMode, 372 | }; 373 | 374 | return kDocumentFileChannel.invokeMethod('writeToFile', args); 375 | } 376 | 377 | /// {@template sharedstorage.saf.writeToFileAsString} 378 | /// Convenient method to write to a file. 379 | /// using `content` as [String] instead [Uint8List]. 380 | /// {@endtemplate} 381 | Future writeToFileAsString( 382 | Uri uri, { 383 | required String content, 384 | FileMode? mode, 385 | }) { 386 | return writeToFileAsBytes( 387 | uri, 388 | bytes: Uint8List.fromList(utf8.encode(content)), 389 | mode: mode, 390 | ); 391 | } 392 | 393 | /// {@template sharedstorage.saf.length} 394 | /// Equivalent to `DocumentFile.length`. 395 | /// 396 | /// Returns the size of a given document `uri` in bytes. 397 | /// 398 | /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#length%28%29). 399 | /// {@endtemplate} 400 | Future documentLength(Uri uri) async => kDocumentFileChannel 401 | .invokeMethod('length', {'uri': '$uri'}); 402 | 403 | /// {@template sharedstorage.saf.lastModified} 404 | /// Equivalent to `DocumentFile.lastModified`. 405 | /// 406 | /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#lastModified%28%29). 407 | /// {@endtemplate} 408 | Future lastModified(Uri uri) async { 409 | const String kLastModified = 'lastModified'; 410 | 411 | final int? inMillisecondsSinceEpoch = await kDocumentFileChannel 412 | .invokeMethod(kLastModified, {'uri': '$uri'}); 413 | 414 | return inMillisecondsSinceEpoch 415 | ?.takeIf((int i) => i > 0) 416 | ?.apply((int i) => DateTime.fromMillisecondsSinceEpoch(i)); 417 | } 418 | 419 | /// {@template sharedstorage.saf.findFile} 420 | /// Equivalent to `DocumentFile.findFile`. 421 | /// 422 | /// If you want to check if a given document file exists by their [displayName] prefer using `child` instead. 423 | /// 424 | /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#findFile%28java.lang.String%29). 425 | /// {@endtemplate} 426 | Future findFile(Uri directoryUri, String displayName) async { 427 | final Map args = { 428 | 'uri': '$directoryUri', 429 | 'displayName': displayName, 430 | }; 431 | 432 | return invokeMapMethod('findFile', args); 433 | } 434 | 435 | /// {@template sharedstorage.saf.renameTo} 436 | /// Rename the current document `uri` to a new `displayName`. 437 | /// 438 | /// **Note: after using this method `uri` is not longer valid, 439 | /// use the returned document instead**. 440 | /// 441 | /// Returns the updated document. 442 | /// 443 | /// Equivalent to `DocumentFile.renameTo`. 444 | /// 445 | /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#renameTo%28java.lang.String%29). 446 | /// {@endtemplate} 447 | Future renameTo(Uri uri, String displayName) async => 448 | invokeMapMethod( 449 | 'renameTo', 450 | {'uri': '$uri', 'displayName': displayName}, 451 | ); 452 | 453 | /// {@template sharedstorage.saf.fromTreeUri} 454 | /// Create a new `DocumentFile` instance given `uri`. 455 | /// 456 | /// Equivalent to `DocumentFile.fromTreeUri`. 457 | /// 458 | /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#fromTreeUri%28android.content.Context,%20android.net.Uri%29). 459 | /// {@endtemplate} 460 | Future fromTreeUri(Uri uri) async => 461 | invokeMapMethod('fromTreeUri', {'uri': '$uri'}); 462 | 463 | /// {@template sharedstorage.saf.child} 464 | /// Return the `child` of the given `uri` if it exists otherwise `null`. 465 | /// 466 | /// It's faster than [DocumentFile.findFile] 467 | /// `path` is the single file name or file path. Empty string returns to itself. 468 | /// 469 | /// Equivalent to `DocumentFile.child` extension/overload. 470 | /// 471 | /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#fromTreeUri%28android.content.Context,%20android.net.Uri%29) 472 | /// {@endtemplate} 473 | @willbemovedsoon 474 | Future child( 475 | Uri uri, 476 | String path, { 477 | bool requiresWriteAccess = false, 478 | }) async { 479 | final Map args = { 480 | 'uri': '$uri', 481 | 'path': path, 482 | 'requiresWriteAccess': requiresWriteAccess, 483 | }; 484 | 485 | return invokeMapMethod('child', args); 486 | } 487 | 488 | /// {@template sharedstorage.saf.share} 489 | /// Start share intent for the given [uri]. 490 | /// 491 | /// To share a file, use [Uri.parse] passing the file absolute path as argument. 492 | /// 493 | /// Note that this method can only share files that your app has permission over, 494 | /// either by being in your app domain (e.g file from your app cache) or that is granted by [openDocumentTree]. 495 | /// {@endtemplate} 496 | @willbemovedsoon 497 | Future shareUri( 498 | Uri uri, { 499 | String? type, 500 | }) { 501 | final Map args = { 502 | 'uri': '$uri', 503 | 'type': type, 504 | }; 505 | 506 | return kDocumentFileHelperChannel.invokeMethod('shareUri', args); 507 | } 508 | 509 | /// {@template sharedstorage.saf.openDocumentFile} 510 | /// It's a convenience method to launch the default application associated 511 | /// with the given MIME type and can't be considered an official SAF API. 512 | /// 513 | /// Launch `ACTION_VIEW` intent to open the given document `uri`. 514 | /// 515 | /// Throws an `PlatformException` with code `EXCEPTION_ACTIVITY_NOT_FOUND` if the activity is not found 516 | /// to the respective MIME type of the give Uri. 517 | /// 518 | /// Returns `true` if launched successfully otherwise `false`. 519 | /// {@endtemplate} 520 | Future openDocumentFile(Uri uri) async { 521 | final bool? successfullyLaunched = 522 | await kDocumentFileHelperChannel.invokeMethod( 523 | 'openDocumentFile', 524 | {'uri': '$uri'}, 525 | ); 526 | 527 | return successfullyLaunched; 528 | } 529 | 530 | /// {@template sharedstorage.saf.parentFile} 531 | /// Get the parent file of the given `uri`. 532 | /// 533 | /// Equivalent to `DocumentFile.getParentFile`. 534 | /// 535 | /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#getParentFile%28%29). 536 | /// {@endtemplate} 537 | Future parentFile(Uri uri) async => 538 | invokeMapMethod('parentFile', {'uri': '$uri'}); 539 | 540 | /// {@template sharedstorage.saf.copy} 541 | /// Copy a document `uri` to the `destination`. 542 | /// 543 | /// This API uses the `createFile` and `getDocumentContent` API's behind the scenes. 544 | /// {@endtemplate} 545 | Future copy(Uri uri, Uri destination) async { 546 | final Map args = { 547 | 'uri': '$uri', 548 | 'destination': '$destination' 549 | }; 550 | 551 | return invokeMapMethod('copy', args); 552 | } 553 | 554 | /// {@template sharedstorage.saf.getDocumentContent} 555 | /// Get content of a given document `uri`. 556 | /// 557 | /// Equivalent to `contentDescriptor` usage. 558 | /// 559 | /// [Refer to details](https://developer.android.com/training/data-storage/shared/documents-files#input_stream). 560 | /// {@endtemplate} 561 | Future getDocumentContent(Uri uri) async => 562 | kDocumentFileChannel.invokeMethod( 563 | 'getDocumentContent', 564 | {'uri': '$uri'}, 565 | ); 566 | 567 | /// {@template sharedstorage.saf.getDocumentContentAsString} 568 | /// Helper method to read document using 569 | /// `getDocumentContent` and get the content as String instead as `Uint8List`. 570 | /// {@endtemplate} 571 | Future getDocumentContentAsString( 572 | Uri uri, { 573 | bool throwIfError = false, 574 | }) async { 575 | final Uint8List? bytes = await getDocumentContent(uri); 576 | 577 | return bytes?.apply((Uint8List a) => utf8.decode(a)); 578 | } 579 | 580 | /// {@template sharedstorage.saf.getDocumentContentAsString} 581 | /// Helper method to generate the file path of the given `uri` 582 | /// 583 | /// See [Get real path from URI, Android KitKat new storage access framework](https://stackoverflow.com/questions/20067508/get-real-path-from-uri-android-kitkat-new-storage-access-framework/20559175#20559175) 584 | /// for details. 585 | /// {@endtemplate} 586 | Future getRealPathFromUri(Uri uri) async => kDocumentFileHelperChannel 587 | .invokeMethod('getRealPathFromUri', {'uri': '$uri'}); 588 | --------------------------------------------------------------------------------