├── .fvm └── fvm_config.json ├── .github └── workflows │ ├── analysis.yml │ ├── backend.yml │ ├── examples.yml │ ├── format.yml │ └── test.yml ├── .gitignore ├── .metadata ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── build.gradle ├── gradle.properties ├── settings.gradle └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── bluechilli │ │ └── flutteruploader │ │ ├── CountProgressListener.java │ │ ├── CountingRequestBody.java │ │ ├── FileItem.java │ │ ├── FlutterUploaderInitializer.java │ │ ├── FlutterUploaderPlugin.java │ │ ├── MethodCallHandlerImpl.java │ │ ├── SharedPreferenceHelper.java │ │ ├── UploadExecutorService.java │ │ ├── UploadStatus.java │ │ ├── UploadTask.java │ │ ├── UploadWorker.java │ │ └── plugin │ │ ├── CachingStreamHandler.java │ │ ├── StatusListener.java │ │ └── UploadObserver.java │ └── res │ ├── drawable-hdpi │ └── ic_upload.png │ ├── drawable-mdpi │ └── ic_upload.png │ ├── drawable-xhdpi │ └── ic_upload.png │ ├── drawable-xxhdpi │ └── ic_upload.png │ ├── drawable-xxxhdpi │ └── ic_upload.png │ └── xml │ └── provider_path.xml ├── example ├── .gitignore ├── .metadata ├── README.md ├── android │ ├── app │ │ ├── build.gradle │ │ └── src │ │ │ ├── androidTest │ │ │ └── java │ │ │ │ └── com │ │ │ │ └── bluechilli │ │ │ │ └── flutteruploaderexample │ │ │ │ └── FlutterActivityTest.java │ │ │ ├── debug │ │ │ └── AndroidManifest.xml │ │ │ ├── main │ │ │ ├── AndroidManifest.xml │ │ │ ├── java │ │ │ │ └── io │ │ │ │ │ └── flutter │ │ │ │ │ └── app │ │ │ │ │ └── FlutterMultiDexApplication.java │ │ │ └── res │ │ │ │ ├── drawable │ │ │ │ └── launch_background.xml │ │ │ │ ├── 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 │ │ │ │ ├── values │ │ │ │ └── styles.xml │ │ │ │ └── xml │ │ │ │ └── network_security_config.xml │ │ │ └── profile │ │ │ └── AndroidManifest.xml │ ├── build.gradle │ ├── gradle.properties │ ├── gradle │ │ └── wrapper │ │ │ └── gradle-wrapper.properties │ └── settings.gradle ├── backend │ ├── .firebaserc │ ├── .gitignore │ ├── README.md │ ├── firebase.json │ ├── functions │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── index.js │ │ ├── package-lock.json │ │ └── package.json │ └── package-lock.json ├── integration_test │ └── flutter_uploader_integration_test.dart ├── ios │ ├── .swiftlint.yml │ ├── Flutter │ │ ├── AppFrameworkInfo.plist │ │ ├── Debug.xcconfig │ │ └── Release.xcconfig │ ├── Runner.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── Runner.xcscheme │ ├── Runner.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── Runner │ │ ├── AppDelegate.swift │ │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ └── Icon-App-83.5x83.5@2x.png │ │ └── LaunchImage.imageset │ │ │ ├── Contents.json │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ └── README.md │ │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ │ ├── GoogleService-Info.plist │ │ ├── Info.plist │ │ └── Runner-Bridging-Header.h ├── lib │ ├── main.dart │ ├── responses_screen.dart │ ├── server_behavior.dart │ ├── upload_item.dart │ ├── upload_item_view.dart │ └── upload_screen.dart └── pubspec.yaml ├── ios ├── .gitignore ├── Assets │ └── .gitkeep ├── Classes │ ├── CachingStreamHandler.swift │ ├── EngineManager.swift │ ├── FlutterUploaderPlugin.h │ ├── FlutterUploaderPlugin.m │ ├── Key.swift │ ├── MimeType.swift │ ├── SwiftFlutterUploaderPlugin.swift │ ├── URLSessionTask.State+statusText.swift │ ├── URLSessionUploader.swift │ ├── UploadFileInfo.swift │ ├── UploadResultDatabase.swift │ ├── UploadTask.swift │ ├── UploaderDefaults.swift │ └── UploaderDelegate.swift └── flutter_uploader.podspec ├── lib ├── flutter_uploader.dart └── src │ ├── file_item.dart │ ├── flutter_uploader.dart │ ├── upload.dart │ ├── upload_method.dart │ ├── upload_task_progress.dart │ ├── upload_task_response.dart │ └── upload_task_status.dart ├── pubspec.yaml ├── script └── format.sh └── test ├── flutter_uploader_test.dart └── flutter_uploader_test.mocks.dart /.fvm/fvm_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "flutterSdkVersion": "stable" 3 | } -------------------------------------------------------------------------------- /.github/workflows/analysis.yml: -------------------------------------------------------------------------------- 1 | name: analysis 2 | on: pull_request 3 | 4 | jobs: 5 | package-analysis: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 # required 9 | - uses: axel-op/dart-package-analyzer@v3 10 | with: 11 | # Required: 12 | githubToken: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/backend.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Backend 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [12.x, 14.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: cd example/backend/functions && npm ci 25 | - run: cd example/backend/functions && npm run-script lint -------------------------------------------------------------------------------- /.github/workflows/examples.yml: -------------------------------------------------------------------------------- 1 | name: examples 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | example_android: 11 | runs-on: ubuntu-18.04 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-java@v1 15 | with: 16 | java-version: 11 17 | - uses: subosito/flutter-action@v1 18 | with: 19 | channel: "stable" 20 | 21 | - name: build 22 | run: | 23 | cd example && flutter build apk --debug 24 | 25 | example_ios: 26 | runs-on: macos-latest 27 | steps: 28 | - uses: actions/checkout@v2 29 | - uses: subosito/flutter-action@v1 30 | with: 31 | channel: "stable" 32 | 33 | - name: build 34 | run: | 35 | cd example && flutter build ios --debug --no-codesign 36 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: format + publishable 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | format_java: 11 | runs-on: ubuntu-18.04 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-java@v1 15 | with: 16 | java-version: 11 17 | - uses: axel-op/googlejavaformat-action@v3 18 | - uses: subosito/flutter-action@v1 19 | with: 20 | channel: 'stable' 21 | 22 | - name: Format 23 | run: | 24 | flutter format --set-exit-if-changed . 25 | 26 | format_swift: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v2 30 | - name: GitHub Action for SwiftLint 31 | uses: norio-nomura/action-swiftlint@3.2.1 32 | 33 | publishable: 34 | runs-on: ubuntu-18.04 35 | steps: 36 | - uses: actions/checkout@v2 37 | 38 | - uses: subosito/flutter-action@v1 39 | with: 40 | channel: 'stable' 41 | 42 | - name: publish checks 43 | run: | 44 | flutter pub get 45 | flutter pub publish -n 46 | dart pub global activate tuneup 47 | dart pub global run tuneup check -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-18.04 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - uses: subosito/flutter-action@v1 16 | with: 17 | channel: 'stable' 18 | 19 | - name: Test 20 | run: | 21 | flutter pub get 22 | flutter test 23 | 24 | drive_ios: 25 | strategy: 26 | matrix: 27 | device: 28 | - "iPhone 12 Pro" 29 | fail-fast: false 30 | runs-on: macos-latest 31 | steps: 32 | - uses: futureware-tech/simulator-action@v1 33 | with: 34 | model: '${{ matrix.device }}' 35 | - uses: actions/checkout@v2 36 | - uses: subosito/flutter-action@v1 37 | with: 38 | channel: 'stable' 39 | # Run flutter integrate tests 40 | - name: Run Flutter integration tests 41 | run: cd example && flutter test integration_test/flutter_uploader_integration_test.dart 42 | 43 | # Skipped until Flutter supports `--multidex` for integration tests 44 | # drive_android: 45 | # runs-on: macos-latest 46 | # #creates a build matrix for your jobs 47 | # strategy: 48 | # #set of different configurations of the virtual environment. 49 | # matrix: 50 | # api-level: [29] 51 | # # api-level: [21, 29] 52 | # target: [default] 53 | # needs: test 54 | # steps: 55 | # - uses: actions/checkout@v2 56 | # - uses: subosito/flutter-action@v1 57 | # with: 58 | # channel: 'stable' 59 | # - name: Run Flutter Driver tests 60 | # uses: reactivecircus/android-emulator-runner@v2 61 | # with: 62 | # api-level: ${{ matrix.api-level }} 63 | # target: ${{ matrix.target }} 64 | # arch: x86_64 65 | # profile: Nexus 6 66 | # script: cd example && flutter test integration_test/flutter_uploader_integration_test.dart -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .atom/ 3 | .idea/ 4 | .vscode/ 5 | 6 | .packages 7 | .pub/ 8 | .dart_tool/ 9 | pubspec.lock 10 | flutter_export_environment.sh 11 | 12 | examples/all_plugins/pubspec.yaml 13 | 14 | Podfile 15 | Podfile.lock 16 | Pods/ 17 | .symlinks/ 18 | **/Flutter/App.framework/ 19 | **/Flutter/ephemeral/ 20 | **/Flutter/Flutter.framework/ 21 | **/Flutter/Generated.xcconfig 22 | **/Flutter/flutter_assets/ 23 | 24 | ServiceDefinitions.json 25 | xcuserdata/ 26 | **/DerivedData/ 27 | 28 | local.properties 29 | keystore.properties 30 | .gradle/ 31 | gradlew 32 | gradlew.bat 33 | gradle-wrapper.jar 34 | .flutter-plugins-dependencies 35 | *.iml 36 | 37 | generated_plugin_registrant.dart 38 | GeneratedPluginRegistrant.h 39 | GeneratedPluginRegistrant.m 40 | GeneratedPluginRegistrant.java 41 | GeneratedPluginRegistrant.swift 42 | build/ 43 | .flutter-plugins 44 | 45 | .project 46 | .classpath 47 | .settings 48 | .last_build_id 49 | .fvm/flutter_sdk 50 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: fec01201b1a491d1d404825829cfd4e5992dbbaa 8 | channel: master 9 | 10 | project_type: plugin 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 3.0.0-beta.4 2 | 3 | - Restore compatibility with Android 12 4 | - Ensure plugin is compatible with Flutter 2.10 5 | 6 | ## 3.0.0-beta.3 7 | 8 | - Add flag to restrict uploads to wifi only (#188) 9 | - Fix ClassCastException on Android (#196) 10 | - Do not persist nil values in result database (#190) 11 | 12 | ## 3.0.0-beta.2 13 | 14 | - Android: Restore concurrency setting for uploads (#174). 15 | 16 | ## 3.0.0-beta.1 17 | 18 | - Migrate to nullsafety 19 | 20 | ## 2.0.0-beta.6 21 | 22 | - Android: Ensure to properly unregister upload observers 23 | - Android: Call back to UI on the main thread (#132) 24 | 25 | ## 2.0.0-beta.5 26 | 27 | - Resolves crashes on iOS when using multiple concurrent uploads 28 | - Additional documentation on results stream 29 | - Android: Clear "progress" as well when a `clearUploads` is called 30 | 31 | ## 2.0.0-beta.4 32 | 33 | - Bump Flutter & Android dependencies, which also resolves the multi-file selection issue in the example 34 | - Android: Ensure `clearUploads` also clears the cache held in memory (#119) 35 | - Added more documentation for the `result` stream 36 | - Correct homepage field in `pubspec.yaml` 37 | - Android: Set `compile` & `target` (for example app) SDK versions to 30 38 | 39 | ## 2.0.0-beta.3 40 | 41 | - Update maintainer field in `pubspec.yaml` 42 | 43 | ## 2.0.0-beta.2 44 | 45 | - Moved package to Flutter Community 46 | 47 | ## 2.0.0-beta.1 48 | 49 | - Runs a Flutter isolate in the background while a upload is running. The entry point can be set using `setBackgroundHandler`. 50 | - Notification handling has been removed from this plugin entirely. Developers can use `FlutterLocalNotifications` using the new isolate mechanism. 51 | - Extends the example & test backend with simulations for various HTTP responses, status codes etc. 52 | - Adds multi-file picking support to the example. 53 | - Adds E2E tests including CI config for iOS/Android 54 | - Adds basic unit tests to confirm message passing Dart <-> Native 55 | - Android: Support Androids Flutters V2 embedding 56 | - Simplify FileItem and replace the `savedDir`/`filename` parameters with `path` 57 | - Uploads with unknown mime type will default to `application/octet-stream` 58 | 59 | ## 1.2.0 60 | 61 | - iOS: fix multipartform upload to be able upload large files 62 | - iOS: fix multipartform upload to be able upload multiple files in one upload task 63 | 64 | ## 1.1.0 65 | 66 | - iOS: define clang module 67 | - iOS: upgrade example project xcode version & compatibility 68 | 69 | ## 1.0.6 70 | 71 | - fix #21 - handle other successful status code (from http spec) in iOS 72 | 73 | ## 1.0.5+1 74 | 75 | - Android: update AGP and various dependencies 76 | - Android: fixes memory leaks in the example project due to old image_picker dependency 77 | - Android: fix memory leak due not unregistering ActivityLifecycleCallbacks 78 | - Android: fix memory leak due to not unregistering WorkManager observers 79 | 80 | ## 1.0.3+2 81 | 82 | - fix bug that upon cancellation it was cancelling the work request however it wasn't cancelling the already progressing upload request (android); 83 | 84 | ## 1.0.3+1 85 | 86 | - remove Accept-Encoding header because OkHttp transparently adds it (android) 87 | - documentation update 88 | - clean up some code 89 | 90 | ## 1.0.3 91 | 92 | - prevent from start uploading when directory is passed as file path 93 | - update androidx workmanager to 2.0.0 94 | - use observable for tracking progress instead of localbroadcastmanager (deprecation) on android 95 | - fixed few typoes in code 96 | - fixes few typoes in document 97 | 98 | ## 1.0.2 99 | 100 | Thanks @ened for pull requests 101 | 102 | - Prevent basic NPE when Activity is not set 103 | - Upgrade example dependencies 104 | - Use the latest gradle plugin 105 | 106 | ## 1.0.1 107 | 108 | - updated licence 109 | 110 | ## 1.0.0 111 | 112 | - initial release 113 | - feature constists of: enqueue, cancel, cancelAll 114 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Blue Chilli Technology Pty Ltd and the contributors 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Flutter Community: flutter_uploader](https://fluttercommunity.dev/_github/header/flutter_uploader)](https://github.com/fluttercommunity/community) 2 | 3 | # Flutter Uploader 4 | 5 | A plugin for creating and managing upload tasks. Supports iOS and Android. 6 | 7 | This plugin is based on [`WorkManager`][1] in Android and [`NSURLSessionUploadTask`][2] in iOS to run upload task in background mode. 8 | 9 | This plugin is inspired by [`flutter_downloader`][5]. Thanks to Hung Duy Ha & Flutter Community for great plugins and inspiration. 10 | 11 | ## iOS integration 12 | 13 | - Enable background mode. 14 | 15 | 16 | 17 | ### AppDelegate changes 18 | 19 | The plugin supports a background isolate. In order for plugins to work, you need to adjust your AppDelegate as follows: 20 | 21 | ```swift 22 | import flutter_uploader 23 | 24 | func registerPlugins(registry: FlutterPluginRegistry) { 25 | GeneratedPluginRegistrant.register(with: registry) 26 | } 27 | 28 | @UIApplicationMain 29 | @objc class AppDelegate: FlutterAppDelegate { 30 | override func application( 31 | _ application: UIApplication, 32 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 33 | ) -> Bool { 34 | GeneratedPluginRegistrant.register(with: self) 35 | 36 | SwiftFlutterUploaderPlugin.registerPlugins = registerPlugins 37 | 38 | // Any further code. 39 | } 40 | } 41 | ``` 42 | 43 | ### Optional configuration: 44 | 45 | - **Configure maximum number of connection per host:** the plugin allows 3 simultaneous http connection per host running at a moment by default. You can change this number by adding following codes to your `Info.plist` file. 46 | 47 | ```xml 48 | 49 | FUMaximumConnectionsPerHost 50 | 3 51 | ``` 52 | 53 | - **Configure maximum number of concurrent upload operation:** the plugin allows 3 simultaneous upload operation running at a moment by default. You can change this number by adding following codes to your `Info.plist` file. 54 | 55 | ```xml 56 | 57 | FUMaximumUploadOperation 58 | 3 59 | ``` 60 | 61 | - **Configure request timeout:** controls how long (in seconds) a task should wait for additional data to arrive before giving up `Info.plist` file. 62 | 63 | ```xml 64 | 65 | FUTimeoutInSeconds 66 | 3600 67 | ``` 68 | 69 | ## Android integration 70 | 71 | ### Optional configuration: 72 | 73 | - **Configure maximum number of concurrent tasks:** the plugin depends on `WorkManager` library and `WorkManager` depends on the number of available processor to configure the maximum number of tasks running at a moment. You can setup a fixed number for this configuration by adding following codes to your `AndroidManifest.xml`: 74 | 75 | ```xml 76 | 81 | 82 | 86 | 87 | 90 | 91 | 92 | 93 | 94 | ``` 95 | 96 | ## Usage 97 | 98 | #### Import package: 99 | 100 | ```dart 101 | import 'package:flutter_uploader/flutter_uploader.dart'; 102 | ``` 103 | 104 | #### Configure a background isolate entry point 105 | 106 | First, define a top-level function: 107 | 108 | ```dart 109 | void backgroundHandler() { 110 | // Needed so that plugin communication works. 111 | WidgetsFlutterBinding.ensureInitialized(); 112 | 113 | // This uploader instance works within the isolate only. 114 | FlutterUploader uploader = FlutterUploader(); 115 | 116 | // You have now access to: 117 | uploader.progress.listen((progress) { 118 | // upload progress 119 | }); 120 | uploader.result.listen((result) { 121 | // upload results 122 | }); 123 | } 124 | ``` 125 | 126 | > The backgroundHandler function needs to be either a static function or a top level function to be accessible as a Flutter entry point. 127 | 128 | Once you have a function defined, configure it in your main `FlutterUploader` object like so: 129 | 130 | ```dart 131 | FlutterUploader().setBackgroundHandler(backgroundHandler); 132 | ``` 133 | 134 | To see how it all works, check out the example. 135 | 136 | #### Create new upload task: 137 | 138 | **multipart/form-data:** 139 | 140 | ```dart 141 | final taskId = await FlutterUploader().enqueue( 142 | MultipartFormDataUpload( 143 | url: "your upload link", //required: url to upload to 144 | files: [FileItem(path: '/path/to/file', fieldname:"file")], // required: list of files that you want to upload 145 | method: UploadMethod.POST, // HTTP method (POST or PUT or PATCH) 146 | headers: {"apikey": "api_123456", "userkey": "userkey_123456"}, 147 | data: {"name": "john"}, // any data you want to send in upload request 148 | tag: 'my tag', // custom tag which is returned in result/progress 149 | ), 150 | ); 151 | ``` 152 | 153 | **binary uploads:** 154 | 155 | ```dart 156 | final taskId = await FlutterUploader().enqueue( 157 | RawUpload( 158 | url: "your upload link", // required: url to upload to 159 | path: '/path/to/file', // required: list of files that you want to upload 160 | method: UploadMethod.POST, // HTTP method (POST or PUT or PATCH) 161 | headers: {"apikey": "api_123456", "userkey": "userkey_123456"}, 162 | tag: 'my tag', // custom tag which is returned in result/progress 163 | ), 164 | ); 165 | ``` 166 | 167 | The plugin will return a `taskId` which is unique for each upload. Hold onto it if you in order to cancel specific uploads. 168 | 169 | ### listen for upload progress 170 | 171 | ```dart 172 | final subscription = FlutterUploader().progress.listen((progress) { 173 | //... code to handle progress 174 | }); 175 | ``` 176 | 177 | ### listen for upload result 178 | 179 | ```dart 180 | final subscription = FlutterUploader().result.listen((result) { 181 | //... code to handle result 182 | }, onError: (ex, stacktrace) { 183 | // ... code to handle error 184 | }); 185 | ``` 186 | 187 | > when tasks are cancelled, it will send on onError handler as exception with status = cancelled 188 | 189 | Upload results are persisted by the plugin and will be submitted on each `.listen`. 190 | It is advised to keep a list of processed uploads in App side and call `clearUploads` on the FlutterUploader plugin once they can be removed. 191 | 192 | #### Cancel an upload task: 193 | 194 | ```dart 195 | FlutterUploader().cancel(taskId: taskId); 196 | ``` 197 | 198 | #### Cancel all upload tasks: 199 | 200 | ```dart 201 | FlutterUploader().cancelAll(); 202 | ``` 203 | 204 | #### Clear Uploads 205 | 206 | ```dart 207 | FlutterUploader().clearUploads() 208 | ``` 209 | 210 | [1]: https://developer.android.com/topic/libraries/architecture/workmanager 211 | [2]: https://developer.apple.com/documentation/foundation/nsurlsessionuploadtask?language=objc 212 | [3]: https://medium.com/@guerrix/info-plist-localization-ad5daaea732a 213 | [4]: https://developer.android.com/training/basics/supporting-devices/languages 214 | [5]: https://pub.dartlang.org/packages/flutter_downloader 215 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:flutter_lints/flutter.yaml 2 | 3 | linter: 4 | rules: 5 | - public_member_api_docs -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/workspace.xml 5 | /.idea/libraries 6 | .DS_Store 7 | /build 8 | /captures 9 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | group 'com.bluechilli.flutteruploader' 2 | version '1.0-SNAPSHOT' 3 | 4 | buildscript { 5 | repositories { 6 | google() 7 | mavenCentral() 8 | } 9 | 10 | dependencies { 11 | classpath 'com.android.tools.build:gradle:4.2.1' 12 | } 13 | } 14 | 15 | rootProject.allprojects { 16 | repositories { 17 | google() 18 | mavenCentral() 19 | } 20 | } 21 | 22 | apply plugin: 'com.android.library' 23 | 24 | android { 25 | compileSdkVersion 31 26 | 27 | defaultConfig { 28 | minSdkVersion 16 29 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 30 | } 31 | 32 | compileOptions { 33 | sourceCompatibility JavaVersion.VERSION_1_8 34 | targetCompatibility JavaVersion.VERSION_1_8 35 | } 36 | 37 | lintOptions { 38 | disable 'InvalidPackage' 39 | } 40 | } 41 | 42 | dependencies { 43 | implementation "androidx.work:work-runtime:2.7.1" 44 | implementation "androidx.concurrent:concurrent-futures:1.1.0" 45 | implementation "androidx.annotation:annotation:1.2.0" 46 | implementation "androidx.lifecycle:lifecycle-livedata:2.3.1" 47 | implementation "androidx.core:core:1.5.0" 48 | implementation "com.squareup.okhttp3:okhttp:4.9.0" 49 | implementation "com.google.code.gson:gson:2.8.6" 50 | } 51 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'flutter_uploader' 2 | android.enableJetifier=true 3 | android.useAndroidX=true -------------------------------------------------------------------------------- /android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /android/src/main/java/com/bluechilli/flutteruploader/CountProgressListener.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploader; 2 | 3 | public interface CountProgressListener { 4 | 5 | void OnProgress(String taskId, long bytesWritten, long contentLength); 6 | 7 | void OnError(String taskId, String code, String message); 8 | } 9 | -------------------------------------------------------------------------------- /android/src/main/java/com/bluechilli/flutteruploader/CountingRequestBody.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploader; 2 | 3 | import androidx.annotation.NonNull; 4 | import java.io.IOException; 5 | import okhttp3.MediaType; 6 | import okhttp3.RequestBody; 7 | import okio.Buffer; 8 | import okio.BufferedSink; 9 | import okio.ForwardingSink; 10 | import okio.Okio; 11 | import okio.Sink; 12 | 13 | public class CountingRequestBody extends RequestBody { 14 | 15 | protected final RequestBody _body; 16 | protected final CountProgressListener _listener; 17 | protected final String _taskId; 18 | protected CountingSink _countingSink; 19 | 20 | public CountingRequestBody(RequestBody body, String taskId, CountProgressListener listener) { 21 | _body = body; 22 | _taskId = taskId; 23 | _listener = listener; 24 | } 25 | 26 | @Override 27 | public MediaType contentType() { 28 | return _body.contentType(); 29 | } 30 | 31 | @Override 32 | public long contentLength() throws IOException { 33 | return _body.contentLength(); 34 | } 35 | 36 | @Override 37 | public void writeTo(@NonNull BufferedSink sink) throws IOException { 38 | try { 39 | _countingSink = new CountingSink(this, sink); 40 | BufferedSink bufferedSink = Okio.buffer(_countingSink); 41 | _body.writeTo(bufferedSink); 42 | 43 | bufferedSink.flush(); 44 | } catch (IOException ex) { 45 | sendError(ex); 46 | } 47 | } 48 | 49 | public void sendProgress(long bytesWritten, long totalContentLength) { 50 | if (_listener != null) { 51 | _listener.OnProgress(_taskId, bytesWritten, totalContentLength); 52 | } 53 | } 54 | 55 | public void sendError(Exception ex) { 56 | if (_listener != null) { 57 | _listener.OnError(_taskId, "upload_task_error", ex.toString()); 58 | } 59 | } 60 | 61 | protected static class CountingSink extends ForwardingSink { 62 | private long _bytesWritten; 63 | private final CountingRequestBody _parent; 64 | 65 | public CountingSink(CountingRequestBody parent, Sink sink) { 66 | super(sink); 67 | _parent = parent; 68 | } 69 | 70 | @Override 71 | public void write(@NonNull Buffer source, long byteCount) throws IOException { 72 | try { 73 | super.write(source, byteCount); 74 | 75 | _bytesWritten += byteCount; 76 | 77 | if (_parent != null) { 78 | _parent.sendProgress(_bytesWritten, _parent.contentLength()); 79 | } 80 | } catch (IOException ex) { 81 | if (_parent != null) { 82 | _parent.sendError(ex); 83 | } 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /android/src/main/java/com/bluechilli/flutteruploader/FileItem.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploader; 2 | 3 | import java.util.Map; 4 | 5 | public class FileItem { 6 | 7 | private String fieldname; 8 | private String path; 9 | 10 | public FileItem(String path) { 11 | this.path = path; 12 | } 13 | 14 | public FileItem(String path, String fieldname) { 15 | this.fieldname = fieldname; 16 | this.path = path; 17 | } 18 | 19 | public static FileItem fromJson(Map map) { 20 | return new FileItem(map.get("path"), map.get("fieldname")); 21 | } 22 | 23 | public String getFieldname() { 24 | return fieldname; 25 | } 26 | 27 | public String getPath() { 28 | return path; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /android/src/main/java/com/bluechilli/flutteruploader/FlutterUploaderInitializer.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploader; 2 | 3 | import android.content.ComponentName; 4 | import android.content.ContentProvider; 5 | import android.content.ContentValues; 6 | import android.content.Context; 7 | import android.content.pm.PackageManager; 8 | import android.content.pm.ProviderInfo; 9 | import android.database.Cursor; 10 | import android.net.Uri; 11 | import android.os.Bundle; 12 | import android.util.Log; 13 | import androidx.annotation.NonNull; 14 | import androidx.annotation.Nullable; 15 | import androidx.work.Configuration; 16 | import androidx.work.WorkManager; 17 | import java.util.concurrent.Executors; 18 | 19 | public class FlutterUploaderInitializer extends ContentProvider { 20 | 21 | private static final String TAG = "UploaderInitializer"; 22 | private static final int DEFAULT_MAX_CONCURRENT_TASKS = 3; 23 | private static final int DEFAULT_UPLOAD_CONNECTION_TIMEOUT = 3600; 24 | 25 | @Override 26 | public boolean onCreate() { 27 | int maximumConcurrentTask = getMaxConcurrentTaskMetadata(getContext()); 28 | WorkManager.initialize( 29 | getContext(), 30 | new Configuration.Builder() 31 | .setExecutor(Executors.newFixedThreadPool(maximumConcurrentTask)) 32 | .build()); 33 | return true; 34 | } 35 | 36 | @Nullable 37 | @Override 38 | public Cursor query( 39 | @NonNull Uri uri, 40 | @Nullable String[] strings, 41 | @Nullable String s, 42 | @Nullable String[] strings1, 43 | @Nullable String s1) { 44 | return null; 45 | } 46 | 47 | @Nullable 48 | @Override 49 | public String getType(@NonNull Uri uri) { 50 | return null; 51 | } 52 | 53 | @Nullable 54 | @Override 55 | public Uri insert(@NonNull Uri uri, @Nullable ContentValues contentValues) { 56 | return null; 57 | } 58 | 59 | @Override 60 | public int delete(@NonNull Uri uri, @Nullable String s, @Nullable String[] strings) { 61 | return 0; 62 | } 63 | 64 | @Override 65 | public int update( 66 | @NonNull Uri uri, 67 | @Nullable ContentValues contentValues, 68 | @Nullable String s, 69 | @Nullable String[] strings) { 70 | return 0; 71 | } 72 | 73 | public static int getMaxConcurrentTaskMetadata(Context context) { 74 | try { 75 | ProviderInfo pi = 76 | context 77 | .getPackageManager() 78 | .getProviderInfo( 79 | new ComponentName( 80 | context, "com.bluechilli.flutteruploader.FlutterUploaderInitializer"), 81 | PackageManager.GET_META_DATA); 82 | Bundle bundle = pi.metaData; 83 | int max = 84 | bundle.getInt( 85 | "com.bluechilli.flutteruploader.MAX_CONCURRENT_TASKS", DEFAULT_MAX_CONCURRENT_TASKS); 86 | Log.d(TAG, "MAX_CONCURRENT_TASKS = " + max); 87 | return max; 88 | } catch (PackageManager.NameNotFoundException e) { 89 | Log.e(TAG, "Failed to load meta-data, NameNotFound: " + e.getMessage()); 90 | } catch (NullPointerException e) { 91 | Log.e(TAG, "Failed to load meta-data, NullPointer: " + e.getMessage()); 92 | } 93 | return DEFAULT_MAX_CONCURRENT_TASKS; 94 | } 95 | 96 | public static int getConnectionTimeout(Context context) { 97 | try { 98 | ProviderInfo pi = 99 | context 100 | .getPackageManager() 101 | .getProviderInfo( 102 | new ComponentName( 103 | context, "com.bluechilli.flutteruploader.FlutterUploaderInitializer"), 104 | PackageManager.GET_META_DATA); 105 | Bundle bundle = pi.metaData; 106 | int max = 107 | bundle.getInt( 108 | "com.bluechilli.flutteruploader.UPLOAD_CONNECTION_TIMEOUT_IN_SECONDS", 109 | DEFAULT_UPLOAD_CONNECTION_TIMEOUT); 110 | Log.d(TAG, "UPLOAD_CONNECTION_TIMEOUT_IN_SECONDS = " + max); 111 | return max; 112 | } catch (PackageManager.NameNotFoundException e) { 113 | Log.e(TAG, "Failed to load meta-data, NameNotFound: " + e.getMessage()); 114 | } catch (NullPointerException e) { 115 | Log.e(TAG, "Failed to load meta-data, NullPointer: " + e.getMessage()); 116 | } 117 | 118 | return DEFAULT_UPLOAD_CONNECTION_TIMEOUT; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /android/src/main/java/com/bluechilli/flutteruploader/FlutterUploaderPlugin.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploader; 2 | 3 | import static com.bluechilli.flutteruploader.MethodCallHandlerImpl.FLUTTER_UPLOAD_WORK_TAG; 4 | 5 | import android.content.Context; 6 | import androidx.annotation.NonNull; 7 | import androidx.annotation.Nullable; 8 | import androidx.lifecycle.LiveData; 9 | import androidx.work.WorkInfo; 10 | import androidx.work.WorkManager; 11 | import com.bluechilli.flutteruploader.plugin.CachingStreamHandler; 12 | import com.bluechilli.flutteruploader.plugin.StatusListener; 13 | import com.bluechilli.flutteruploader.plugin.UploadObserver; 14 | import io.flutter.embedding.engine.plugins.FlutterPlugin; 15 | import io.flutter.plugin.common.BinaryMessenger; 16 | import io.flutter.plugin.common.EventChannel; 17 | import io.flutter.plugin.common.MethodChannel; 18 | import io.flutter.plugin.common.PluginRegistry.Registrar; 19 | import java.util.ArrayList; 20 | import java.util.Arrays; 21 | import java.util.Collections; 22 | import java.util.HashMap; 23 | import java.util.List; 24 | import java.util.Map; 25 | 26 | /** FlutterUploaderPlugin */ 27 | public class FlutterUploaderPlugin implements FlutterPlugin, StatusListener { 28 | 29 | private static final String CHANNEL_NAME = "flutter_uploader"; 30 | private static final String PROGRESS_EVENT_CHANNEL_NAME = "flutter_uploader/events/progress"; 31 | private static final String RESULT_EVENT_CHANNEL_NAME = "flutter_uploader/events/result"; 32 | 33 | private MethodChannel channel; 34 | private MethodCallHandlerImpl methodCallHandler; 35 | private UploadObserver uploadObserver; 36 | 37 | private EventChannel progressEventChannel; 38 | private final CachingStreamHandler> progressStreamHandler = 39 | new CachingStreamHandler<>(); 40 | 41 | private EventChannel resultEventChannel; 42 | private final CachingStreamHandler> resultStreamHandler = 43 | new CachingStreamHandler<>(); 44 | private LiveData> workInfoLiveData; 45 | 46 | public static void registerWith(Registrar registrar) { 47 | final FlutterUploaderPlugin plugin = new FlutterUploaderPlugin(); 48 | plugin.startListening(registrar.context(), registrar.messenger()); 49 | registrar.addViewDestroyListener( 50 | view -> { 51 | plugin.stopListening(); 52 | return false; 53 | }); 54 | } 55 | 56 | @Override 57 | public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { 58 | startListening(binding.getApplicationContext(), binding.getBinaryMessenger()); 59 | } 60 | 61 | @Override 62 | public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { 63 | stopListening(); 64 | } 65 | 66 | private void startListening(Context context, BinaryMessenger messenger) { 67 | final int timeout = FlutterUploaderInitializer.getConnectionTimeout(context); 68 | 69 | channel = new MethodChannel(messenger, CHANNEL_NAME); 70 | methodCallHandler = new MethodCallHandlerImpl(context, timeout, this); 71 | 72 | uploadObserver = new UploadObserver(this); 73 | workInfoLiveData = 74 | WorkManager.getInstance(context).getWorkInfosByTagLiveData(FLUTTER_UPLOAD_WORK_TAG); 75 | workInfoLiveData.observeForever(uploadObserver); 76 | 77 | channel.setMethodCallHandler(methodCallHandler); 78 | 79 | progressEventChannel = new EventChannel(messenger, PROGRESS_EVENT_CHANNEL_NAME); 80 | progressEventChannel.setStreamHandler(progressStreamHandler); 81 | 82 | resultEventChannel = new EventChannel(messenger, RESULT_EVENT_CHANNEL_NAME); 83 | resultEventChannel.setStreamHandler(resultStreamHandler); 84 | } 85 | 86 | private void stopListening() { 87 | channel.setMethodCallHandler(null); 88 | channel = null; 89 | 90 | if (uploadObserver != null) { 91 | workInfoLiveData.removeObserver(uploadObserver); 92 | workInfoLiveData = null; 93 | uploadObserver = null; 94 | } 95 | 96 | methodCallHandler = null; 97 | 98 | progressEventChannel.setStreamHandler(null); 99 | progressEventChannel = null; 100 | 101 | resultEventChannel.setStreamHandler(null); 102 | resultEventChannel = null; 103 | 104 | progressStreamHandler.clear(); 105 | resultStreamHandler.clear(); 106 | } 107 | 108 | @Override 109 | public void onEnqueued(String id) { 110 | Map args = new HashMap<>(); 111 | args.put("taskId", id); 112 | args.put("status", UploadStatus.ENQUEUED); 113 | 114 | resultStreamHandler.add(id, args); 115 | } 116 | 117 | @Override 118 | public void onUpdateProgress(String id, int status, int progress) { 119 | final Map args = new HashMap<>(); 120 | args.put("taskId", id); 121 | args.put("status", status); 122 | args.put("progress", progress); 123 | 124 | progressStreamHandler.add(id, args); 125 | } 126 | 127 | @Override 128 | public void onFailed( 129 | String id, 130 | int status, 131 | int statusCode, 132 | String code, 133 | String message, 134 | @Nullable String[] details) { 135 | Map args = new HashMap<>(); 136 | args.put("taskId", id); 137 | args.put("status", status); 138 | args.put("statusCode", statusCode); 139 | args.put("code", code); 140 | args.put("message", message); 141 | args.put( 142 | "details", 143 | details != null 144 | ? new ArrayList<>(Arrays.asList(details)) 145 | : Collections.emptyList()); 146 | 147 | resultStreamHandler.add(id, args); 148 | } 149 | 150 | @Override 151 | public void onCompleted( 152 | String id, 153 | int status, 154 | int statusCode, 155 | String response, 156 | @Nullable Map headers) { 157 | Map args = new HashMap<>(); 158 | args.put("taskId", id); 159 | args.put("status", status); 160 | args.put("statusCode", statusCode); 161 | args.put("message", response); 162 | args.put("headers", headers != null ? headers : Collections.emptyMap()); 163 | 164 | resultStreamHandler.add(id, args); 165 | } 166 | 167 | @Override 168 | public void onWorkPruned() { 169 | progressStreamHandler.clear(); 170 | resultStreamHandler.clear(); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /android/src/main/java/com/bluechilli/flutteruploader/MethodCallHandlerImpl.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploader; 2 | 3 | import android.content.Context; 4 | import androidx.annotation.NonNull; 5 | import androidx.core.content.ContextCompat; 6 | import androidx.work.BackoffPolicy; 7 | import androidx.work.Constraints; 8 | import androidx.work.Data; 9 | import androidx.work.NetworkType; 10 | import androidx.work.OneTimeWorkRequest; 11 | import androidx.work.WorkManager; 12 | import androidx.work.WorkRequest; 13 | import com.bluechilli.flutteruploader.plugin.StatusListener; 14 | import com.google.gson.Gson; 15 | import io.flutter.plugin.common.MethodCall; 16 | import io.flutter.plugin.common.MethodChannel; 17 | import io.flutter.plugin.common.MethodChannel.MethodCallHandler; 18 | import io.flutter.plugin.common.MethodChannel.Result; 19 | import java.util.ArrayList; 20 | import java.util.Arrays; 21 | import java.util.Collections; 22 | import java.util.List; 23 | import java.util.Map; 24 | import java.util.UUID; 25 | import java.util.concurrent.Executor; 26 | import java.util.concurrent.Executors; 27 | import java.util.concurrent.TimeUnit; 28 | 29 | public class MethodCallHandlerImpl implements MethodCallHandler { 30 | 31 | /** The generic {@link WorkManager} tag which matches any upload. */ 32 | public static final String FLUTTER_UPLOAD_WORK_TAG = "flutter_upload_task"; 33 | 34 | private final Context context; 35 | 36 | private final int connectionTimeout; 37 | 38 | @NonNull private final StatusListener statusListener; 39 | 40 | private final Executor workManagerExecutor = Executors.newSingleThreadExecutor(); 41 | private final Executor mainExecutor; 42 | 43 | private static final List VALID_HTTP_METHODS = Arrays.asList("POST", "PUT", "PATCH"); 44 | 45 | MethodCallHandlerImpl(Context context, int timeout, @NonNull StatusListener listener) { 46 | mainExecutor = ContextCompat.getMainExecutor(context); 47 | this.context = context; 48 | this.connectionTimeout = timeout; 49 | this.statusListener = listener; 50 | } 51 | 52 | @Override 53 | public void onMethodCall(MethodCall call, @NonNull Result result) { 54 | switch (call.method) { 55 | case "setBackgroundHandler": 56 | setBackgroundHandler(call, result); 57 | break; 58 | case "enqueue": 59 | enqueue(call, result); 60 | break; 61 | case "enqueueBinary": 62 | enqueueBinary(call, result); 63 | break; 64 | case "cancel": 65 | cancel(call, result); 66 | break; 67 | case "cancelAll": 68 | cancelAll(call, result); 69 | break; 70 | case "clearUploads": 71 | clearUploads(call, result); 72 | break; 73 | default: 74 | result.notImplemented(); 75 | break; 76 | } 77 | } 78 | 79 | void setBackgroundHandler(MethodCall call, MethodChannel.Result result) { 80 | Number callbackHandle = call.argument("callbackHandle"); 81 | if (callbackHandle != null) { 82 | SharedPreferenceHelper.saveCallbackDispatcherHandleKey(context, callbackHandle.longValue()); 83 | } 84 | 85 | result.success(null); 86 | } 87 | 88 | private void enqueue(MethodCall call, MethodChannel.Result result) { 89 | String url = call.argument("url"); 90 | String method = call.argument("method"); 91 | List> files = call.argument("files"); 92 | Map parameters = call.argument("data"); 93 | Map headers = call.argument("headers"); 94 | String tag = call.argument("tag"); 95 | Boolean allowCellular = call.argument("allowCellular"); 96 | if (allowCellular == null) { 97 | result.error("invalid_flag", "allowCellular must be set", null); 98 | return; 99 | } 100 | 101 | if (method == null) { 102 | method = "POST"; 103 | } 104 | 105 | if (files == null || files.isEmpty()) { 106 | result.error("invalid_call", "Invalid call parameters passed", null); 107 | return; 108 | } 109 | 110 | if (!VALID_HTTP_METHODS.contains(method.toUpperCase())) { 111 | result.error("invalid_method", "Method must be either POST | PUT | PATCH", null); 112 | return; 113 | } 114 | 115 | List items = new ArrayList<>(); 116 | 117 | for (Map file : files) { 118 | items.add(FileItem.fromJson(file)); 119 | } 120 | 121 | WorkRequest request = 122 | buildRequest( 123 | new UploadTask( 124 | url, 125 | method, 126 | items, 127 | headers, 128 | parameters, 129 | connectionTimeout, 130 | false, 131 | tag, 132 | allowCellular)); 133 | 134 | WorkManager.getInstance(context) 135 | .enqueue(request) 136 | .getResult() 137 | .addListener( 138 | () -> { 139 | String taskId = request.getId().toString(); 140 | mainExecutor.execute( 141 | () -> { 142 | result.success(taskId); 143 | statusListener.onUpdateProgress(taskId, UploadStatus.ENQUEUED, 0); 144 | }); 145 | }, 146 | workManagerExecutor); 147 | } 148 | 149 | private void enqueueBinary(MethodCall call, MethodChannel.Result result) { 150 | String url = call.argument("url"); 151 | String method = call.argument("method"); 152 | String path = call.argument("path"); 153 | Map headers = call.argument("headers"); 154 | String tag = call.argument("tag"); 155 | Boolean allowCellular = call.argument("allowCellular"); 156 | 157 | if (allowCellular == null) { 158 | result.error("invalid_flag", "allowCellular must be set", null); 159 | return; 160 | } 161 | 162 | if (method == null) { 163 | method = "POST"; 164 | } 165 | 166 | if (path == null) { 167 | result.error("invalid_call", "Invalid call parameters passed", null); 168 | return; 169 | } 170 | 171 | if (!VALID_HTTP_METHODS.contains(method.toUpperCase())) { 172 | result.error("invalid_method", "Method must be either POST | PUT | PATCH", null); 173 | return; 174 | } 175 | 176 | WorkRequest request = 177 | buildRequest( 178 | new UploadTask( 179 | url, 180 | method, 181 | Collections.singletonList(new FileItem(path)), 182 | headers, 183 | Collections.emptyMap(), 184 | connectionTimeout, 185 | true, 186 | tag, 187 | allowCellular)); 188 | 189 | WorkManager.getInstance(context) 190 | .enqueue(request) 191 | .getResult() 192 | .addListener( 193 | () -> { 194 | String taskId = request.getId().toString(); 195 | mainExecutor.execute( 196 | () -> { 197 | result.success(taskId); 198 | statusListener.onUpdateProgress(taskId, UploadStatus.ENQUEUED, 0); 199 | }); 200 | }, 201 | workManagerExecutor); 202 | } 203 | 204 | private void cancel(MethodCall call, MethodChannel.Result result) { 205 | String taskId = call.argument("taskId"); 206 | WorkManager.getInstance(context) 207 | .cancelWorkById(UUID.fromString(taskId)) 208 | .getResult() 209 | .addListener(() -> mainExecutor.execute(() -> result.success(null)), workManagerExecutor); 210 | } 211 | 212 | private void cancelAll(MethodCall call, MethodChannel.Result result) { 213 | WorkManager.getInstance(context) 214 | .cancelAllWorkByTag(FLUTTER_UPLOAD_WORK_TAG) 215 | .getResult() 216 | .addListener(() -> mainExecutor.execute(() -> result.success(null)), workManagerExecutor); 217 | } 218 | 219 | private void clearUploads(MethodCall call, MethodChannel.Result result) { 220 | WorkManager.getInstance(context) 221 | .pruneWork() 222 | .getResult() 223 | .addListener( 224 | () -> { 225 | statusListener.onWorkPruned(); 226 | mainExecutor.execute(() -> result.success(null)); 227 | }, 228 | workManagerExecutor); 229 | } 230 | 231 | private WorkRequest buildRequest(UploadTask task) { 232 | Gson gson = new Gson(); 233 | 234 | Data.Builder dataBuilder = 235 | new Data.Builder() 236 | .putString(UploadWorker.ARG_URL, task.getURL()) 237 | .putString(UploadWorker.ARG_METHOD, task.getMethod()) 238 | .putInt(UploadWorker.ARG_REQUEST_TIMEOUT, task.getTimeout()) 239 | .putBoolean(UploadWorker.ARG_BINARY_UPLOAD, task.isBinaryUpload()) 240 | .putString(UploadWorker.ARG_UPLOAD_REQUEST_TAG, task.getTag()); 241 | 242 | List files = task.getFiles(); 243 | 244 | String fileItemsJson = gson.toJson(files); 245 | dataBuilder.putString(UploadWorker.ARG_FILES, fileItemsJson); 246 | 247 | if (task.getHeaders() != null) { 248 | String headersJson = gson.toJson(task.getHeaders()); 249 | dataBuilder.putString(UploadWorker.ARG_HEADERS, headersJson); 250 | } 251 | 252 | if (task.getParameters() != null) { 253 | String parametersJson = gson.toJson(task.getParameters()); 254 | dataBuilder.putString(UploadWorker.ARG_DATA, parametersJson); 255 | } 256 | 257 | Constraints constraints = 258 | new Constraints.Builder() 259 | .setRequiredNetworkType( 260 | task.isAllowCellular() ? NetworkType.CONNECTED : NetworkType.UNMETERED) 261 | .build(); 262 | return new OneTimeWorkRequest.Builder(UploadWorker.class) 263 | .setConstraints(constraints) 264 | .addTag(FLUTTER_UPLOAD_WORK_TAG) 265 | .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.SECONDS) 266 | .setInputData(dataBuilder.build()) 267 | .build(); 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /android/src/main/java/com/bluechilli/flutteruploader/SharedPreferenceHelper.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploader; 2 | 3 | import android.content.Context; 4 | import android.content.SharedPreferences; 5 | 6 | public class SharedPreferenceHelper { 7 | private static final String SHARED_PREFS_FILE_NAME = "flutter_uploader_plugin"; 8 | private static final String CALLBACK_DISPATCHER_HANDLE_KEY = 9 | "com.bluechilli.flutteruploader.CALLBACK_DISPATCHER_HANDLE_KEY"; 10 | 11 | public static SharedPreferences get(Context context) { 12 | return context.getSharedPreferences(SHARED_PREFS_FILE_NAME, Context.MODE_PRIVATE); 13 | } 14 | 15 | public static void saveCallbackDispatcherHandleKey(Context context, Long callbackHandle) { 16 | get(context).edit().putLong(CALLBACK_DISPATCHER_HANDLE_KEY, callbackHandle).apply(); 17 | } 18 | 19 | public static Long getCallbackHandle(Context context) { 20 | return get(context).getLong(CALLBACK_DISPATCHER_HANDLE_KEY, -1L); 21 | } 22 | 23 | public static boolean hasCallbackHandle(Context context) { 24 | return get(context).contains(CALLBACK_DISPATCHER_HANDLE_KEY); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /android/src/main/java/com/bluechilli/flutteruploader/UploadExecutorService.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploader; 2 | 3 | import android.content.Context; 4 | import java.util.concurrent.ExecutorService; 5 | import java.util.concurrent.Executors; 6 | 7 | public class UploadExecutorService { 8 | private static ExecutorService executorService = null; 9 | 10 | public static ExecutorService getExecutorService(Context context) { 11 | if (executorService == null) { 12 | final int max = FlutterUploaderInitializer.getMaxConcurrentTaskMetadata(context); 13 | executorService = Executors.newFixedThreadPool(max); 14 | } 15 | return executorService; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /android/src/main/java/com/bluechilli/flutteruploader/UploadStatus.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploader; 2 | 3 | public class UploadStatus { 4 | public static int UNDEFINED = 0; 5 | public static int ENQUEUED = 1; 6 | public static int RUNNING = 2; 7 | public static int COMPLETE = 3; 8 | public static int FAILED = 4; 9 | public static int CANCELED = 5; 10 | public static int PAUSED = 6; 11 | } 12 | -------------------------------------------------------------------------------- /android/src/main/java/com/bluechilli/flutteruploader/UploadTask.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploader; 2 | 3 | import android.net.Uri; 4 | import java.util.List; 5 | import java.util.Map; 6 | 7 | public class UploadTask { 8 | 9 | private String url; 10 | private String method; 11 | private Map headers; 12 | private Map data; 13 | private List files; 14 | private int requestTimeoutInSeconds; 15 | private boolean binaryUpload; 16 | private String tag; 17 | private boolean allowCellular; 18 | 19 | public UploadTask( 20 | String url, 21 | String method, 22 | List files, 23 | Map headers, 24 | Map data, 25 | int requestTimeoutInSeconds, 26 | boolean binaryUpload, 27 | String tag, 28 | boolean allowCellular) { 29 | this.url = url; 30 | this.method = method; 31 | this.files = files; 32 | this.headers = headers; 33 | this.data = data; 34 | this.requestTimeoutInSeconds = requestTimeoutInSeconds; 35 | this.binaryUpload = binaryUpload; 36 | this.tag = tag; 37 | this.allowCellular = allowCellular; 38 | } 39 | 40 | public String getURL() { 41 | return url; 42 | } 43 | 44 | public Uri getUri() { 45 | return Uri.parse(url); 46 | } 47 | 48 | public String getMethod() { 49 | return method; 50 | } 51 | 52 | public List getFiles() { 53 | return files; 54 | } 55 | 56 | public Map getHeaders() { 57 | return headers; 58 | } 59 | 60 | public Map getParameters() { 61 | return data; 62 | } 63 | 64 | public int getTimeout() { 65 | return requestTimeoutInSeconds; 66 | } 67 | 68 | public boolean isBinaryUpload() { 69 | return binaryUpload; 70 | } 71 | 72 | public String getTag() { 73 | return tag; 74 | } 75 | 76 | public boolean isAllowCellular() { 77 | return allowCellular; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /android/src/main/java/com/bluechilli/flutteruploader/plugin/CachingStreamHandler.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploader.plugin; 2 | 3 | import androidx.annotation.Nullable; 4 | import io.flutter.plugin.common.EventChannel.EventSink; 5 | import io.flutter.plugin.common.EventChannel.StreamHandler; 6 | import java.util.HashMap; 7 | import java.util.Map; 8 | 9 | /** 10 | * A StreamHandler which manages a map of unique items and caches their last status. 11 | * 12 | * @param 13 | */ 14 | public class CachingStreamHandler implements StreamHandler { 15 | @Nullable private EventSink eventSink; 16 | 17 | Map cache = new HashMap<>(); 18 | 19 | @Override 20 | public void onListen(Object arguments, EventSink events) { 21 | eventSink = events; 22 | 23 | if (!cache.isEmpty()) { 24 | for (T item : cache.values()) { 25 | events.success(item); 26 | } 27 | } 28 | } 29 | 30 | @Override 31 | public void onCancel(Object arguments) { 32 | eventSink = null; 33 | } 34 | 35 | public void add(String id, T args) { 36 | if (eventSink != null) { 37 | eventSink.success(args); 38 | } 39 | 40 | cache.put(id, args); 41 | } 42 | 43 | public void clear() { 44 | cache.clear(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /android/src/main/java/com/bluechilli/flutteruploader/plugin/StatusListener.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploader.plugin; 2 | 3 | import androidx.annotation.Nullable; 4 | import java.util.Map; 5 | 6 | public interface StatusListener { 7 | void onEnqueued(String id); 8 | 9 | void onUpdateProgress(String id, int status, int progress); 10 | 11 | void onFailed( 12 | String id, 13 | int status, 14 | int statusCode, 15 | String code, 16 | String message, 17 | @Nullable String[] details); 18 | 19 | void onCompleted( 20 | String id, 21 | int status, 22 | int statusCode, 23 | String response, 24 | @Nullable Map headers); 25 | 26 | void onWorkPruned(); 27 | } 28 | -------------------------------------------------------------------------------- /android/src/main/java/com/bluechilli/flutteruploader/plugin/UploadObserver.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploader.plugin; 2 | 3 | import android.text.TextUtils; 4 | import androidx.lifecycle.Observer; 5 | import androidx.work.Data; 6 | import androidx.work.WorkInfo; 7 | import com.bluechilli.flutteruploader.UploadStatus; 8 | import com.bluechilli.flutteruploader.UploadWorker; 9 | import com.google.gson.Gson; 10 | import com.google.gson.reflect.TypeToken; 11 | import java.io.BufferedReader; 12 | import java.io.FileReader; 13 | import java.lang.ref.WeakReference; 14 | import java.lang.reflect.Type; 15 | import java.util.List; 16 | import java.util.Map; 17 | 18 | public class UploadObserver implements Observer> { 19 | private final WeakReference listener; 20 | private final Gson gson = new Gson(); 21 | 22 | public UploadObserver(StatusListener listener) { 23 | this.listener = new WeakReference<>(listener); 24 | } 25 | 26 | @Override 27 | public void onChanged(List workInfoList) { 28 | StatusListener listener = this.listener.get(); 29 | 30 | if (listener == null) { 31 | return; 32 | } 33 | 34 | for (WorkInfo info : workInfoList) { 35 | String id = info.getId().toString(); 36 | 37 | switch (info.getState()) { 38 | case ENQUEUED: 39 | { 40 | listener.onEnqueued(info.getId().toString()); 41 | } 42 | case RUNNING: 43 | { 44 | Data progress = info.getProgress(); 45 | 46 | listener.onUpdateProgress( 47 | info.getId().toString(), 48 | progress.getInt("status", -1), 49 | progress.getInt("progress", -1)); 50 | } 51 | break; 52 | case FAILED: 53 | { 54 | final Data outputData = info.getOutputData(); 55 | int failedStatus = outputData.getInt(UploadWorker.EXTRA_STATUS, UploadStatus.FAILED); 56 | int statusCode = outputData.getInt(UploadWorker.EXTRA_STATUS_CODE, 500); 57 | String code = outputData.getString(UploadWorker.EXTRA_ERROR_CODE); 58 | String errorMessage = outputData.getString(UploadWorker.EXTRA_ERROR_MESSAGE); 59 | String[] details = outputData.getStringArray(UploadWorker.EXTRA_ERROR_DETAILS); 60 | 61 | listener.onFailed(id, failedStatus, statusCode, code, errorMessage, details); 62 | } 63 | break; 64 | case CANCELLED: 65 | listener.onFailed(id, UploadStatus.CANCELED, 500, "flutter_upload_cancelled", null, null); 66 | break; 67 | case SUCCEEDED: 68 | { 69 | final Data outputData = info.getOutputData(); 70 | int status = outputData.getInt(UploadWorker.EXTRA_STATUS, UploadStatus.COMPLETE); 71 | int statusCode = outputData.getInt(UploadWorker.EXTRA_STATUS_CODE, 500); 72 | Map headers = null; 73 | Type type = new TypeToken>() {}.getType(); 74 | String headerJson = outputData.getString(UploadWorker.EXTRA_HEADERS); 75 | if (headerJson != null) { 76 | headers = gson.fromJson(headerJson, type); 77 | } 78 | String response = extractResponse(outputData); 79 | listener.onCompleted(id, status, statusCode, response, headers); 80 | } 81 | break; 82 | } 83 | } 84 | } 85 | 86 | String extractResponse(Data outputData) { 87 | String response = outputData.getString(UploadWorker.EXTRA_RESPONSE); 88 | if (TextUtils.isEmpty(response)) { 89 | String responseFile = outputData.getString(UploadWorker.EXTRA_RESPONSE_FILE); 90 | if (!TextUtils.isEmpty(responseFile)) { 91 | StringBuilder buffer = new StringBuilder(); 92 | 93 | try (BufferedReader br = new BufferedReader(new FileReader(responseFile))) { 94 | String st; 95 | while ((st = br.readLine()) != null) { 96 | buffer.append(st); 97 | } 98 | response = buffer.toString(); 99 | 100 | } catch (Throwable ignored) { 101 | response = ""; 102 | } 103 | } 104 | } 105 | 106 | return response; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /android/src/main/res/drawable-hdpi/ic_upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/android/src/main/res/drawable-hdpi/ic_upload.png -------------------------------------------------------------------------------- /android/src/main/res/drawable-mdpi/ic_upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/android/src/main/res/drawable-mdpi/ic_upload.png -------------------------------------------------------------------------------- /android/src/main/res/drawable-xhdpi/ic_upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/android/src/main/res/drawable-xhdpi/ic_upload.png -------------------------------------------------------------------------------- /android/src/main/res/drawable-xxhdpi/ic_upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/android/src/main/res/drawable-xxhdpi/ic_upload.png -------------------------------------------------------------------------------- /android/src/main/res/drawable-xxxhdpi/ic_upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/android/src/main/res/drawable-xxxhdpi/ic_upload.png -------------------------------------------------------------------------------- /android/src/main/res/xml/provider_path.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # Visual Studio Code related 19 | .vscode/ 20 | 21 | # Flutter/Dart/Pub related 22 | **/doc/api/ 23 | .dart_tool/ 24 | .flutter-plugins 25 | .packages 26 | .pub-cache/ 27 | .pub/ 28 | /build/ 29 | 30 | # Android related 31 | **/android/**/gradle-wrapper.jar 32 | **/android/.gradle 33 | **/android/captures/ 34 | **/android/gradlew 35 | **/android/gradlew.bat 36 | **/android/local.properties 37 | **/android/**/GeneratedPluginRegistrant.java 38 | 39 | # iOS/XCode related 40 | **/ios/**/*.mode1v3 41 | **/ios/**/*.mode2v3 42 | **/ios/**/*.moved-aside 43 | **/ios/**/*.pbxuser 44 | **/ios/**/*.perspectivev3 45 | **/ios/**/*sync/ 46 | **/ios/**/.sconsign.dblite 47 | **/ios/**/.tags* 48 | **/ios/**/.vagrant/ 49 | **/ios/**/DerivedData/ 50 | **/ios/**/Icon? 51 | **/ios/**/Pods/ 52 | **/ios/**/.symlinks/ 53 | **/ios/**/profile 54 | **/ios/**/xcuserdata 55 | **/ios/.generated/ 56 | **/ios/Flutter/App.framework 57 | **/ios/Flutter/Flutter.framework 58 | **/ios/Flutter/Generated.xcconfig 59 | **/ios/Flutter/app.flx 60 | **/ios/Flutter/app.zip 61 | **/ios/Flutter/flutter_assets/ 62 | **/ios/ServiceDefinitions.json 63 | **/ios/Runner/GeneratedPluginRegistrant.* 64 | **/ios/Flutter/*.sh 65 | # Exceptions to above rules. 66 | !**/ios/**/default.mode1v3 67 | !**/ios/**/default.mode2v3 68 | !**/ios/**/default.pbxuser 69 | !**/ios/**/default.perspectivev3 70 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 71 | 72 | **/ios/*.lock 73 | *.lock 74 | ios/Flutter/flutter_export_environment.sh 75 | ios/Flutter/Flutter.podspec -------------------------------------------------------------------------------- /example/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: fec01201b1a491d1d404825829cfd4e5992dbbaa 8 | channel: master 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # flutter_uploader_example 2 | 3 | Demonstrates how to use the flutter_uploader plugin. 4 | 5 | ## Getting Started 6 | 7 | # Setup upload Api 8 | 9 | 1. install firebase-tools in terminal 10 | 11 | ```console 12 | npm install -g firebase-tools 13 | ``` 14 | 15 | 2. create project in firebase console 16 | 17 | 3. login to firebase in terminal 18 | 19 | ```console 20 | firebase login 21 | ``` 22 | 23 | 4. Go to example/backend/ 24 | 25 | 5. run 26 | 27 | ```console 28 | firebase deploy 29 | ``` 30 | 31 | 6. run example app 32 | 33 | ## Driver tests 34 | 35 | Run the current end to end test suite: 36 | 37 | ``` 38 | flutter drive --driver=test_driver/flutter_uploader_e2e_test.dart test_driver/flutter_uploader_test.dart 39 | ``` -------------------------------------------------------------------------------- /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 from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 26 | 27 | android { 28 | compileSdkVersion 31 29 | 30 | lintOptions { 31 | disable 'InvalidPackage' 32 | } 33 | 34 | compileOptions { 35 | sourceCompatibility JavaVersion.VERSION_1_8 36 | targetCompatibility JavaVersion.VERSION_1_8 37 | } 38 | 39 | defaultConfig { 40 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 41 | applicationId "com.bluechilli.flutteruploaderexample" 42 | minSdkVersion 16 43 | targetSdkVersion 31 44 | versionCode flutterVersionCode.toInteger() 45 | versionName flutterVersionName 46 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 47 | } 48 | 49 | buildTypes { 50 | release { 51 | // TODO: Add your own signing config for the release build. 52 | // Signing with the debug keys for now, so `flutter run --release` works. 53 | signingConfig signingConfigs.debug 54 | } 55 | } 56 | } 57 | 58 | flutter { 59 | source '../..' 60 | } 61 | 62 | dependencies { 63 | def multidex_version = "2.0.1" 64 | implementation "androidx.multidex:multidex:$multidex_version" 65 | 66 | testImplementation 'junit:junit:4.13.1' 67 | androidTestImplementation 'androidx.test:runner:1.2.0' 68 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' 69 | } -------------------------------------------------------------------------------- /example/android/app/src/androidTest/java/com/bluechilli/flutteruploaderexample/FlutterActivityTest.java: -------------------------------------------------------------------------------- 1 | package com.bluechilli.flutteruploaderexample; 2 | 3 | import androidx.test.rule.ActivityTestRule; 4 | import dev.flutter.plugins.e2e.FlutterTestRunner; 5 | import org.junit.Rule; 6 | import org.junit.runner.RunWith; 7 | 8 | @RunWith(FlutterTestRunner.class) 9 | public class FlutterActivityTest { 10 | 11 | @Rule 12 | public ActivityTestRule rule = 13 | new ActivityTestRule<>(io.flutter.embedding.android.FlutterActivity.class); 14 | } 15 | -------------------------------------------------------------------------------- /example/android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /example/android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 20 | 28 | 32 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 46 | 47 | 51 | 54 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /example/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java: -------------------------------------------------------------------------------- 1 | // Generated file. 2 | // If you wish to remove Flutter's multidex support, delete this entire file. 3 | 4 | package io.flutter.app; 5 | 6 | import android.content.Context; 7 | import androidx.annotation.CallSuper; 8 | import androidx.multidex.MultiDex; 9 | 10 | /** Extension of {@link io.flutter.app.FlutterApplication}, adding multidex support. */ 11 | public class FlutterMultiDexApplication extends FlutterApplication { 12 | @Override 13 | @CallSuper 14 | protected void attachBaseContext(Context base) { 15 | super.attachBaseContext(base); 16 | MultiDex.install(this); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /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/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/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/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/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/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/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/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/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/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /example/android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /example/android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | 7 | dependencies { 8 | classpath 'com.android.tools.build:gradle:4.2.1' 9 | } 10 | } 11 | 12 | allprojects { 13 | repositories { 14 | google() 15 | mavenCentral() 16 | } 17 | } 18 | 19 | rootProject.buildDir = '../build' 20 | subprojects { 21 | project.buildDir = "${rootProject.buildDir}/${project.name}" 22 | } 23 | subprojects { 24 | project.evaluationDependsOn(':app') 25 | } 26 | 27 | task clean(type: Delete) { 28 | delete rootProject.buildDir 29 | } 30 | -------------------------------------------------------------------------------- /example/android/gradle.properties: -------------------------------------------------------------------------------- 1 | android.enableJetifier=false 2 | android.useAndroidX=true 3 | org.gradle.jvmargs=-Xmx1536M 4 | -------------------------------------------------------------------------------- /example/android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Jul 16 09:48:55 BST 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip 7 | -------------------------------------------------------------------------------- /example/android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() 4 | 5 | def plugins = new Properties() 6 | def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') 7 | if (pluginsFile.exists()) { 8 | pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } 9 | } 10 | 11 | plugins.each { name, path -> 12 | def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() 13 | include ":$name" 14 | project(":$name").projectDir = pluginDirectory 15 | } 16 | -------------------------------------------------------------------------------- /example/backend/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "demo": "flutteruploader" 4 | } 5 | } -------------------------------------------------------------------------------- /example/backend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /example/backend/README.md: -------------------------------------------------------------------------------- 1 | # Example backend 2 | 3 | The example backend is meant to be deployed to a Firebase instance. 4 | 5 | You can run this backend locally or deploy it to your own firebase instance. 6 | 7 | To get started, run these commands to install firebase locally: 8 | 9 | ``` 10 | npm i -g firebase-tools 11 | firebase login 12 | ``` 13 | 14 | ## Starting the functions locally (recommended) 15 | 16 | Firebase comes with great support for function [emulators](https://firebase.google.com/docs/rules/emulator-setup). 17 | 18 | ``` 19 | npm i -g firebase-tools 20 | firebase emulators:start --only functions 21 | ``` 22 | 23 | If you want the server to be reachable in your local network, adjust `firebase.json` in this folder like so: 24 | 25 | ``` 26 | { 27 | "functions": { 28 | "predeploy": [ 29 | "npm --prefix \"$RESOURCE_DIR\" run lint" 30 | ], 31 | "source": "functions" 32 | }, 33 | "emulators": { 34 | "functions": { 35 | "host": "192.168.1.148", 36 | "port": 5001 37 | } 38 | } 39 | } 40 | ``` 41 | 42 | Replace the IP address your network interface IP. 43 | 44 | ## Installation on your own instance 45 | 46 | You can create your own instance here https://console.firebase.google.com/u/0/. 47 | 48 | Note down the project ID. 49 | 50 | ### Deployments 51 | 52 | `PROJECT_ID` needs to be set to the ID you remembered above. 53 | 54 | ``` 55 | firebase -P $PROJECT_ID deploy --only=functions 56 | ``` 57 | 58 | ## Test 59 | 60 | You can adjust the URLs in `example/lib/main.dart`, for example: 61 | 62 | ``` 63 | ✔ functions[upload]: http function initialized (http://localhost:5001/flutteruploadertest/us-central1/upload). 64 | ``` 65 | 66 | The URL is `http://localhost:5001/flutteruploadertest/us-central1/upload`. -------------------------------------------------------------------------------- /example/backend/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "predeploy": [ 4 | "npm --prefix \"$RESOURCE_DIR\" run lint" 5 | ], 6 | "source": "functions" 7 | } 8 | } -------------------------------------------------------------------------------- /example/backend/functions/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | // Required for certain syntax usages 4 | "ecmaVersion": 2017 5 | }, 6 | "plugins": [ 7 | "promise" 8 | ], 9 | "extends": "eslint:recommended", 10 | "rules": { 11 | // Removed rule "disallow the use of console" from recommended eslint rules 12 | "no-console": "off", 13 | 14 | // Removed rule "disallow multiple spaces in regular expressions" from recommended eslint rules 15 | "no-regex-spaces": "off", 16 | 17 | // Removed rule "disallow the use of debugger" from recommended eslint rules 18 | "no-debugger": "off", 19 | 20 | // Removed rule "disallow unused variables" from recommended eslint rules 21 | "no-unused-vars": "off", 22 | 23 | // Removed rule "disallow mixed spaces and tabs for indentation" from recommended eslint rules 24 | "no-mixed-spaces-and-tabs": "off", 25 | 26 | // Removed rule "disallow the use of undeclared variables unless mentioned in /*global */ comments" from recommended eslint rules 27 | "no-undef": "off", 28 | 29 | // Warn against template literal placeholder syntax in regular strings 30 | "no-template-curly-in-string": 1, 31 | 32 | // Warn if return statements do not either always or never specify values 33 | "consistent-return": 1, 34 | 35 | // Warn if no return statements in callbacks of array methods 36 | "array-callback-return": 1, 37 | 38 | // Require the use of === and !== 39 | "eqeqeq": 2, 40 | 41 | // Disallow the use of alert, confirm, and prompt 42 | "no-alert": 2, 43 | 44 | // Disallow the use of arguments.caller or arguments.callee 45 | "no-caller": 2, 46 | 47 | // Disallow null comparisons without type-checking operators 48 | "no-eq-null": 2, 49 | 50 | // Disallow the use of eval() 51 | "no-eval": 2, 52 | 53 | // Warn against extending native types 54 | "no-extend-native": 1, 55 | 56 | // Warn against unnecessary calls to .bind() 57 | "no-extra-bind": 1, 58 | 59 | // Warn against unnecessary labels 60 | "no-extra-label": 1, 61 | 62 | // Disallow leading or trailing decimal points in numeric literals 63 | "no-floating-decimal": 2, 64 | 65 | // Warn against shorthand type conversions 66 | "no-implicit-coercion": 1, 67 | 68 | // Warn against function declarations and expressions inside loop statements 69 | "no-loop-func": 1, 70 | 71 | // Disallow new operators with the Function object 72 | "no-new-func": 2, 73 | 74 | // Warn against new operators with the String, Number, and Boolean objects 75 | "no-new-wrappers": 1, 76 | 77 | // Disallow throwing literals as exceptions 78 | "no-throw-literal": 2, 79 | 80 | // Require using Error objects as Promise rejection reasons 81 | "prefer-promise-reject-errors": 2, 82 | 83 | // Enforce “for” loop update clause moving the counter in the right direction 84 | "for-direction": 2, 85 | 86 | // Enforce return statements in getters 87 | "getter-return": 2, 88 | 89 | // Disallow await inside of loops 90 | "no-await-in-loop": 2, 91 | 92 | // Disallow comparing against -0 93 | "no-compare-neg-zero": 2, 94 | 95 | // Warn against catch clause parameters from shadowing variables in the outer scope 96 | "no-catch-shadow": 1, 97 | 98 | // Disallow identifiers from shadowing restricted names 99 | "no-shadow-restricted-names": 2, 100 | 101 | // Enforce return statements in callbacks of array methods 102 | "callback-return": 2, 103 | 104 | // Require error handling in callbacks 105 | "handle-callback-err": 2, 106 | 107 | // Warn against string concatenation with __dirname and __filename 108 | "no-path-concat": 1, 109 | 110 | // Prefer using arrow functions for callbacks 111 | "prefer-arrow-callback": 1, 112 | 113 | // Return inside each then() to create readable and reusable Promise chains. 114 | // Forces developers to return console logs and http calls in promises. 115 | "promise/always-return": 2, 116 | 117 | //Enforces the use of catch() on un-returned promises 118 | "promise/catch-or-return": 2, 119 | 120 | // Warn against nested then() or catch() statements 121 | "promise/no-nesting": 1 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /example/backend/functions/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /example/backend/functions/index.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const os = require("os"); 3 | const fs = require("fs"); 4 | const functions = require("firebase-functions"); 5 | const Busboy = require("busboy"); 6 | const md5File = require('md5-file'); 7 | const crypto = require("crypto"); 8 | 9 | exports.upload = functions.https.onRequest(async (req, res) => { 10 | const uploads = []; 11 | const methods = ["POST", "PUT", "PATCH"]; 12 | if (!methods.includes(req.method, 0)) { 13 | return res.status(405).json({ 14 | errorMessage: "Method is not allowed" 15 | }); 16 | } 17 | 18 | const simulate = req.query.simulate !== undefined ? req.query.simulate : 'ok200'; 19 | 20 | const busboy = new Busboy({ headers: req.headers, highWaterMark: 2 * 1024 }); 21 | 22 | busboy.on("file", (fieldname, file, filename, encoding, mimetype) => { 23 | console.log("File [" + fieldname + "]: filename: " + filename); 24 | 25 | let length = 0; 26 | 27 | file.on("data", data => { 28 | console.log("File [" + fieldname + "] got " + data.length + " bytes"); 29 | length = data.length; 30 | }); 31 | file.on("end", () => { 32 | console.log("File [" + fieldname + "] Finished"); 33 | }); 34 | 35 | const filepath = path.join(os.tmpdir(), filename); 36 | uploads.push({ 37 | file: filepath, 38 | fieldname: fieldname, 39 | filename: filename, 40 | mimetype: mimetype, 41 | encoding: encoding, 42 | length: length, 43 | }); 44 | console.log(`Saving '${fieldname}' to ${filepath}`); 45 | file.pipe(fs.createWriteStream(filepath)); 46 | }); 47 | 48 | const fields = {}; 49 | 50 | busboy.on("field", (fieldname, val, fieldnameTruncated, valTruncated) => { 51 | console.log("Field [" + fieldname + "]: value: " + val); 52 | fields[fieldname] = val; 53 | }); 54 | busboy.on("finish", () => { 55 | const statusCode = statusCodeForSimulation(simulate); 56 | 57 | if (statusCode === 201) { 58 | return res.status(statusCode).end(); 59 | } 60 | 61 | let response = { 62 | uploads: uploads, 63 | fields: fields, 64 | headers: req.headers, 65 | method: req.method, 66 | }; 67 | 68 | // Simple random data added to each request to test for issue https://github.com/BlueChilli/flutter_uploader/issues/53. 69 | if (simulate === 'ok200randomdata') { 70 | response.random = crypto.randomBytes(10240).toString('hex'); 71 | } 72 | 73 | return res.status(statusCode).json({ 74 | message: jsonMessageForSimulation(simulate), 75 | request: response 76 | }).end(); 77 | }); 78 | busboy.on("error", error => { 79 | res.status(500).json({ 80 | errorMessage: error.toString(), 81 | error: error 82 | }).end(); 83 | }); 84 | busboy.end(req.rawBody); 85 | return req.pipe(busboy); 86 | }); 87 | 88 | exports.uploadBinary = functions.https.onRequest(async (req, res) => { 89 | const methods = ["POST", "PUT", "PATCH"]; 90 | if (!methods.includes(req.method, 0)) { 91 | return res.status(405).json({ 92 | errorMessage: "Method is not allowed" 93 | }); 94 | } 95 | 96 | const simulate = req.query.simulate !== undefined ? req.query.simulate : 'ok200'; 97 | const filename = [...Array(10)].map(i => (~~(Math.random() * 36)).toString(36)).join(''); 98 | const filepath = path.join(os.tmpdir(), filename); 99 | 100 | console.log(`File [${filename}]: filepath: ${filepath}, ${JSON.stringify(req.headers)}`); 101 | 102 | try { 103 | await writeToFile(filepath, req.rawBody); 104 | } catch (e) { 105 | console.error(e); 106 | return res.status(500).json({ 107 | message: "Invalid request", 108 | }).end(); 109 | } 110 | 111 | const md5hash = md5File.sync(filepath); 112 | const stats = fs.statSync(filepath); 113 | const fileSizeInBytes = stats.size; 114 | 115 | fs.unlinkSync(filepath); 116 | 117 | const statusCode = statusCodeForSimulation(simulate); 118 | 119 | if (statusCode === 201) { 120 | return res.status(statusCode).end(); 121 | } 122 | 123 | return res.status(statusCode).json({ 124 | message: "Successfully uploaded", 125 | length: fileSizeInBytes, 126 | md5: md5hash, 127 | headers: req.headers, 128 | method: req.method, 129 | }).end(); 130 | }); 131 | 132 | 133 | 134 | function statusCodeForSimulation(simulation) { 135 | switch (simulation) { 136 | case 'ok200': 137 | case 'ok200randomdata': 138 | return 200; 139 | case 'ok201': 140 | return 201; 141 | case 'error401': 142 | return 401; 143 | case 'error403': 144 | return 403; 145 | case 'error500': 146 | return 500; 147 | default: 148 | console.error('Unknown simulation, returning 500'); 149 | return 500; 150 | } 151 | } 152 | 153 | function jsonMessageForSimulation(simulation) { 154 | switch (simulation) { 155 | case 'error401': 156 | return 401; 157 | case 'error403': 158 | return 403; 159 | case 'error500': 160 | return 500; 161 | default: 162 | return "Successfully uploaded" 163 | } 164 | } 165 | 166 | function writeToFile(filePath, rawBody) { 167 | return new Promise((resolve, reject) => { 168 | const file = fs.createWriteStream(filePath); 169 | file.write(rawBody); 170 | file.end(); 171 | file.on("finish", () => { resolve(); }); 172 | file.on("error", reject); 173 | }); 174 | } 175 | -------------------------------------------------------------------------------- /example/backend/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "description": "Cloud Functions for Firebase", 4 | "scripts": { 5 | "lint": "eslint .", 6 | "serve": "firebase serve --only functions -o 0.0.0.0", 7 | "shell": "firebase functions:shell", 8 | "start": "npm run shell", 9 | "deploy": "firebase deploy --only functions", 10 | "logs": "firebase functions:log" 11 | }, 12 | "dependencies": { 13 | "busboy": "^0.3.0", 14 | "firebase-admin": "^11.0.1", 15 | "firebase-functions": "^3.13.2", 16 | "md5-file": "^4.0.0" 17 | }, 18 | "devDependencies": { 19 | "eslint": "^5.16.0", 20 | "eslint-plugin-promise": "^4.2.1" 21 | }, 22 | "private": true, 23 | "engines": { 24 | "node": "10" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /example/backend/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1 3 | } 4 | -------------------------------------------------------------------------------- /example/integration_test/flutter_uploader_integration_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:math'; 3 | import 'dart:io'; 4 | 5 | import 'package:integration_test/integration_test.dart'; 6 | import 'package:flutter_test/flutter_test.dart'; 7 | import 'package:path_provider/path_provider.dart'; 8 | 9 | import 'package:flutter_uploader/flutter_uploader.dart'; 10 | 11 | final baseUrl = Uri.parse( 12 | 'https://us-central1-flutteruploadertest.cloudfunctions.net/upload', 13 | ); 14 | 15 | void main() { 16 | IntegrationTestWidgetsFlutterBinding.ensureInitialized(); 17 | 18 | late FlutterUploader uploader; 19 | var tempFilePaths = []; 20 | 21 | setUp(() { 22 | uploader = FlutterUploader(); 23 | }); 24 | 25 | tearDownAll(() { 26 | for (var path in tempFilePaths) { 27 | try { 28 | File(path).deleteSync(); 29 | } catch (e) { 30 | // ignored 31 | } 32 | } 33 | tempFilePaths.clear(); 34 | }); 35 | 36 | bool Function(UploadTaskResponse) isCompleted(String taskId) { 37 | return (response) { 38 | return response.taskId == taskId && 39 | response.status == UploadTaskStatus.complete; 40 | }; 41 | } 42 | 43 | bool Function(UploadTaskResponse) isFailed(String taskId) { 44 | return (response) => 45 | response.taskId == taskId && response.status == UploadTaskStatus.failed; 46 | } 47 | 48 | group('multipart/form-data uploads', () { 49 | final url = baseUrl; 50 | 51 | testWidgets('single file', (WidgetTester tester) async { 52 | var fileItem = FileItem(path: await _tmpFile(), field: 'file'); 53 | 54 | final taskId = await uploader.enqueue( 55 | MultipartFormDataUpload(url: url.toString(), files: [fileItem]), 56 | ); 57 | 58 | expect(taskId, isNotNull); 59 | 60 | final res = await uploader.result.firstWhere(isCompleted(taskId)); 61 | final json = jsonDecode(res.response!); 62 | 63 | expect(json['message'], 'Successfully uploaded'); 64 | expect(res.statusCode, 200); 65 | expect(json['request']['headers']['accept'], '*/*'); 66 | expect(res.status, UploadTaskStatus.complete); 67 | }); 68 | 69 | testWidgets('multiple uploads stresstest', (WidgetTester tester) async { 70 | final taskIds = []; 71 | for (var i = 0; i < 10; i++) { 72 | taskIds.add(await uploader.enqueue( 73 | MultipartFormDataUpload(url: url.toString(), files: [ 74 | FileItem(path: await _tmpFile(), field: 'file'), 75 | ]), 76 | )); 77 | } 78 | 79 | final res = await Future.wait( 80 | taskIds.map( 81 | (taskId) => uploader.result.firstWhere(isCompleted(taskId)), 82 | ), 83 | ); 84 | 85 | for (var i = 0; i < res.length; i++) { 86 | expect(res[i].taskId, taskIds[i]); 87 | } 88 | }); 89 | 90 | testWidgets('can submit custom data', (tester) async { 91 | var fileItem = FileItem(path: await _tmpFile(), field: 'file'); 92 | 93 | final taskId = await uploader.enqueue( 94 | MultipartFormDataUpload(url: url.toString(), files: [ 95 | fileItem 96 | ], data: { 97 | 'simpleKey': 'simpleValue', 98 | 'listOf': jsonEncode(['data', 'data', 'data']), 99 | 'dictionaryOf': jsonEncode({ 100 | 'key1': 'value1', 101 | 'key2': 'value2', 102 | }), 103 | }), 104 | ); 105 | 106 | expect(taskId, isNotNull); 107 | 108 | final res = await uploader.result.firstWhere(isCompleted(taskId)); 109 | final json = jsonDecode(res.response!); 110 | 111 | expect(json['request']['fields']['simpleKey'], 'simpleValue'); 112 | expect(jsonDecode(json['request']['fields']['listOf']), 113 | ['data', 'data', 'data']); 114 | expect(jsonDecode(json['request']['fields']['dictionaryOf']), { 115 | 'key1': 'value1', 116 | 'key2': 'value2', 117 | }); 118 | }); 119 | 120 | testWidgets("can overwrite 'Accept' header", (WidgetTester tester) async { 121 | var fileItem = FileItem(path: await _tmpFile(), field: 'file'); 122 | 123 | final taskId = await uploader.enqueue(MultipartFormDataUpload( 124 | url: url.toString(), 125 | files: [fileItem], 126 | headers: {'Accept': 'application/json, charset=utf-8'}, 127 | )); 128 | final res = await uploader.result.firstWhere(isCompleted(taskId)); 129 | final json = jsonDecode(res.response!); 130 | 131 | expect(json['request']['headers']['accept'], 132 | 'application/json, charset=utf-8'); 133 | }); 134 | 135 | testWidgets('multiple files', (WidgetTester tester) async { 136 | final taskId = await uploader.enqueue( 137 | MultipartFormDataUpload(url: url.toString(), files: [ 138 | FileItem(path: await _tmpFile(256), field: 'file1'), 139 | FileItem(path: await _tmpFile(257), field: 'file2'), 140 | ]), 141 | ); 142 | 143 | expect(taskId, isNotNull); 144 | 145 | final res = await uploader.result.firstWhere(isCompleted(taskId)); 146 | final json = jsonDecode(res.response!); 147 | 148 | expect(json['message'], 'Successfully uploaded'); 149 | expect(res.statusCode, 200); 150 | expect(res.status, UploadTaskStatus.complete); 151 | }); 152 | 153 | testWidgets('handles 201 empty body', (WidgetTester tester) async { 154 | var fileItem = FileItem(path: await _tmpFile(), field: 'file'); 155 | 156 | final taskId = await uploader.enqueue( 157 | MultipartFormDataUpload( 158 | url: url.replace(queryParameters: { 159 | 'simulate': 'ok201', 160 | }).toString(), 161 | files: [fileItem], 162 | ), 163 | ); 164 | 165 | expect(taskId, isNotNull); 166 | 167 | final res = await uploader.result.firstWhere(isCompleted(taskId)); 168 | 169 | expect(res.response, isNull); 170 | expect(res.statusCode, 201); 171 | expect(res.status, UploadTaskStatus.complete); 172 | }); 173 | 174 | testWidgets('forwards errors', (WidgetTester tester) async { 175 | var fileItem = FileItem(path: await _tmpFile(), field: 'file'); 176 | 177 | final taskId = await uploader.enqueue( 178 | MultipartFormDataUpload( 179 | url: 180 | url.replace(queryParameters: {'simulate': 'error500'}).toString(), 181 | files: [fileItem], 182 | ), 183 | ); 184 | 185 | expect(taskId, isNotNull); 186 | 187 | final res = await uploader.result.firstWhere(isFailed(taskId)); 188 | expect(res.statusCode, 500); 189 | expect(res.status, UploadTaskStatus.failed); 190 | }); 191 | }); 192 | 193 | group('binary uploads', () { 194 | final url = baseUrl.replace(path: baseUrl.path + 'Binary'); 195 | 196 | testWidgets('single file', (WidgetTester tester) async { 197 | final taskId = await uploader.enqueue( 198 | RawUpload( 199 | url: url.toString(), 200 | path: await _tmpFile(), 201 | ), 202 | ); 203 | 204 | expect(taskId, isNotNull); 205 | 206 | final res = await uploader.result.firstWhere(isCompleted(taskId)); 207 | final json = jsonDecode(res.response!); 208 | 209 | expect(json['message'], 'Successfully uploaded'); 210 | expect(res.statusCode, 200); 211 | expect(json['headers']['accept'], '*/*'); 212 | expect(res.status, UploadTaskStatus.complete); 213 | }); 214 | 215 | testWidgets('multiple uploads stresstest', (WidgetTester tester) async { 216 | final taskIds = []; 217 | for (var i = 0; i < 10; i++) { 218 | taskIds.add(await uploader.enqueue( 219 | RawUpload(url: url.toString(), path: await _tmpFile()), 220 | )); 221 | } 222 | 223 | final res = await Future.wait( 224 | taskIds.map( 225 | (taskId) => uploader.result.firstWhere(isCompleted(taskId)), 226 | ), 227 | ); 228 | 229 | for (var i = 0; i < res.length; i++) { 230 | expect(res[i].taskId, taskIds[i]); 231 | } 232 | }); 233 | 234 | testWidgets("can overwrite 'Accept' header", (WidgetTester tester) async { 235 | final taskId = await uploader.enqueue(RawUpload( 236 | url: url.toString(), 237 | path: await _tmpFile(), 238 | headers: {'Accept': 'application/json, charset=utf-8'}, 239 | )); 240 | final res = await uploader.result.firstWhere(isCompleted(taskId)); 241 | final json = jsonDecode(res.response!); 242 | 243 | expect(json['headers']['accept'], 'application/json, charset=utf-8'); 244 | }); 245 | testWidgets('fowards errors', (WidgetTester tester) async { 246 | final taskId = await uploader.enqueue( 247 | RawUpload( 248 | url: 249 | url.replace(queryParameters: {'simulate': 'error500'}).toString(), 250 | path: await _tmpFile(), 251 | ), 252 | ); 253 | 254 | expect(taskId, isNotNull); 255 | 256 | final res = await uploader.result.firstWhere(isFailed(taskId)); 257 | expect(res.statusCode, 500); 258 | expect(res.status, UploadTaskStatus.failed); 259 | }); 260 | }); 261 | } 262 | 263 | /// Create a temporary file, with random contents. 264 | Future _tmpFile([int length = 128]) async { 265 | /// Create a temporary file, with random contents. 266 | final tempDir = await getTemporaryDirectory(); 267 | 268 | var random = Random.secure(); 269 | var data = List.generate(length, (i) => random.nextInt(256)); 270 | var name = String.fromCharCodes( 271 | List.generate(16, (index) => random.nextInt(33) + 89), 272 | ); 273 | final file = File('${tempDir.path}/$name')..writeAsBytesSync(data); 274 | 275 | file.statSync(); 276 | 277 | return file.path; 278 | } 279 | -------------------------------------------------------------------------------- /example/ios/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - example/ios/Pods 3 | - Pods 4 | included: 5 | - .symlinks/plugins/flutter_uploader/ios 6 | 7 | -------------------------------------------------------------------------------- /example/ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 9.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /example/ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /example/ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 52 | 54 | 60 | 61 | 62 | 63 | 69 | 71 | 77 | 78 | 79 | 80 | 82 | 83 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /example/ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildSystemType 6 | Latest 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | import flutter_uploader 5 | 6 | func registerPlugins(registry: FlutterPluginRegistry) { 7 | GeneratedPluginRegistrant.register(with: registry) 8 | } 9 | 10 | @UIApplicationMain 11 | @objc class AppDelegate: FlutterAppDelegate { 12 | override func application( 13 | _ application: UIApplication, 14 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 15 | ) -> Bool { 16 | GeneratedPluginRegistrant.register(with: self) 17 | 18 | SwiftFlutterUploaderPlugin.registerPlugins = registerPlugins 19 | 20 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /example/ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /example/ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /example/ios/Runner/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AD_UNIT_ID_FOR_BANNER_TEST 6 | ca-app-pub-3940256099942544/2934735716 7 | AD_UNIT_ID_FOR_INTERSTITIAL_TEST 8 | ca-app-pub-3940256099942544/4411468910 9 | CLIENT_ID 10 | 475199574117-ju3u7efhlbgq3ges9a4u8s37pdcba78i.apps.googleusercontent.com 11 | REVERSED_CLIENT_ID 12 | com.googleusercontent.apps.475199574117-ju3u7efhlbgq3ges9a4u8s37pdcba78i 13 | API_KEY 14 | AIzaSyAUQQgluEf9xsCY_Bn_1T5jon0zlFIJjqs 15 | GCM_SENDER_ID 16 | 475199574117 17 | PLIST_VERSION 18 | 1 19 | BUNDLE_ID 20 | com.bluechilli.d.flutterUploaderExample 21 | PROJECT_ID 22 | flutteruploader 23 | STORAGE_BUCKET 24 | flutteruploader.appspot.com 25 | IS_ADS_ENABLED 26 | 27 | IS_ANALYTICS_ENABLED 28 | 29 | IS_APPINVITE_ENABLED 30 | 31 | IS_GCM_ENABLED 32 | 33 | IS_SIGNIN_ENABLED 34 | 35 | GOOGLE_APP_ID 36 | 1:475199574117:ios:0461ae26ac197ca5 37 | DATABASE_URL 38 | https://flutteruploader.firebaseio.com 39 | 40 | -------------------------------------------------------------------------------- /example/ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | flutter_uploader_example 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | $(FLUTTER_BUILD_NAME) 19 | CFBundleSignature 20 | ???? 21 | CFBundleURLTypes 22 | 23 | 24 | CFBundleTypeRole 25 | Editor 26 | CFBundleURLSchemes 27 | 28 | com.googleusercontent.apps.475199574117-ju3u7efhlbgq3ges9a4u8s37pdcba78i 29 | 30 | 31 | 32 | CFBundleVersion 33 | $(FLUTTER_BUILD_NUMBER) 34 | FUMaximumConnectionsPerHost 35 | 5 36 | FUMaximumUploadOperation 37 | 3 38 | LSRequiresIPhoneOS 39 | 40 | NSCameraUsageDescription 41 | app needs to access to camera 42 | NSMicrophoneUsageDescription 43 | app needs to access to microphone 44 | NSPhotoLibraryUsageDescription 45 | app needs to access to photo library 46 | UIBackgroundModes 47 | 48 | fetch 49 | remote-notification 50 | 51 | UILaunchStoryboardName 52 | LaunchScreen 53 | UIMainStoryboardFile 54 | Main 55 | UISupportedInterfaceOrientations 56 | 57 | UIInterfaceOrientationPortrait 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | UISupportedInterfaceOrientations~ipad 62 | 63 | UIInterfaceOrientationPortrait 64 | UIInterfaceOrientationPortraitUpsideDown 65 | UIInterfaceOrientationLandscapeLeft 66 | UIInterfaceOrientationLandscapeRight 67 | 68 | UIViewControllerBasedStatusBarAppearance 69 | 70 | UIFileSharingEnabled 71 | 72 | UISupportsDocumentBrowser 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /example/ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" -------------------------------------------------------------------------------- /example/lib/main.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: public_member_api_docs 2 | // ignore_for_file: avoid_print 3 | 4 | import 'dart:io'; 5 | 6 | import 'package:flutter/material.dart'; 7 | import 'package:flutter_uploader/flutter_uploader.dart'; 8 | import 'package:flutter_uploader_example/responses_screen.dart'; 9 | import 'package:flutter_uploader_example/upload_screen.dart'; 10 | import 'package:shared_preferences/shared_preferences.dart'; 11 | import 'package:flutter_local_notifications/flutter_local_notifications.dart'; 12 | 13 | const String title = 'FileUpload Sample app'; 14 | final Uri uploadURL = Uri.parse( 15 | 'https://us-central1-flutteruploadertest.cloudfunctions.net/upload', 16 | ); 17 | 18 | FlutterUploader _uploader = FlutterUploader(); 19 | 20 | void backgroundHandler() { 21 | WidgetsFlutterBinding.ensureInitialized(); 22 | 23 | // Notice these instances belong to a forked isolate. 24 | var uploader = FlutterUploader(); 25 | 26 | var notifications = FlutterLocalNotificationsPlugin(); 27 | 28 | // Only show notifications for unprocessed uploads. 29 | SharedPreferences.getInstance().then((preferences) { 30 | var processed = preferences.getStringList('processed') ?? []; 31 | 32 | if (Platform.isAndroid) { 33 | uploader.progress.listen((progress) { 34 | if (processed.contains(progress.taskId)) { 35 | return; 36 | } 37 | 38 | notifications.show( 39 | progress.taskId.hashCode, 40 | 'FlutterUploader Example', 41 | 'Upload in Progress', 42 | NotificationDetails( 43 | android: AndroidNotificationDetails( 44 | 'FlutterUploader.Example', 45 | 'FlutterUploader', 46 | channelDescription: 47 | 'Installed when you activate the Flutter Uploader Example', 48 | progress: progress.progress ?? 0, 49 | icon: 'ic_upload', 50 | enableVibration: false, 51 | importance: Importance.low, 52 | showProgress: true, 53 | onlyAlertOnce: true, 54 | maxProgress: 100, 55 | channelShowBadge: false, 56 | ), 57 | iOS: const IOSNotificationDetails(), 58 | ), 59 | ); 60 | }); 61 | } 62 | 63 | uploader.result.listen((result) { 64 | if (processed.contains(result.taskId)) { 65 | return; 66 | } 67 | 68 | processed.add(result.taskId); 69 | preferences.setStringList('processed', processed); 70 | 71 | notifications.cancel(result.taskId.hashCode); 72 | 73 | final successful = result.status == UploadTaskStatus.complete; 74 | 75 | var title = 'Upload Complete'; 76 | if (result.status == UploadTaskStatus.failed) { 77 | title = 'Upload Failed'; 78 | } else if (result.status == UploadTaskStatus.canceled) { 79 | title = 'Upload Canceled'; 80 | } 81 | 82 | notifications 83 | .show( 84 | result.taskId.hashCode, 85 | 'FlutterUploader Example', 86 | title, 87 | NotificationDetails( 88 | android: AndroidNotificationDetails( 89 | 'FlutterUploader.Example', 90 | 'FlutterUploader', 91 | channelDescription: 92 | 'Installed when you activate the Flutter Uploader Example', 93 | icon: 'ic_upload', 94 | enableVibration: !successful, 95 | importance: result.status == UploadTaskStatus.failed 96 | ? Importance.high 97 | : Importance.min, 98 | ), 99 | iOS: const IOSNotificationDetails( 100 | presentAlert: true, 101 | ), 102 | ), 103 | ) 104 | .catchError((e, stack) { 105 | print('error while showing notification: $e, $stack'); 106 | }); 107 | }); 108 | }); 109 | } 110 | 111 | void main() => runApp(const App()); 112 | 113 | class App extends StatefulWidget { 114 | const App({Key? key}) : super(key: key); 115 | 116 | @override 117 | _AppState createState() => _AppState(); 118 | } 119 | 120 | class _AppState extends State { 121 | int _currentIndex = 0; 122 | 123 | bool allowCellular = true; 124 | 125 | @override 126 | void initState() { 127 | super.initState(); 128 | 129 | _uploader.setBackgroundHandler(backgroundHandler); 130 | 131 | var flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); 132 | var initializationSettingsAndroid = 133 | const AndroidInitializationSettings('ic_upload'); 134 | var initializationSettingsIOS = IOSInitializationSettings( 135 | requestSoundPermission: false, 136 | requestBadgePermission: false, 137 | requestAlertPermission: true, 138 | onDidReceiveLocalNotification: 139 | (int id, String? title, String? body, String? payload) async {}, 140 | ); 141 | var initializationSettings = InitializationSettings( 142 | android: initializationSettingsAndroid, iOS: initializationSettingsIOS); 143 | flutterLocalNotificationsPlugin.initialize( 144 | initializationSettings, 145 | onSelectNotification: (payload) async {}, 146 | ); 147 | 148 | SharedPreferences.getInstance() 149 | .then((sp) => sp.getBool('allowCellular') ?? true) 150 | .then((result) { 151 | if (mounted) { 152 | setState(() { 153 | allowCellular = result; 154 | }); 155 | } 156 | }); 157 | } 158 | 159 | @override 160 | Widget build(BuildContext context) { 161 | return MaterialApp( 162 | title: title, 163 | theme: ThemeData( 164 | primarySwatch: Colors.blue, 165 | ), 166 | home: Scaffold( 167 | appBar: AppBar( 168 | actions: [ 169 | IconButton( 170 | icon: Icon(allowCellular 171 | ? Icons.signal_cellular_connected_no_internet_4_bar 172 | : Icons.wifi_outlined), 173 | onPressed: () async { 174 | final sp = await SharedPreferences.getInstance(); 175 | await sp.setBool('allowCellular', !allowCellular); 176 | if (mounted) { 177 | setState(() { 178 | allowCellular = !allowCellular; 179 | }); 180 | } 181 | }, 182 | ), 183 | ], 184 | ), 185 | body: _currentIndex == 0 186 | ? UploadScreen( 187 | uploader: _uploader, 188 | uploadURL: uploadURL, 189 | onUploadStarted: () { 190 | setState(() => _currentIndex = 1); 191 | }, 192 | ) 193 | : ResponsesScreen( 194 | uploader: _uploader, 195 | ), 196 | bottomNavigationBar: BottomNavigationBar( 197 | items: const [ 198 | BottomNavigationBarItem( 199 | icon: Icon(Icons.cloud_upload), 200 | label: 'Upload', 201 | ), 202 | BottomNavigationBarItem( 203 | icon: Icon(Icons.receipt), 204 | label: 'Responses', 205 | ), 206 | ], 207 | onTap: (newIndex) { 208 | setState(() => _currentIndex = newIndex); 209 | }, 210 | currentIndex: _currentIndex, 211 | ), 212 | ), 213 | ); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /example/lib/responses_screen.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: public_member_api_docs 2 | // ignore_for_file: avoid_print 3 | 4 | import 'dart:async'; 5 | 6 | import 'package:flutter/material.dart'; 7 | import 'package:flutter_uploader/flutter_uploader.dart'; 8 | import 'package:flutter_uploader_example/upload_item.dart'; 9 | import 'package:flutter_uploader_example/upload_item_view.dart'; 10 | 11 | /// Shows the statusresponses for previous uploads. 12 | class ResponsesScreen extends StatefulWidget { 13 | const ResponsesScreen({ 14 | Key? key, 15 | required this.uploader, 16 | }) : super(key: key); 17 | 18 | final FlutterUploader uploader; 19 | 20 | @override 21 | _ResponsesScreenState createState() => _ResponsesScreenState(); 22 | } 23 | 24 | class _ResponsesScreenState extends State { 25 | StreamSubscription? _progressSubscription; 26 | StreamSubscription? _resultSubscription; 27 | 28 | Map _tasks = {}; 29 | 30 | @override 31 | void initState() { 32 | super.initState(); 33 | 34 | _progressSubscription = widget.uploader.progress.listen((progress) { 35 | final task = _tasks[progress.taskId]; 36 | print( 37 | 'In MAIN APP: ID: ${progress.taskId}, progress: ${progress.progress}'); 38 | if (task == null) return; 39 | if (task.isCompleted()) return; 40 | 41 | var tmp = {}..addAll(_tasks); 42 | tmp.putIfAbsent(progress.taskId, () => UploadItem(progress.taskId)); 43 | tmp[progress.taskId] = 44 | task.copyWith(progress: progress.progress, status: progress.status); 45 | setState(() => _tasks = tmp); 46 | }, onError: (ex, stacktrace) { 47 | print('exception: $ex'); 48 | print('stacktrace: $stacktrace'); 49 | }); 50 | 51 | _resultSubscription = widget.uploader.result.listen((result) { 52 | print( 53 | 'IN MAIN APP: ${result.taskId}, status: ${result.status}, statusCode: ${result.statusCode}, headers: ${result.headers}'); 54 | 55 | var tmp = {}..addAll(_tasks); 56 | tmp.putIfAbsent(result.taskId, () => UploadItem(result.taskId)); 57 | tmp[result.taskId] = 58 | tmp[result.taskId]!.copyWith(status: result.status, response: result); 59 | 60 | setState(() => _tasks = tmp); 61 | }, onError: (ex, stacktrace) { 62 | print('exception: $ex'); 63 | print('stacktrace: $stacktrace'); 64 | }); 65 | } 66 | 67 | @override 68 | void dispose() { 69 | super.dispose(); 70 | _progressSubscription?.cancel(); 71 | _resultSubscription?.cancel(); 72 | } 73 | 74 | @override 75 | Widget build(BuildContext context) { 76 | return Scaffold( 77 | appBar: AppBar( 78 | title: const Text('Responses'), 79 | ), 80 | body: ListView.separated( 81 | padding: const EdgeInsets.all(20.0), 82 | itemCount: _tasks.length, 83 | itemBuilder: (context, index) { 84 | final item = _tasks.values.elementAt(index); 85 | return UploadItemView( 86 | item: item, 87 | onCancel: _cancelUpload, 88 | ); 89 | }, 90 | separatorBuilder: (context, index) { 91 | return const Divider( 92 | color: Colors.black, 93 | ); 94 | }, 95 | ), 96 | ); 97 | } 98 | 99 | Future _cancelUpload(String id) async { 100 | await widget.uploader.cancel(taskId: id); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /example/lib/server_behavior.dart: -------------------------------------------------------------------------------- 1 | /// Configure server behavior 2 | class ServerBehavior { 3 | /// User visible 4 | final String title; 5 | 6 | /// Backend server understands this 7 | final String name; 8 | 9 | ServerBehavior._(this.title, this.name); 10 | 11 | /// Default behavior 12 | static ServerBehavior defaultOk200 = ServerBehavior._('OK - 200', 'ok200'); 13 | 14 | /// All available server behaviors. 15 | static List all = [ 16 | defaultOk200, 17 | ServerBehavior._('OK - 200, add random data', 'ok200randomdata'), 18 | ServerBehavior._('OK - 201', 'ok201'), 19 | ServerBehavior._('Error - 401', 'error401'), 20 | ServerBehavior._('Error - 403', 'error403'), 21 | ServerBehavior._('Error - 500', 'error500') 22 | ]; 23 | } 24 | -------------------------------------------------------------------------------- /example/lib/upload_item.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: public_member_api_docs 2 | 3 | import 'package:equatable/equatable.dart'; 4 | 5 | import 'package:flutter_uploader/flutter_uploader.dart'; 6 | 7 | class UploadItem extends Equatable { 8 | final String id; 9 | final int? progress; 10 | final UploadTaskStatus? status; 11 | 12 | /// Store the entire response object. 13 | final UploadTaskResponse? response; 14 | 15 | const UploadItem( 16 | this.id, { 17 | this.progress, 18 | this.status, 19 | this.response, 20 | }); 21 | 22 | UploadItem copyWith({ 23 | String? id, 24 | int? progress, 25 | UploadTaskStatus? status, 26 | UploadTaskResponse? response, 27 | }) { 28 | return UploadItem( 29 | id ?? this.id, 30 | progress: progress ?? this.progress, 31 | status: status ?? this.status, 32 | response: response ?? this.response, 33 | ); 34 | } 35 | 36 | bool isCompleted() => 37 | status == UploadTaskStatus.canceled || 38 | status == UploadTaskStatus.complete || 39 | status == UploadTaskStatus.failed; 40 | 41 | @override 42 | List get props { 43 | return [ 44 | id, 45 | progress, 46 | status, 47 | response, 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /example/lib/upload_item_view.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: public_member_api_docs 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:flutter_uploader/flutter_uploader.dart'; 6 | import 'package:flutter_uploader_example/upload_item.dart'; 7 | 8 | typedef CancelUploadCallback = Future Function(String id); 9 | 10 | class UploadItemView extends StatelessWidget { 11 | final UploadItem item; 12 | final CancelUploadCallback onCancel; 13 | 14 | const UploadItemView({ 15 | Key? key, 16 | required this.item, 17 | required this.onCancel, 18 | }) : super(key: key); 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | return Row( 23 | children: [ 24 | Expanded( 25 | child: Column( 26 | crossAxisAlignment: CrossAxisAlignment.stretch, 27 | children: [ 28 | Text( 29 | item.id, 30 | style: Theme.of(context) 31 | .textTheme 32 | .caption! 33 | .copyWith(fontFamily: 'monospace'), 34 | ), 35 | Container( 36 | height: 5.0, 37 | ), 38 | Text(item.status!.description), 39 | // if (item.status == UploadTaskStatus.complete && 40 | // item.remoteHash != null) 41 | // Builder(builder: (context) { 42 | // return Column( 43 | // mainAxisSize: MainAxisSize.min, 44 | // crossAxisAlignment: CrossAxisAlignment.stretch, 45 | // children: [ 46 | // _compareMd5(item.path, item.remoteHash), 47 | // _compareSize(item.path, item.remoteSize), 48 | // ], 49 | // ); 50 | // }), 51 | Container(height: 5.0), 52 | if (item.status == UploadTaskStatus.running) 53 | LinearProgressIndicator(value: item.progress!.toDouble() / 100), 54 | if (item.status == UploadTaskStatus.complete || 55 | item.status == UploadTaskStatus.failed) ...[ 56 | Text('HTTP status code: ${item.response!.statusCode}'), 57 | if (item.response!.response != null) 58 | Text( 59 | item.response!.response!, 60 | style: Theme.of(context) 61 | .textTheme 62 | .caption! 63 | .copyWith(fontFamily: 'monospace'), 64 | ), 65 | ] 66 | ], 67 | ), 68 | ), 69 | if (item.status == UploadTaskStatus.running) 70 | SizedBox( 71 | height: 50, 72 | width: 50, 73 | child: IconButton( 74 | icon: const Icon(Icons.cancel), 75 | onPressed: () => onCancel(item.id), 76 | ), 77 | ) 78 | ], 79 | ); 80 | } 81 | 82 | // Text _compareMd5(String localPath, String remoteHash) { 83 | // final File file = File(localPath); 84 | // if (!file.existsSync()) { 85 | // return Text( 86 | // 'File ƒ', 87 | // style: TextStyle(color: Colors.grey), 88 | // ); 89 | // } 90 | 91 | // var digest = md5.convert(file.readAsBytesSync()); 92 | // if (digest.toString().toLowerCase() == remoteHash) { 93 | // return Text( 94 | // 'Hash $digest √', 95 | // style: TextStyle(color: Colors.green), 96 | // ); 97 | // } else { 98 | // return Text( 99 | // 'Hash $digest vs $remoteHash ƒ', 100 | // style: TextStyle(color: Colors.red), 101 | // ); 102 | // } 103 | // } 104 | 105 | // Text _compareSize(String localPath, int remoteSize) { 106 | // final File file = File(localPath); 107 | // if (!file.existsSync()) { 108 | // return Text( 109 | // 'File ƒ', 110 | // style: TextStyle(color: Colors.grey), 111 | // ); 112 | // } 113 | 114 | // final length = file.lengthSync(); 115 | // if (length == remoteSize) { 116 | // return Text( 117 | // 'Length $length √', 118 | // style: TextStyle(color: Colors.green), 119 | // ); 120 | // } else { 121 | // return Text( 122 | // 'Length $length vs $remoteSize ƒ', 123 | // style: TextStyle(color: Colors.red), 124 | // ); 125 | // } 126 | // } 127 | } 128 | -------------------------------------------------------------------------------- /example/lib/upload_screen.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: public_member_api_docs 2 | 3 | import 'dart:async'; 4 | import 'dart:io'; 5 | 6 | import 'package:file_picker/file_picker.dart'; 7 | import 'package:flutter/material.dart'; 8 | import 'package:flutter_uploader/flutter_uploader.dart'; 9 | import 'package:flutter_uploader_example/server_behavior.dart'; 10 | import 'package:image_picker/image_picker.dart'; 11 | import 'package:shared_preferences/shared_preferences.dart'; 12 | 13 | class UploadScreen extends StatefulWidget { 14 | const UploadScreen({ 15 | Key? key, 16 | required this.uploader, 17 | required this.uploadURL, 18 | required this.onUploadStarted, 19 | }) : super(key: key); 20 | 21 | final FlutterUploader uploader; 22 | final Uri uploadURL; 23 | final VoidCallback onUploadStarted; 24 | 25 | @override 26 | _UploadScreenState createState() => _UploadScreenState(); 27 | } 28 | 29 | class _UploadScreenState extends State { 30 | ImagePicker imagePicker = ImagePicker(); 31 | 32 | ServerBehavior _serverBehavior = ServerBehavior.defaultOk200; 33 | 34 | @override 35 | void initState() { 36 | super.initState(); 37 | 38 | if (Platform.isAndroid) { 39 | imagePicker.retrieveLostData().then((lostData) { 40 | if (lostData.isEmpty) { 41 | return; 42 | } 43 | 44 | if (lostData.type == RetrieveType.image) { 45 | _handleFileUpload([lostData.file!.path]); 46 | } 47 | if (lostData.type == RetrieveType.video) { 48 | _handleFileUpload([lostData.file!.path]); 49 | } 50 | }); 51 | } 52 | } 53 | 54 | @override 55 | Widget build(BuildContext context) { 56 | return Scaffold( 57 | appBar: AppBar( 58 | title: const Text('Flutter Uploader'), 59 | ), 60 | body: Padding( 61 | padding: const EdgeInsets.all(8.0), 62 | child: Center( 63 | child: SingleChildScrollView( 64 | child: Column( 65 | mainAxisAlignment: MainAxisAlignment.center, 66 | children: [ 67 | Text( 68 | 'Configure test Server Behavior', 69 | style: Theme.of(context).textTheme.subtitle1, 70 | ), 71 | DropdownButton( 72 | items: ServerBehavior.all.map((e) { 73 | return DropdownMenuItem( 74 | value: e, 75 | child: Text(e.title), 76 | ); 77 | }).toList(), 78 | onChanged: (newBehavior) { 79 | if (newBehavior != null) { 80 | setState(() => _serverBehavior = newBehavior); 81 | } 82 | }, 83 | value: _serverBehavior, 84 | ), 85 | const Divider(), 86 | Text( 87 | 'multipart/form-data uploads', 88 | style: Theme.of(context).textTheme.subtitle1, 89 | ), 90 | Wrap( 91 | alignment: WrapAlignment.center, 92 | spacing: 10, 93 | children: [ 94 | ElevatedButton( 95 | onPressed: () => getImage(binary: false), 96 | child: const Text('upload image'), 97 | ), 98 | ElevatedButton( 99 | onPressed: () => getVideo(binary: false), 100 | child: const Text('upload video'), 101 | ), 102 | ElevatedButton( 103 | onPressed: () => getMultiple(binary: false), 104 | child: const Text('upload multi'), 105 | ), 106 | ], 107 | ), 108 | const Divider(height: 40), 109 | Text( 110 | 'binary uploads', 111 | style: Theme.of(context).textTheme.subtitle1, 112 | ), 113 | const Text('this will upload selected files as binary'), 114 | Wrap( 115 | alignment: WrapAlignment.center, 116 | spacing: 10, 117 | children: [ 118 | ElevatedButton( 119 | onPressed: () => getImage(binary: true), 120 | child: const Text('upload image'), 121 | ), 122 | ElevatedButton( 123 | onPressed: () => getVideo(binary: true), 124 | child: const Text('upload video'), 125 | ), 126 | ElevatedButton( 127 | onPressed: () => getMultiple(binary: true), 128 | child: const Text('upload multi'), 129 | ), 130 | ], 131 | ), 132 | const Divider(height: 40), 133 | const Text('Cancellation'), 134 | Row( 135 | mainAxisAlignment: MainAxisAlignment.center, 136 | children: [ 137 | ElevatedButton( 138 | onPressed: () => widget.uploader.cancelAll(), 139 | child: const Text('Cancel All'), 140 | ), 141 | Container(width: 20.0), 142 | ElevatedButton( 143 | onPressed: () { 144 | widget.uploader.clearUploads(); 145 | }, 146 | child: const Text('Clear Uploads'), 147 | ) 148 | ], 149 | ), 150 | ], 151 | ), 152 | ), 153 | ), 154 | ), 155 | ); 156 | } 157 | 158 | Future getImage({required bool binary}) async { 159 | final prefs = await SharedPreferences.getInstance(); 160 | await prefs.setBool('binary', binary); 161 | 162 | var image = await imagePicker.pickImage(source: ImageSource.gallery); 163 | 164 | if (image != null) { 165 | _handleFileUpload([image.path]); 166 | } 167 | } 168 | 169 | Future getVideo({required bool binary}) async { 170 | final prefs = await SharedPreferences.getInstance(); 171 | await prefs.setBool('binary', binary); 172 | 173 | var video = await imagePicker.pickVideo(source: ImageSource.gallery); 174 | 175 | if (video != null) { 176 | _handleFileUpload([video.path]); 177 | } 178 | } 179 | 180 | Future getMultiple({required bool binary}) async { 181 | final prefs = await SharedPreferences.getInstance(); 182 | await prefs.setBool('binary', binary); 183 | 184 | final files = await FilePicker.platform.pickFiles( 185 | allowCompression: false, 186 | allowMultiple: true, 187 | ); 188 | if (files != null && files.count > 0) { 189 | if (binary) { 190 | for (var file in files.files) { 191 | _handleFileUpload([file.path]); 192 | } 193 | } else { 194 | _handleFileUpload(files.paths); 195 | } 196 | } 197 | } 198 | 199 | void _handleFileUpload(List paths) async { 200 | final prefs = await SharedPreferences.getInstance(); 201 | final binary = prefs.getBool('binary') ?? false; 202 | final allowCellular = prefs.getBool('allowCellular') ?? true; 203 | 204 | await widget.uploader.enqueue(_buildUpload( 205 | binary, 206 | paths.whereType().toList(), 207 | allowCellular, 208 | )); 209 | 210 | widget.onUploadStarted(); 211 | } 212 | 213 | Upload _buildUpload(bool binary, List paths, 214 | [bool allowCellular = true]) { 215 | const tag = 'upload'; 216 | 217 | var url = binary 218 | ? widget.uploadURL.replace(path: widget.uploadURL.path + 'Binary') 219 | : widget.uploadURL; 220 | 221 | url = url.replace(queryParameters: { 222 | 'simulate': _serverBehavior.name, 223 | }); 224 | 225 | if (binary) { 226 | return RawUpload( 227 | url: url.toString(), 228 | path: paths.first, 229 | method: UploadMethod.POST, 230 | tag: tag, 231 | allowCellular: allowCellular, 232 | ); 233 | } else { 234 | return MultipartFormDataUpload( 235 | url: url.toString(), 236 | data: {'name': 'john'}, 237 | files: paths.map((e) => FileItem(path: e, field: 'file')).toList(), 238 | method: UploadMethod.POST, 239 | tag: tag, 240 | allowCellular: allowCellular, 241 | ); 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: flutter_uploader_example 2 | version: 3.0.0-beta.3 3 | description: Demonstrates how to use the flutter_uploader plugin. 4 | publish_to: "none" 5 | 6 | environment: 7 | sdk: '>=2.12.0 <3.0.0' 8 | 9 | dependencies: 10 | flutter: 11 | sdk: flutter 12 | file_picker: ^4.3.3 13 | path_provider: ^2.0.8 14 | image_picker: ^0.8.4+6 15 | shared_preferences: ^2.0.5 16 | flutter_local_notifications: ^9.2.0 17 | 18 | dev_dependencies: 19 | flutter_lints: ^1.0.4 20 | integration_test: 21 | sdk: flutter 22 | flutter_test: 23 | sdk: flutter 24 | 25 | flutter_uploader: 26 | path: ../ 27 | 28 | flutter: 29 | uses-material-design: true 30 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vagrant/ 3 | .sconsign.dblite 4 | .svn/ 5 | 6 | .DS_Store 7 | *.swp 8 | profile 9 | 10 | DerivedData/ 11 | build/ 12 | GeneratedPluginRegistrant.h 13 | GeneratedPluginRegistrant.m 14 | 15 | .generated/ 16 | 17 | *.pbxuser 18 | *.mode1v3 19 | *.mode2v3 20 | *.perspectivev3 21 | 22 | !default.pbxuser 23 | !default.mode1v3 24 | !default.mode2v3 25 | !default.perspectivev3 26 | 27 | xcuserdata 28 | 29 | *.moved-aside 30 | 31 | *.pyc 32 | *sync/ 33 | Icon? 34 | .tags* 35 | 36 | /Flutter/Generated.xcconfig 37 | -------------------------------------------------------------------------------- /ios/Assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluttercommunity/flutter_uploader/d418e275ec4496fb34d18f8cd4d33cb902644537/ios/Assets/.gitkeep -------------------------------------------------------------------------------- /ios/Classes/CachingStreamHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SimpleStreamHandler.swift 3 | // flutter_uploader 4 | // 5 | // Created by Sebastian Roth on 21/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | class CachingStreamHandler: NSObject, FlutterStreamHandler { 11 | var cache: [String: T] = [:] 12 | 13 | var eventSink: FlutterEventSink? 14 | 15 | private let cacheSemaphore = DispatchSemaphore(value: 1) 16 | 17 | func add(_ taskId: String, _ value: T) { 18 | cacheSemaphore.wait() 19 | cache[taskId] = value 20 | cacheSemaphore.signal() 21 | 22 | if let sink = eventSink { 23 | sink(value) 24 | } 25 | } 26 | 27 | func clear() { 28 | cacheSemaphore.wait() 29 | cache.removeAll() 30 | cacheSemaphore.signal() 31 | } 32 | 33 | func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { 34 | cacheSemaphore.wait() 35 | for cacheEntry in cache { 36 | events(cacheEntry.value) 37 | } 38 | cacheSemaphore.signal() 39 | 40 | self.eventSink = events 41 | 42 | return nil 43 | } 44 | 45 | func onCancel(withArguments arguments: Any?) -> FlutterError? { 46 | self.eventSink = nil 47 | 48 | return nil 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /ios/Classes/EngineManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EngineManager.swift 3 | // flutter_uploader 4 | // 5 | // Created by Sebastian Roth on 21/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | class EngineManager { 11 | private var headlessRunner: FlutterEngine? 12 | public var registerPlugins: FlutterPluginRegistrantCallback? 13 | 14 | private let semaphore = DispatchSemaphore(value: 1) 15 | 16 | private func startEngineIfNeeded() { 17 | semaphore.wait() 18 | 19 | defer { 20 | semaphore.signal() 21 | } 22 | 23 | guard let callbackHandle = UploaderDefaults.shared.callbackHandle else { 24 | if let runner = headlessRunner { 25 | runner.destroyContext() 26 | headlessRunner = nil 27 | } 28 | return 29 | } 30 | 31 | // Already started 32 | if headlessRunner != nil { 33 | return 34 | } 35 | 36 | headlessRunner = FlutterEngine(name: "FlutterUploaderIsolate", project: nil, allowHeadlessExecution: true) 37 | 38 | guard let info = FlutterCallbackCache.lookupCallbackInformation(Int64(callbackHandle)) else { 39 | fatalError("Can not find callback") 40 | } 41 | 42 | let entryPoint = info.callbackName 43 | let uri = info.callbackLibraryPath 44 | 45 | DispatchQueue.main.async { 46 | self.headlessRunner?.run(withEntrypoint: entryPoint, libraryURI: uri) 47 | if let registerPlugins = SwiftFlutterUploaderPlugin.registerPlugins, let runner = self.headlessRunner { 48 | registerPlugins(runner) 49 | } else { 50 | self.headlessRunner = nil 51 | } 52 | } 53 | } 54 | } 55 | 56 | extension EngineManager: UploaderDelegate { 57 | func uploadEnqueued(taskId: String) { 58 | } 59 | 60 | func uploadProgressed(taskId: String, inStatus: UploadTaskStatus, progress: Int) { 61 | startEngineIfNeeded() 62 | } 63 | 64 | func uploadCompleted(taskId: String, message: String?, statusCode: Int, headers: [String: Any]) { 65 | startEngineIfNeeded() 66 | } 67 | 68 | func uploadFailed(taskId: String, inStatus: UploadTaskStatus, statusCode: Int, errorCode: String, errorMessage: String?, errorStackTrace: [String]) { 69 | startEngineIfNeeded() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /ios/Classes/FlutterUploaderPlugin.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface FlutterUploaderPlugin : NSObject 4 | @end 5 | -------------------------------------------------------------------------------- /ios/Classes/FlutterUploaderPlugin.m: -------------------------------------------------------------------------------- 1 | #import "FlutterUploaderPlugin.h" 2 | 3 | #if __has_include() 4 | #import 5 | #else 6 | #import "flutter_uploader-Swift.h" 7 | #endif 8 | 9 | @implementation FlutterUploaderPlugin 10 | + (void)registerWithRegistrar:(NSObject*)registrar { 11 | [SwiftFlutterUploaderPlugin registerWithRegistrar:registrar]; 12 | } 13 | @end 14 | -------------------------------------------------------------------------------- /ios/Classes/Key.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Keys.swift 3 | // flutter_uploader 4 | // 5 | // Created by Sebastian Roth on 23/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Key { 11 | static let taskId = "taskId" 12 | static let status = "status" 13 | static let progress = "progress" 14 | static let fieldname = "fieldname" 15 | static let path = "path" 16 | static let message = "message" 17 | static let statusCode = "statusCode" 18 | static let headers = "headers" 19 | static let code = "code" 20 | static let details = "details" 21 | } 22 | -------------------------------------------------------------------------------- /ios/Classes/MimeType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let defaultMimeType = "application/octet-stream" 4 | let mimeTypes = [ 5 | "html": "text/html", 6 | "htm": "text/html", 7 | "shtml": "text/html", 8 | "css": "text/css", 9 | "xml": "text/xml", 10 | "gif": "image/gif", 11 | "jpeg": "image/jpeg", 12 | "jpg": "image/jpeg", 13 | "js": "application/javascript", 14 | "atom": "application/atom+xml", 15 | "rss": "application/rss+xml", 16 | "mml": "text/mathml", 17 | "txt": "text/plain", 18 | "jad": "text/vnd.sun.j2me.app-descriptor", 19 | "wml": "text/vnd.wap.wml", 20 | "htc": "text/x-component", 21 | "png": "image/png", 22 | "tif": "image/tiff", 23 | "tiff": "image/tiff", 24 | "wbmp": "image/vnd.wap.wbmp", 25 | "ico": "image/x-icon", 26 | "jng": "image/x-jng", 27 | "bmp": "image/x-ms-bmp", 28 | "svg": "image/svg+xml", 29 | "svgz": "image/svg+xml", 30 | "webp": "image/webp", 31 | "woff": "application/font-woff", 32 | "jar": "application/java-archive", 33 | "war": "application/java-archive", 34 | "ear": "application/java-archive", 35 | "json": "application/json", 36 | "hqx": "application/mac-binhex40", 37 | "doc": "application/msword", 38 | "pdf": "application/pdf", 39 | "ps": "application/postscript", 40 | "eps": "application/postscript", 41 | "ai": "application/postscript", 42 | "rtf": "application/rtf", 43 | "m3u8": "application/vnd.apple.mpegurl", 44 | "xls": "application/vnd.ms-excel", 45 | "eot": "application/vnd.ms-fontobject", 46 | "ppt": "application/vnd.ms-powerpoint", 47 | "wmlc": "application/vnd.wap.wmlc", 48 | "kml": "application/vnd.google-earth.kml+xml", 49 | "kmz": "application/vnd.google-earth.kmz", 50 | "7z": "application/x-7z-compressed", 51 | "cco": "application/x-cocoa", 52 | "jardiff": "application/x-java-archive-diff", 53 | "jnlp": "application/x-java-jnlp-file", 54 | "run": "application/x-makeself", 55 | "pl": "application/x-perl", 56 | "pm": "application/x-perl", 57 | "prc": "application/x-pilot", 58 | "pdb": "application/x-pilot", 59 | "rar": "application/x-rar-compressed", 60 | "rpm": "application/x-redhat-package-manager", 61 | "sea": "application/x-sea", 62 | "swf": "application/x-shockwave-flash", 63 | "sit": "application/x-stuffit", 64 | "tcl": "application/x-tcl", 65 | "tk": "application/x-tcl", 66 | "der": "application/x-x509-ca-cert", 67 | "pem": "application/x-x509-ca-cert", 68 | "crt": "application/x-x509-ca-cert", 69 | "xpi": "application/x-xpinstall", 70 | "xhtml": "application/xhtml+xml", 71 | "xspf": "application/xspf+xml", 72 | "zip": "application/zip", 73 | "bin": "application/octet-stream", 74 | "exe": "application/octet-stream", 75 | "dll": "application/octet-stream", 76 | "deb": "application/octet-stream", 77 | "dmg": "application/octet-stream", 78 | "iso": "application/octet-stream", 79 | "img": "application/octet-stream", 80 | "msi": "application/octet-stream", 81 | "msp": "application/octet-stream", 82 | "msm": "application/octet-stream", 83 | "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 84 | "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 85 | "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", 86 | "mid": "audio/midi", 87 | "midi": "audio/midi", 88 | "kar": "audio/midi", 89 | "mp3": "audio/mpeg", 90 | "ogg": "audio/ogg", 91 | "m4a": "audio/x-m4a", 92 | "ra": "audio/x-realaudio", 93 | "3gpp": "video/3gpp", 94 | "3gp": "video/3gpp", 95 | "ts": "video/mp2t", 96 | "mp4": "video/mp4", 97 | "mpeg": "video/mpeg", 98 | "mpg": "video/mpeg", 99 | "mov": "video/quicktime", 100 | "webm": "video/webm", 101 | "flv": "video/x-flv", 102 | "m4v": "video/x-m4v", 103 | "mng": "video/x-mng", 104 | "asx": "video/x-ms-asf", 105 | "asf": "video/x-ms-asf", 106 | "wmv": "video/x-ms-wmv", 107 | "avi": "video/x-msvideo" 108 | ] 109 | 110 | public struct MimeType { 111 | let ext: String? 112 | public var value: String { 113 | guard let fileExtension: String = ext else { 114 | return defaultMimeType 115 | } 116 | 117 | let fext = fileExtension.lowercased() 118 | return mimeTypes.keys.contains(fext) ? mimeTypes[fext]! : defaultMimeType 119 | } 120 | 121 | public init(path: String) { 122 | ext = NSString(string: path).pathExtension 123 | } 124 | 125 | public init(path: NSString) { 126 | ext = path.pathExtension 127 | } 128 | 129 | public init(url: URL) { 130 | ext = url.pathExtension 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /ios/Classes/URLSessionTask.State+statusText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSessionTask.State+statusText.swift 3 | // flutter_uploader 4 | // 5 | // Created by Sebastian Roth on 21/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URLSessionTask.State { 11 | func statusText() -> String { 12 | switch self { 13 | case .running: 14 | return "running" 15 | case .canceling: 16 | return "canceling" 17 | case .completed: 18 | return "completed" 19 | case .suspended: 20 | return "suspended" 21 | default: 22 | return "unknown" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ios/Classes/URLSessionUploader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSessionHolder.swift 3 | // flutter_uploader 4 | // 5 | // Created by Sebastian Roth on 21/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Keys { 11 | static let backgroundSessionIdentifier = "chillisource.flutter_uploader.upload.background" 12 | static let wifiBackgroundSessionIdentifier = "chillisource.flutter_uploader.upload.background.wifi" 13 | fileprivate static let maximumConcurrentTask = "FUMaximumConnectionsPerHost" 14 | fileprivate static let maximumConcurrentUploadOperation = "FUMaximumUploadOperation" 15 | 16 | /// In seconds 17 | fileprivate static let timeout = "FUTimeoutInSeconds" 18 | } 19 | 20 | class URLSessionUploader: NSObject { 21 | static let shared = URLSessionUploader() 22 | 23 | var session: URLSession? 24 | var wifiSession: URLSession? 25 | let queue = OperationQueue() 26 | 27 | // Accessing uploadedData & runningTaskById will require exclusive access 28 | private let semaphore = DispatchSemaphore(value: 1) 29 | 30 | // Reference for uploaded data. 31 | var uploadedData = [String: Data]() 32 | 33 | // Reference for currently running tasks. 34 | var runningTaskById = [String: UploadTask]() 35 | 36 | private var delegates: [UploaderDelegate] = [] 37 | 38 | /// See the discussion on 39 | /// [application:handleEventsForBackgroundURLSession:completionHandler:](https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622941-application?language=objc) 40 | public var backgroundTransferCompletionHander: (() -> Void)? 41 | 42 | // MARK: Public API 43 | 44 | func addDelegate(_ delegate: UploaderDelegate) { 45 | delegates.append(delegate) 46 | } 47 | 48 | func enqueueUploadTask(_ request: URLRequest, path: String, wifiOnly: Bool) -> URLSessionUploadTask? { 49 | guard let session = self.session, 50 | let wifiSession = self.wifiSession else { 51 | return nil 52 | } 53 | 54 | let activeSession = wifiOnly ? wifiSession : session 55 | let uploadTask = activeSession.uploadTask( 56 | with: request as URLRequest, 57 | fromFile: URL(fileURLWithPath: path) 58 | ) 59 | 60 | // Create a random UUID as task description (& ID). 61 | uploadTask.taskDescription = UUID().uuidString 62 | 63 | let taskId = identifierForTask(uploadTask) 64 | 65 | delegates.uploadEnqueued(taskId: taskId) 66 | 67 | uploadTask.resume() 68 | 69 | semaphore.wait() 70 | self.runningTaskById[taskId] = UploadTask(taskId: taskId, status: .enqueue, progress: 0) 71 | semaphore.signal() 72 | 73 | return uploadTask 74 | } 75 | 76 | /// 77 | /// The description on URLSessionTask.taskIdentifier explains how the task is only unique within a session. 78 | public func identifierForTask(_ task: URLSessionUploadTask) -> String { 79 | return "\(self.session?.configuration.identifier ?? "chillisoure.flutter_uploader").\(task.taskDescription!)" 80 | } 81 | 82 | /// Cancel a task by ID. Complete with `true`/`false` depending on whether the task was running. 83 | func cancelWithTaskId(_ taskId: String) { 84 | guard let session = session else { 85 | return 86 | } 87 | 88 | session.getTasksWithCompletionHandler { (_, uploadTasks, _) in 89 | for uploadTask in uploadTasks { 90 | let state = uploadTask.state 91 | if self.identifierForTask(uploadTask) == taskId && state == .running { 92 | self.delegates.uploadProgressed(taskId: taskId, inStatus: .canceled, progress: -1) 93 | 94 | uploadTask.cancel() 95 | return 96 | } 97 | } 98 | } 99 | } 100 | 101 | /// Cancel all running tasks & return the list of canceled tasks. 102 | func cancelAllTasks() { 103 | session?.getTasksWithCompletionHandler { (_, uploadTasks, _) in 104 | for uploadTask in uploadTasks { 105 | let state = uploadTask.state 106 | let taskId = self.identifierForTask(uploadTask) 107 | if state == .running { 108 | self.delegates.uploadProgressed(taskId: taskId, inStatus: .canceled, progress: -1) 109 | 110 | uploadTask.cancel() 111 | } 112 | } 113 | } 114 | } 115 | 116 | // MARK: Private API 117 | 118 | private override init() { 119 | super.init() 120 | 121 | delegates.append(EngineManager()) 122 | 123 | delegates.append(UploadResultDatabase.shared) 124 | 125 | self.queue.name = "chillisource.flutter_uploader.queue" 126 | 127 | let mainBundle = Bundle.main 128 | var maxConcurrentTasks: NSNumber 129 | if let concurrentTasks = mainBundle.object(forInfoDictionaryKey: Keys.maximumConcurrentTask) as? NSNumber { 130 | maxConcurrentTasks = concurrentTasks 131 | } else { 132 | maxConcurrentTasks = NSNumber(value: 3) 133 | } 134 | 135 | NSLog("MAXIMUM_CONCURRENT_TASKS = \(maxConcurrentTasks)") 136 | 137 | var maxUploadOperation: NSNumber 138 | if let operationTask = mainBundle.object(forInfoDictionaryKey: Keys.maximumConcurrentUploadOperation) as? NSNumber { 139 | maxUploadOperation = operationTask 140 | } else { 141 | maxUploadOperation = NSNumber(value: 2) 142 | } 143 | 144 | NSLog("MAXIMUM_CONCURRENT_UPLOAD_OPERATION = \(maxUploadOperation)") 145 | 146 | self.queue.maxConcurrentOperationCount = maxUploadOperation.intValue 147 | 148 | // configure session for wifi only uploads 149 | let wifiConfiguration = URLSessionConfiguration.background(withIdentifier: Keys.wifiBackgroundSessionIdentifier) 150 | wifiConfiguration.httpMaximumConnectionsPerHost = maxConcurrentTasks.intValue 151 | wifiConfiguration.timeoutIntervalForRequest = URLSessionUploader.determineTimeout() 152 | wifiConfiguration.allowsCellularAccess = false 153 | self.wifiSession = URLSession(configuration: wifiConfiguration, delegate: self, delegateQueue: queue) 154 | 155 | // configure regular session 156 | let sessionConfiguration = URLSessionConfiguration.background(withIdentifier: Keys.backgroundSessionIdentifier) 157 | sessionConfiguration.httpMaximumConnectionsPerHost = maxConcurrentTasks.intValue 158 | sessionConfiguration.timeoutIntervalForRequest = URLSessionUploader.determineTimeout() 159 | self.session = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: queue) 160 | } 161 | 162 | private static func determineTimeout() -> Double { 163 | if let timeoutSetting = Bundle.main.object(forInfoDictionaryKey: Keys.timeout) as? NSNumber { 164 | return timeoutSetting.doubleValue 165 | } else { 166 | return SwiftFlutterUploaderPlugin.defaultTimeout 167 | } 168 | } 169 | } 170 | 171 | extension URLSessionUploader: URLSessionDelegate, URLSessionDataDelegate, URLSessionTaskDelegate { 172 | func urlSession(_ session: URLSession, 173 | dataTask: URLSessionDataTask, 174 | didReceive response: URLResponse, 175 | completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { 176 | completionHandler(.allow) 177 | } 178 | 179 | func urlSession(_ session: URLSession, 180 | task: URLSessionTask, 181 | didReceive challenge: URLAuthenticationChallenge, 182 | completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { 183 | completionHandler(.performDefaultHandling, nil) 184 | } 185 | 186 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { 187 | semaphore.wait() 188 | defer { 189 | semaphore.signal() 190 | } 191 | 192 | NSLog("URLSessionDidReceiveData:") 193 | 194 | guard let uploadTask = dataTask as? URLSessionUploadTask else { 195 | NSLog("URLSessionDidReceiveData: not an uplaod task") 196 | return 197 | } 198 | 199 | if data.count > 0 { 200 | let taskId = identifierForTask(uploadTask) 201 | if var existing = uploadedData[taskId] { 202 | existing.append(data) 203 | } else { 204 | uploadedData[taskId] = Data(data) 205 | } 206 | } 207 | } 208 | 209 | func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { 210 | NSLog("URLSessionDidBecomeInvalidWithError:") 211 | } 212 | 213 | func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) { 214 | NSLog("URLSessionTaskIsWaitingForConnectivity:") 215 | } 216 | 217 | public func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { 218 | semaphore.wait() 219 | defer { 220 | semaphore.signal() 221 | } 222 | 223 | if totalBytesExpectedToSend == NSURLSessionTransferSizeUnknown { 224 | NSLog("Unknown transfer size") 225 | } else { 226 | guard let uploadTask = task as? URLSessionUploadTask else { 227 | NSLog("URLSessionDidSendBodyData: an not uplaod task") 228 | return 229 | } 230 | 231 | let taskId = identifierForTask(uploadTask) 232 | let bytesExpectedToSend = Double(totalBytesExpectedToSend) 233 | let tBytesSent = Double(totalBytesSent) 234 | let progress = round(Double(tBytesSent / bytesExpectedToSend * 100)) 235 | 236 | let runningTask = self.runningTaskById[taskId] 237 | NSLog("URLSessionDidSendBodyData: \(taskId), byteSent: \(bytesSent), totalBytesSent: \(totalBytesSent), totalBytesExpectedToSend: \(totalBytesExpectedToSend), progress:\(progress)") 238 | 239 | if runningTask != nil { 240 | let isRunning: (Int, Int, Int) -> Bool = { (current, previous, step) in 241 | let prev = previous + step 242 | return (current == 0 || current > prev || current >= 100) && current != previous 243 | } 244 | 245 | if isRunning(Int(progress), runningTask!.progress, SwiftFlutterUploaderPlugin.stepUpdate) { 246 | self.delegates.uploadProgressed(taskId: taskId, inStatus: .running, progress: Int(progress)) 247 | self.runningTaskById[taskId] = UploadTask(taskId: taskId, status: .running, progress: Int(progress), tag: runningTask?.tag) 248 | } 249 | } 250 | } 251 | } 252 | 253 | public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { 254 | NSLog("URLSessionDidFinishEvents:") 255 | 256 | session.getTasksWithCompletionHandler { (_, uploadTasks, _) in 257 | self.semaphore.wait() 258 | defer { 259 | self.semaphore.signal() 260 | } 261 | 262 | if uploadTasks.isEmpty { 263 | NSLog("all upload tasks have been completed") 264 | 265 | self.backgroundTransferCompletionHander?() 266 | self.backgroundTransferCompletionHander = nil 267 | } 268 | } 269 | } 270 | 271 | public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 272 | semaphore.wait() 273 | defer { 274 | semaphore.signal() 275 | } 276 | 277 | guard let uploadTask = task as? URLSessionUploadTask else { 278 | NSLog("URLSessionDidCompleteWithError: not an uplaod task") 279 | return 280 | } 281 | 282 | let taskId = identifierForTask(uploadTask) 283 | 284 | if error != nil { 285 | NSLog("URLSessionDidCompleteWithError: \(taskId) failed with \(error!.localizedDescription)") 286 | var uploadStatus: UploadTaskStatus = .failed 287 | switch error! { 288 | case URLError.cancelled: 289 | uploadStatus = .canceled 290 | default: 291 | uploadStatus = .failed 292 | } 293 | 294 | self.delegates.uploadFailed(taskId: taskId, 295 | inStatus: uploadStatus, 296 | statusCode: 500, 297 | errorCode: "upload_error", 298 | errorMessage: error?.localizedDescription ?? "", 299 | errorStackTrace: Thread.callStackSymbols) 300 | 301 | self.runningTaskById.removeValue(forKey: taskId) 302 | self.uploadedData.removeValue(forKey: taskId) 303 | return 304 | } 305 | 306 | var hasResponseError = false 307 | var response: HTTPURLResponse? 308 | var statusCode = 500 309 | 310 | if task.response is HTTPURLResponse { 311 | response = task.response as? HTTPURLResponse 312 | 313 | if response != nil { 314 | NSLog("URLSessionDidCompleteWithError: \(taskId) with response: \(response!) and status: \(response!.statusCode)") 315 | statusCode = response!.statusCode 316 | hasResponseError = !isRequestSuccessful(response!.statusCode) 317 | } 318 | } 319 | 320 | NSLog("URLSessionDidCompleteWithError: upload completed") 321 | 322 | let headers = response?.allHeaderFields 323 | var responseHeaders = [String: Any]() 324 | if headers != nil { 325 | headers!.forEach { (key, value) in 326 | if let key = key as? String { 327 | responseHeaders[key] = value 328 | } 329 | } 330 | } 331 | 332 | let message: String? 333 | if let data = uploadedData[taskId] { 334 | message = String(data: data, encoding: String.Encoding.utf8) 335 | } else { 336 | message = nil 337 | } 338 | 339 | let statusText = uploadTask.state.statusText() 340 | if error == nil && !hasResponseError { 341 | NSLog("URLSessionDidCompleteWithError: response: \(message ?? "null"), task: \(statusText)") 342 | self.delegates.uploadCompleted(taskId: taskId, message: message, statusCode: response?.statusCode ?? 200, headers: responseHeaders) 343 | } else if hasResponseError { 344 | NSLog("URLSessionDidCompleteWithError: task: \(statusText) statusCode: \(response?.statusCode ?? -1), error:\(message ?? "null"), response:\(String(describing: response))") 345 | self.delegates.uploadFailed(taskId: taskId, inStatus: .failed, statusCode: statusCode, errorCode: "upload_error", errorMessage: message, errorStackTrace: Thread.callStackSymbols) 346 | } else { 347 | NSLog("URLSessionDidCompleteWithError: task: \(statusText) statusCode: \(response?.statusCode ?? -1), error:\(error?.localizedDescription ?? "none")") 348 | delegates.uploadFailed( 349 | taskId: taskId, 350 | inStatus: .failed, 351 | statusCode: statusCode, 352 | errorCode: "upload_error", 353 | errorMessage: error?.localizedDescription ?? "", 354 | errorStackTrace: Thread.callStackSymbols 355 | ) 356 | } 357 | 358 | self.uploadedData.removeValue(forKey: taskId) 359 | self.runningTaskById.removeValue(forKey: taskId) 360 | } 361 | 362 | private func isRequestSuccessful(_ statusCode: Int) -> Bool { 363 | return statusCode >= 200 && statusCode <= 299 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /ios/Classes/UploadFileInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploadFileInfo.swift 3 | // flutter_uploader 4 | // 5 | // Created by Sebastian Roth on 21/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | struct UploadFileInfo { 11 | let fieldname: String 12 | let path: String 13 | let mimeType: String 14 | 15 | init(fieldname: String, path: String) { 16 | self.fieldname = fieldname 17 | self.path = path 18 | let mime = MimeType(url: URL(fileURLWithPath: path)) 19 | self.mimeType = mime.value 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ios/Classes/UploadResultDatabase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploadResultDatabase.swift 3 | // flutter_uploader 4 | // 5 | // Created by Sebastian Roth on 21/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A helper class which stores the upload results for later retrieval by the plugin. 11 | /// This mimics the behavior of the workmanager LiveData on Android, which allows a limited retrieval of completed work. 12 | class UploadResultDatabase: UploaderDelegate { 13 | static let shared = UploadResultDatabase() 14 | 15 | private init() { 16 | if let url = resultsPListURL, let plist = try? loadPropertyList(url) { 17 | for result in plist { 18 | if let map = result as? [String: Any] { 19 | self.results.append(map) 20 | } 21 | } 22 | } 23 | } 24 | 25 | public func clear() { 26 | results.removeAll() 27 | 28 | guard let url = resultsPListURL else { return } 29 | 30 | do { 31 | try savePropertyList(url, []) 32 | } catch { 33 | print("error write \(error)") 34 | } 35 | } 36 | 37 | var results: [[String: Any]] = [] 38 | 39 | func uploadEnqueued(taskId: String) { 40 | results.append([ 41 | Key.taskId: taskId, 42 | Key.status: UploadTaskStatus.enqueue.rawValue 43 | ]) 44 | 45 | guard let url = resultsPListURL else { return } 46 | 47 | do { 48 | try savePropertyList(url, results) 49 | } catch { 50 | print("error write \(error)") 51 | } 52 | } 53 | 54 | func uploadProgressed(taskId: String, inStatus: UploadTaskStatus, progress: Int) { 55 | // No need to store in-flight. 56 | } 57 | 58 | func uploadCompleted(taskId: String, message: String?, statusCode: Int, headers: [String: Any]) { 59 | results.append([ 60 | Key.taskId: taskId, 61 | Key.status: UploadTaskStatus.completed.rawValue, 62 | Key.message: message ?? "", 63 | Key.statusCode: statusCode, 64 | Key.headers: headers 65 | ]) 66 | 67 | guard let url = resultsPListURL else { return } 68 | 69 | do { 70 | try savePropertyList(url, results) 71 | } catch { 72 | print("error write \(error)") 73 | } 74 | } 75 | 76 | func uploadFailed(taskId: String, inStatus: UploadTaskStatus, statusCode: Int, errorCode: String, errorMessage: String?, errorStackTrace: [String]) { 77 | results.append([ 78 | Key.taskId: taskId, 79 | Key.status: inStatus.rawValue, 80 | Key.statusCode: statusCode, 81 | Key.code: errorCode, 82 | Key.message: errorMessage ?? NSNull(), 83 | Key.details: errorStackTrace 84 | ]) 85 | 86 | guard let url = resultsPListURL else { return } 87 | 88 | do { 89 | try savePropertyList(url, results) 90 | } catch { 91 | print("error write \(error)") 92 | } 93 | } 94 | 95 | private func savePropertyList(_ plistURL: URL, _ plist: Any) throws { 96 | let plistData = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) 97 | try plistData.write(to: plistURL) 98 | } 99 | 100 | private func loadPropertyList(_ plistURL: URL) throws -> [Any] { 101 | let data = try Data(contentsOf: plistURL) 102 | guard let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as? [Any] else { 103 | return [] 104 | } 105 | return plist 106 | } 107 | 108 | private var resultsPListURL: URL? { 109 | let documentDirectoryURL = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) 110 | return documentDirectoryURL?.appendingPathComponent("flutter_uploader-results.plist") 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /ios/Classes/UploadTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploadTask.swift 3 | // flutter_uploader 4 | // 5 | // Created by Sebastian Roth on 21/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | enum UploadTaskStatus: Int { 11 | case undefined = 0, enqueue, running, completed, failed, canceled, paused 12 | } 13 | 14 | struct UploadTask { 15 | let taskId: String 16 | let status: UploadTaskStatus 17 | let progress: Int 18 | let tag: String? 19 | let allowCellular: Bool 20 | 21 | init(taskId: String, status: UploadTaskStatus, progress: Int, tag: String? = nil, allowCellular: Bool = true) { 22 | self.taskId = taskId 23 | self.status = status 24 | self.progress = progress 25 | self.tag = tag 26 | self.allowCellular = allowCellular 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ios/Classes/UploaderDefaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploaderDefaults.swift 3 | // flutter_uploader 4 | // 5 | // Created by Sebastian Roth on 21/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | class UploaderDefaults: NSObject { 11 | static let shared = UploaderDefaults() 12 | 13 | private static let prefCallbackHandle = "flutter_uploader.callbackHandle" 14 | 15 | var callbackHandle: Int? { 16 | get { 17 | if UserDefaults.standard.value(forKey: UploaderDefaults.prefCallbackHandle) != nil { 18 | return UserDefaults.standard.integer(forKey: UploaderDefaults.prefCallbackHandle) 19 | } else { 20 | return nil 21 | } 22 | } 23 | set { 24 | if let value = newValue { 25 | UserDefaults.standard.set(value, forKey: UploaderDefaults.prefCallbackHandle) 26 | } else { 27 | UserDefaults.standard.removeObject(forKey: UploaderDefaults.prefCallbackHandle) 28 | } 29 | } 30 | } 31 | 32 | private override init() { 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ios/Classes/UploaderDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistentUploader.swift 3 | // flutter_uploader 4 | // 5 | // Created by Sebastian Roth on 21/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol UploaderDelegate { 11 | func uploadEnqueued(taskId: String) 12 | 13 | func uploadProgressed(taskId: String, inStatus: UploadTaskStatus, progress: Int) 14 | 15 | func uploadCompleted(taskId: String, message: String?, statusCode: Int, headers: [String: Any]) 16 | 17 | func uploadFailed(taskId: String, inStatus: UploadTaskStatus, statusCode: Int, errorCode: String, errorMessage: String?, errorStackTrace: [String]) 18 | } 19 | 20 | extension Array: UploaderDelegate where Element == UploaderDelegate { 21 | func uploadEnqueued(taskId: String) { 22 | forEach { (delegate) in 23 | delegate.uploadEnqueued(taskId: taskId) 24 | } 25 | } 26 | 27 | func uploadProgressed(taskId: String, inStatus: UploadTaskStatus, progress: Int) { 28 | forEach { (delegate) in 29 | delegate.uploadProgressed(taskId: taskId, inStatus: inStatus, progress: progress) 30 | } 31 | } 32 | 33 | func uploadCompleted(taskId: String, message: String?, statusCode: Int, headers: [String: Any]) { 34 | forEach { (delegate) in 35 | delegate.uploadCompleted(taskId: taskId, message: message, statusCode: statusCode, headers: headers) 36 | } 37 | } 38 | 39 | func uploadFailed(taskId: String, inStatus: UploadTaskStatus, statusCode: Int, errorCode: String, errorMessage: String?, errorStackTrace: [String]) { 40 | forEach { (delegate) in 41 | delegate.uploadFailed(taskId: taskId, inStatus: inStatus, statusCode: statusCode, errorCode: errorCode, errorMessage: errorMessage, errorStackTrace: errorStackTrace) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ios/flutter_uploader.podspec: -------------------------------------------------------------------------------- 1 | # 2 | # To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html 3 | # 4 | Pod::Spec.new do |s| 5 | s.name = 'flutter_uploader' 6 | s.version = '1.2.0' 7 | s.summary = 'background upload plugin for flutter' 8 | s.description = <<-DESC 9 | A new flutter plugin project. 10 | DESC 11 | s.homepage = 'http://example.com' 12 | s.license = { :file => '../LICENSE' } 13 | s.author = { 'Your Company' => 'email@example.com' } 14 | s.source = { :path => '.' } 15 | s.source_files = 'Classes/**/*' 16 | s.public_header_files = 'Classes/**/*.h' 17 | s.dependency 'Flutter' 18 | s.dependency 'Alamofire', '5.2.2' 19 | s.ios.deployment_target = '10.0' 20 | s.swift_version = '5.2' 21 | s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } 22 | end 23 | 24 | -------------------------------------------------------------------------------- /lib/flutter_uploader.dart: -------------------------------------------------------------------------------- 1 | library flutter_uploader; 2 | 3 | import 'dart:async'; 4 | import 'dart:ui' show PluginUtilities; 5 | 6 | import 'package:equatable/equatable.dart'; 7 | import 'package:flutter/foundation.dart'; 8 | import 'package:flutter/services.dart'; 9 | 10 | part 'src/file_item.dart'; 11 | 12 | part 'src/flutter_uploader.dart'; 13 | 14 | part 'src/upload.dart'; 15 | 16 | part 'src/upload_method.dart'; 17 | 18 | part 'src/upload_task_progress.dart'; 19 | 20 | part 'src/upload_task_response.dart'; 21 | 22 | part 'src/upload_task_status.dart'; 23 | -------------------------------------------------------------------------------- /lib/src/file_item.dart: -------------------------------------------------------------------------------- 1 | part of flutter_uploader; 2 | 3 | /// Represents a single file in a multipart/form-data upload 4 | class FileItem { 5 | /// Path to the local file. It is the developers reponsibility to ensure 6 | /// the path can be accessed. 7 | final String path; 8 | 9 | /// The field name will be used during HTTP multipart/form-data uploads. 10 | /// It is ignored for binary file uploads. 11 | final String field; 12 | 13 | /// Default constructor. The [field] property is set to `file` by default. 14 | FileItem({ 15 | required this.path, 16 | this.field = 'file', 17 | }); 18 | 19 | @override 20 | String toString() => 'FileItem(path: $path fieldname:$field)'; 21 | 22 | /// JSON representation for sharing with the underlying platform. 23 | Map toJson() => {'path': path, 'fieldname': field}; 24 | } 25 | -------------------------------------------------------------------------------- /lib/src/flutter_uploader.dart: -------------------------------------------------------------------------------- 1 | part of flutter_uploader; 2 | 3 | /// Controls task scheduling and allows developers to observe the status. 4 | /// The class is designed as a singleton and can therefore be instantiated as 5 | /// often as needed. 6 | class FlutterUploader { 7 | final MethodChannel _platform; 8 | final EventChannel _progressChannel; 9 | final EventChannel _resultChannel; 10 | 11 | Stream? _progressStream; 12 | Stream? _resultStream; 13 | 14 | static FlutterUploader? _instance; 15 | 16 | /// Default constructor which returns the same object on future calls (singleton). 17 | factory FlutterUploader() { 18 | return _instance ??= FlutterUploader.private( 19 | const MethodChannel('flutter_uploader'), 20 | const EventChannel('flutter_uploader/events/progress'), 21 | const EventChannel('flutter_uploader/events/result'), 22 | ); 23 | } 24 | 25 | /// Only required for testing. 26 | @visibleForTesting 27 | FlutterUploader.private( 28 | MethodChannel channel, 29 | EventChannel progressChannel, 30 | EventChannel resultChannel, 31 | ) : _platform = channel, 32 | _progressChannel = progressChannel, 33 | _resultChannel = resultChannel; 34 | 35 | /// This call is required to receive background notifications. 36 | /// [backgroundHandler] is a top level function which will be invoked by Android 37 | Future setBackgroundHandler(final Function backgroundHandler) async { 38 | final callback = PluginUtilities.getCallbackHandle(backgroundHandler)!; 39 | final handle = callback.toRawHandle(); 40 | await _platform.invokeMethod('setBackgroundHandler', { 41 | 'callbackHandle': handle, 42 | }); 43 | } 44 | 45 | /// Stream to listen on upload progress 46 | Stream get progress { 47 | return _progressStream ??= _progressChannel 48 | .receiveBroadcastStream() 49 | .map>((event) => Map.from(event)) 50 | .map(_parseProgress); 51 | } 52 | 53 | UploadTaskProgress _parseProgress(Map map) { 54 | String id = map['taskId']; 55 | int status = map['status']; 56 | int? uploadProgress = map['progress']; 57 | 58 | return UploadTaskProgress( 59 | id, 60 | uploadProgress, 61 | UploadTaskStatus.from(status), 62 | ); 63 | } 64 | 65 | /// Stream to listen on upload result 66 | /// 67 | /// This stream may contain duplicates and previously finished uploads. 68 | /// Uploads can run in the background at any time and the platform (e.g. 69 | /// Android WorkManager) will keep a record of previously completed work. 70 | /// 71 | /// In order to clear the list, you can use [clearUploads] following by a 72 | /// re-subscription to this stream. 73 | Stream get result { 74 | return _resultStream ??= _resultChannel 75 | .receiveBroadcastStream() 76 | .map>((event) => Map.from(event)) 77 | .map(_parseResult); 78 | } 79 | 80 | UploadTaskResponse _parseResult(Map map) { 81 | String id = map['taskId']; 82 | String? message = map['message']; 83 | int? status = map['status']; 84 | int? statusCode = map['statusCode']; 85 | final headers = map['headers'] != null 86 | ? Map.from(map['headers']) 87 | : {}; 88 | 89 | return UploadTaskResponse( 90 | taskId: id, 91 | status: UploadTaskStatus.from(status), 92 | statusCode: statusCode, 93 | headers: headers, 94 | response: message, 95 | ); 96 | } 97 | 98 | /// Enqueues a new upload task described by [upload]. 99 | /// 100 | /// See [MultipartFormDataUpload], [RawUpload] for available configuration. 101 | Future enqueue(Upload upload) async { 102 | if (upload is MultipartFormDataUpload) { 103 | return (await _platform.invokeMethod('enqueue', { 104 | 'url': upload.url, 105 | 'method': describeEnum(upload.method), 106 | 'files': (upload.files ?? []).map((e) => e.toJson()).toList(), 107 | 'headers': upload.headers, 108 | 'data': upload.data, 109 | 'tag': upload.tag, 110 | 'allowCellular': upload.allowCellular, 111 | }))!; 112 | } 113 | if (upload is RawUpload) { 114 | return (await _platform.invokeMethod('enqueueBinary', { 115 | 'url': upload.url, 116 | 'method': describeEnum(upload.method), 117 | 'path': upload.path, 118 | 'headers': upload.headers, 119 | 'tag': upload.tag, 120 | 'allowCellular': upload.allowCellular, 121 | }))!; 122 | } 123 | 124 | throw 'Invalid upload type'; 125 | } 126 | 127 | /// Cancel a given upload task 128 | /// 129 | /// **parameters:** 130 | /// 131 | /// * `taskId`: unique identifier of the upload task 132 | /// 133 | Future cancel({required String taskId}) async { 134 | await _platform.invokeMethod('cancel', {'taskId': taskId}); 135 | } 136 | 137 | /// 138 | /// Cancel all enqueued and running upload tasks 139 | /// 140 | Future cancelAll() async { 141 | await _platform.invokeMethod('cancelAll'); 142 | } 143 | 144 | /// Clears all previously downloaded files from the database. 145 | /// The uploader, through it's various platform implementations, will keep 146 | /// a list of successfully uploaded files (or failed uploads). 147 | /// 148 | /// Be careful, clearing this list will clear this list and you won't have access to it anymore. 149 | Future clearUploads() async { 150 | await _platform.invokeMethod('clearUploads'); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /lib/src/upload.dart: -------------------------------------------------------------------------------- 1 | part of flutter_uploader; 2 | 3 | /// Abstract data structure for storing uploads. 4 | abstract class Upload { 5 | /// Default constructor which specicies a [url] and [method]. 6 | /// Sub classes may override the method for developer convenience. 7 | const Upload({ 8 | required this.url, 9 | required this.method, 10 | this.headers = const {}, 11 | this.tag, 12 | this.allowCellular = true, 13 | }); 14 | 15 | /// Upload link 16 | final String url; 17 | 18 | /// HTTP method to use for upload (POST,PUT,PATCH) 19 | final UploadMethod method; 20 | 21 | /// HTTP headers. 22 | final Map? headers; 23 | 24 | /// Name of the upload request (only used on Android) 25 | final String? tag; 26 | 27 | /// If uploads are allowed to use cellular connections 28 | /// Defaults to true. If false, uploads will only use wifi connections 29 | final bool allowCellular; 30 | } 31 | 32 | /// Standard RFC 2388 multipart/form-data upload. 33 | /// 34 | /// The platform will generate the boundaries and accompanying information. 35 | class MultipartFormDataUpload extends Upload { 36 | /// Default constructor which requires either files or data to be set. 37 | MultipartFormDataUpload({ 38 | required String url, 39 | UploadMethod method = UploadMethod.POST, 40 | Map? headers, 41 | String? tag, 42 | this.files, 43 | this.data, 44 | bool allowCellular = true, 45 | }) : assert(files != null || data != null), 46 | super( 47 | url: url, 48 | method: method, 49 | headers: headers, 50 | tag: tag, 51 | allowCellular: allowCellular, 52 | ) { 53 | // Need to specify either files or data. 54 | assert(files!.isNotEmpty || data!.isNotEmpty); 55 | } 56 | 57 | /// files to be uploaded 58 | final List? files; 59 | 60 | /// additional data. Each entry will be sent as a form field. 61 | final Map? data; 62 | } 63 | 64 | /// Also called a binary upload, this represents a upload without any form-encoding applies. 65 | class RawUpload extends Upload { 66 | /// Default constructor. 67 | const RawUpload({ 68 | required String url, 69 | UploadMethod method = UploadMethod.POST, 70 | Map? headers, 71 | String? tag, 72 | this.path, 73 | bool allowCellular = true, 74 | }) : super( 75 | url: url, 76 | method: method, 77 | headers: headers, 78 | tag: tag, 79 | allowCellular: allowCellular, 80 | ); 81 | 82 | /// single file to upload 83 | final String? path; 84 | } 85 | -------------------------------------------------------------------------------- /lib/src/upload_method.dart: -------------------------------------------------------------------------------- 1 | part of flutter_uploader; 2 | 3 | // ignore_for_file: constant_identifier_names 4 | 5 | /// Upload method for multipart and raw uploads. 6 | enum UploadMethod { 7 | /// HTTP POST 8 | POST, 9 | 10 | /// HTTP PUT 11 | PUT, 12 | 13 | /// HTTP PATCH 14 | PATCH, 15 | } 16 | -------------------------------------------------------------------------------- /lib/src/upload_task_progress.dart: -------------------------------------------------------------------------------- 1 | part of flutter_uploader; 2 | 3 | /// Contains in-flight progress information. For finished uploads, refer to the 4 | /// [UploadTaskResponse] class. 5 | class UploadTaskProgress extends Equatable { 6 | /// Upload Task ID. 7 | final String taskId; 8 | 9 | /// Upload progress, range from 0 to 100 (complete). 10 | final int? progress; 11 | 12 | /// Status of the upload itself. 13 | final UploadTaskStatus status; 14 | 15 | /// Default constructor. 16 | const UploadTaskProgress( 17 | this.taskId, 18 | this.progress, 19 | this.status, 20 | ); 21 | 22 | @override 23 | bool get stringify => true; 24 | 25 | @override 26 | List get props => [taskId, progress, status]; 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/upload_task_response.dart: -------------------------------------------------------------------------------- 1 | part of flutter_uploader; 2 | 3 | /// Contains information about a enqueue or finished/failed upload. 4 | /// For in-flight information, see the [UploadTaskProgress] class. 5 | class UploadTaskResponse extends Equatable { 6 | /// Upload Task ID. 7 | final String taskId; 8 | 9 | /// If the server responded with a body, it will be available here. 10 | /// No automatic conversion (e.g. JSON / XML) will be done. 11 | final String? response; 12 | 13 | /// The status code of the finished upload. 14 | final int? statusCode; 15 | 16 | /// The final status, refer to the enum for details. 17 | final UploadTaskStatus? status; 18 | 19 | /// Response headers. 20 | final Map? headers; 21 | 22 | /// Default constructor. 23 | const UploadTaskResponse({ 24 | required this.taskId, 25 | this.response, 26 | this.statusCode, 27 | this.status, 28 | this.headers, 29 | }); 30 | 31 | @override 32 | bool get stringify => true; 33 | 34 | @override 35 | List get props { 36 | return [ 37 | taskId, 38 | response, 39 | statusCode, 40 | status, 41 | headers, 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/src/upload_task_status.dart: -------------------------------------------------------------------------------- 1 | part of flutter_uploader; 2 | 3 | /// A class defines a set of possible statuses of a upload task 4 | class UploadTaskStatus extends Equatable { 5 | final int? _value; 6 | 7 | const UploadTaskStatus._internal(this._value); 8 | 9 | /// Raw value getter. 10 | int? get value => _value; 11 | 12 | /// User friendly description. 13 | String get description { 14 | if (value == null) return 'Undefined'; 15 | switch (value) { 16 | case 1: 17 | return 'Enqueued'; 18 | case 2: 19 | return 'Running'; 20 | case 3: 21 | return 'Completed'; 22 | case 4: 23 | return 'Failed'; 24 | case 5: 25 | return 'Cancelled'; 26 | case 6: 27 | return 'Paused'; 28 | default: 29 | return 'Undefined ($value)'; 30 | } 31 | } 32 | 33 | /// Convert a raw integer value to [UploadTaskStatus]. 34 | static UploadTaskStatus from(int? value) => UploadTaskStatus._internal(value); 35 | 36 | @override 37 | List get props => [_value]; 38 | 39 | /// Status is not determined yet. 40 | static const undefined = UploadTaskStatus._internal(0); 41 | 42 | /// Upload was enqueued and is about to be picked up by a upload worker. 43 | static const enqueued = UploadTaskStatus._internal(1); 44 | 45 | /// Upload is running / in progress. 46 | static const running = UploadTaskStatus._internal(2); 47 | 48 | /// Upload completed successfully. 49 | static const complete = UploadTaskStatus._internal(3); 50 | 51 | /// Upload has failed. 52 | static const failed = UploadTaskStatus._internal(4); 53 | 54 | /// Upload was cancelled by calling one of the `cancel` methods in `FlutterUploader`. 55 | static const canceled = UploadTaskStatus._internal(5); 56 | 57 | /// Upload is paused due to intermittent issues, like internet connectivity. 58 | static const paused = UploadTaskStatus._internal(6); 59 | } 60 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: flutter_uploader 2 | description: A plugin for creating and managing upload tasks with optional background exection support. 3 | version: 3.0.0-beta.4 4 | homepage: https://github.com/fluttercommunity/flutter_uploader 5 | maintainer: Sebastian Roth (@ened) 6 | 7 | environment: 8 | sdk: '>=2.12.0 <3.0.0' 9 | flutter: ">=1.20.0" 10 | 11 | dependencies: 12 | equatable: ^2.0.0 13 | flutter: 14 | sdk: flutter 15 | 16 | dev_dependencies: 17 | build_runner: ^2.1.7 18 | mockito: ^5.0.17 19 | flutter_lints: ^1.0.4 20 | flutter_test: 21 | sdk: flutter 22 | 23 | flutter: 24 | plugin: 25 | platforms: 26 | android: 27 | package: com.bluechilli.flutteruploader 28 | pluginClass: FlutterUploaderPlugin 29 | ios: 30 | pluginClass: FlutterUploaderPlugin 31 | 32 | false_secrets: 33 | - example/ios/Runner/GoogleService-Info.plist 34 | -------------------------------------------------------------------------------- /script/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" 5 | readonly REPO_DIR="$(dirname "$SCRIPT_DIR")" 6 | 7 | test -f google-java-format-1.3-all-deps.jar || 8 | wget https://github.com/google/google-java-format/releases/download/google-java-format-1.3/google-java-format-1.3-all-deps.jar 9 | 10 | java -jar google-java-format-1.3-all-deps.jar --replace `find ${REPO_DIR} -name "*.java"` 11 | [[ -z "`git ls-files --modified`" ]] || ( 12 | echo "Formatting failed." 13 | echo "Please follow the instructions on https://github.com/google/google-java-format" 14 | echo "The expected file formatting is:"; 15 | git diff 16 | 17 | exit 1 18 | ) 19 | -------------------------------------------------------------------------------- /test/flutter_uploader_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:ui'; 3 | 4 | import 'package:flutter/services.dart'; 5 | import 'package:flutter_test/flutter_test.dart'; 6 | import 'package:flutter_uploader/flutter_uploader.dart'; 7 | import 'package:mockito/annotations.dart'; 8 | import 'package:mockito/mockito.dart'; 9 | 10 | import 'flutter_uploader_test.mocks.dart'; 11 | 12 | void tmpBackgroundHandler() {} 13 | 14 | @GenerateMocks([EventChannel, MethodChannel]) 15 | void main() { 16 | TestWidgetsFlutterBinding.ensureInitialized(); 17 | 18 | late FlutterUploader uploader; 19 | 20 | const methodChannel = MethodChannel('flutter_uploader'); 21 | 22 | dynamic mockResponse; 23 | 24 | EventChannel progressChannel; 25 | EventChannel resultChannel; 26 | 27 | late StreamController progressController; 28 | late StreamController resultController; 29 | 30 | final log = []; 31 | 32 | setUp(() { 33 | methodChannel.setMockMethodCallHandler((call) async { 34 | log.add(call); 35 | 36 | if (mockResponse != null) { 37 | final tmp = mockResponse; 38 | mockResponse = null; 39 | return tmp; 40 | } 41 | 42 | return; 43 | }); 44 | 45 | progressChannel = MockEventChannel(); 46 | resultChannel = MockEventChannel(); 47 | 48 | progressController = StreamController(); 49 | resultController = StreamController(); 50 | 51 | when(progressChannel.receiveBroadcastStream()) 52 | .thenAnswer((_) => progressController.stream.asBroadcastStream()); 53 | when(resultChannel.receiveBroadcastStream()) 54 | .thenAnswer((_) => resultController.stream.asBroadcastStream()); 55 | 56 | uploader = 57 | FlutterUploader.private(methodChannel, progressChannel, resultChannel); 58 | 59 | log.clear(); 60 | }); 61 | 62 | tearDown(() { 63 | progressController.close(); 64 | resultController.close(); 65 | }); 66 | 67 | group('FlutterUploader', () { 68 | group('setBackgroundHandler', () { 69 | test('passes the arguments correctly', () async { 70 | await uploader.setBackgroundHandler(tmpBackgroundHandler); 71 | 72 | expect(log, [ 73 | isMethodCall('setBackgroundHandler', arguments: { 74 | 'callbackHandle': 75 | PluginUtilities.getCallbackHandle(tmpBackgroundHandler)! 76 | .toRawHandle() 77 | }), 78 | ]); 79 | }); 80 | }); 81 | 82 | group('enqueue', () { 83 | final sampleUpload = MultipartFormDataUpload( 84 | url: 'http://www.somewhere.com', 85 | files: [ 86 | FileItem(path: '/path/to/file1'), 87 | FileItem(path: '/path/to/file2', field: 'field2'), 88 | ], 89 | method: UploadMethod.PATCH, 90 | headers: {'header1': 'value1'}, 91 | data: {'data1': 'value1'}, 92 | tag: 'tag1', 93 | ); 94 | 95 | test('allowCellular has default value of true', () async { 96 | methodChannel.setMockMethodCallHandler((call) async { 97 | expect(call.arguments['allowCellular'] as bool?, isTrue); 98 | return 'allowCellular'; 99 | }); 100 | expect(await uploader.enqueue(sampleUpload), 'allowCellular'); 101 | }); 102 | test('returns the task id', () async { 103 | mockResponse = 'TASK123'; 104 | 105 | expect(await uploader.enqueue(sampleUpload), 'TASK123'); 106 | }); 107 | test('passes the arguments correctly', () async { 108 | mockResponse = 'TASK123'; 109 | 110 | await uploader.enqueue(sampleUpload); 111 | 112 | expect(log, [ 113 | isMethodCall('enqueue', arguments: { 114 | 'url': 'http://www.somewhere.com', 115 | 'method': 'PATCH', 116 | 'files': [ 117 | { 118 | 'path': '/path/to/file1', 119 | 'fieldname': 'file', 120 | }, 121 | { 122 | 'path': '/path/to/file2', 123 | 'fieldname': 'field2', 124 | } 125 | ], 126 | 'headers': { 127 | 'header1': 'value1', 128 | }, 129 | 'data': { 130 | 'data1': 'value1', 131 | }, 132 | 'tag': 'tag1', 133 | 'allowCellular': true, 134 | }), 135 | ]); 136 | }); 137 | }); 138 | 139 | group('enqueueBinary', () { 140 | const sampleUpload = RawUpload( 141 | url: 'http://www.somewhere.com', 142 | path: '/path/to/file1', 143 | method: UploadMethod.PATCH, 144 | headers: {'header1': 'value1'}, 145 | tag: 'tag1', 146 | ); 147 | 148 | test('returns the task id', () async { 149 | mockResponse = 'TASK123'; 150 | 151 | expect(await uploader.enqueue(sampleUpload), 'TASK123'); 152 | }); 153 | 154 | test('passes the arguments correctly', () async { 155 | mockResponse = 'TASK123'; 156 | 157 | await uploader.enqueue(sampleUpload); 158 | 159 | expect(log, [ 160 | isMethodCall('enqueueBinary', arguments: { 161 | 'url': 'http://www.somewhere.com', 162 | 'method': 'PATCH', 163 | 'path': '/path/to/file1', 164 | 'headers': { 165 | 'header1': 'value1', 166 | }, 167 | 'tag': 'tag1', 168 | 'allowCellular': true, 169 | }), 170 | ]); 171 | }); 172 | }); 173 | group('cancel', () { 174 | test('calls correctly', () async { 175 | await uploader.cancel(taskId: 'task123'); 176 | 177 | expect(log, [ 178 | isMethodCall('cancel', arguments: { 179 | 'taskId': 'task123', 180 | }), 181 | ]); 182 | }); 183 | }); 184 | 185 | group('cancelAll', () { 186 | test('calls correctly', () async { 187 | await uploader.cancelAll(); 188 | 189 | expect(log, [ 190 | isMethodCall('cancelAll', arguments: null), 191 | ]); 192 | }); 193 | }); 194 | 195 | group('clearUploads', () { 196 | test('calls correctly', () async { 197 | await uploader.clearUploads(); 198 | 199 | expect(log, [ 200 | isMethodCall('clearUploads', arguments: null), 201 | ]); 202 | }); 203 | }); 204 | group('progress stream', () { 205 | testWidgets('supports multiple subscriptions', 206 | (WidgetTester tester) async { 207 | const fakeTaskId = '123123'; 208 | 209 | final c1 = Completer(); 210 | final c2 = Completer(); 211 | 212 | uploader.progress.take(1).listen((event) => c1.complete(event.taskId)); 213 | uploader.progress.take(1).listen((event) => c2.complete(event.taskId)); 214 | 215 | progressController.add({ 216 | 'taskId': fakeTaskId, 217 | 'message': '123', 218 | 'status': 200, 219 | 'statusCode': 120, 220 | }); 221 | 222 | expect(await c1.future, fakeTaskId); 223 | expect(await c2.future, fakeTaskId); 224 | }); 225 | }); 226 | }); 227 | 228 | group('result stream', () { 229 | testWidgets('supports multiple subscriptions', (WidgetTester tester) async { 230 | const fakeTaskId = '123123'; 231 | 232 | final c1 = Completer(); 233 | final c2 = Completer(); 234 | 235 | uploader.result.take(1).listen((event) => c1.complete(event.taskId)); 236 | uploader.result.take(1).listen((event) => c2.complete(event.taskId)); 237 | 238 | resultController.add({ 239 | 'taskId': fakeTaskId, 240 | 'message': '123', 241 | 'status': 200, 242 | 'statusCode': 120, 243 | }); 244 | 245 | expect(await c1.future, fakeTaskId); 246 | expect(await c2.future, fakeTaskId); 247 | }); 248 | }); 249 | } 250 | -------------------------------------------------------------------------------- /test/flutter_uploader_test.mocks.dart: -------------------------------------------------------------------------------- 1 | // Mocks generated by Mockito 5.0.17 from annotations 2 | // in flutter_uploader/test/flutter_uploader_test.dart. 3 | // Do not manually edit this file. 4 | 5 | import 'dart:async' as _i5; 6 | 7 | import 'package:flutter/src/services/binary_messenger.dart' as _i3; 8 | import 'package:flutter/src/services/message_codec.dart' as _i2; 9 | import 'package:flutter/src/services/platform_channel.dart' as _i4; 10 | import 'package:mockito/mockito.dart' as _i1; 11 | 12 | // ignore_for_file: avoid_redundant_argument_values 13 | // ignore_for_file: avoid_setters_without_getters 14 | // ignore_for_file: comment_references 15 | // ignore_for_file: implementation_imports 16 | // ignore_for_file: invalid_use_of_visible_for_testing_member 17 | // ignore_for_file: prefer_const_constructors 18 | // ignore_for_file: unnecessary_parenthesis 19 | // ignore_for_file: camel_case_types 20 | 21 | class _FakeMethodCodec_0 extends _i1.Fake implements _i2.MethodCodec {} 22 | 23 | class _FakeBinaryMessenger_1 extends _i1.Fake implements _i3.BinaryMessenger {} 24 | 25 | /// A class which mocks [EventChannel]. 26 | /// 27 | /// See the documentation for Mockito's code generation for more information. 28 | class MockEventChannel extends _i1.Mock implements _i4.EventChannel { 29 | MockEventChannel() { 30 | _i1.throwOnMissingStub(this); 31 | } 32 | 33 | @override 34 | String get name => 35 | (super.noSuchMethod(Invocation.getter(#name), returnValue: '') as String); 36 | @override 37 | _i2.MethodCodec get codec => (super.noSuchMethod(Invocation.getter(#codec), 38 | returnValue: _FakeMethodCodec_0()) as _i2.MethodCodec); 39 | @override 40 | _i3.BinaryMessenger get binaryMessenger => 41 | (super.noSuchMethod(Invocation.getter(#binaryMessenger), 42 | returnValue: _FakeBinaryMessenger_1()) as _i3.BinaryMessenger); 43 | @override 44 | _i5.Stream receiveBroadcastStream([dynamic arguments]) => (super 45 | .noSuchMethod(Invocation.method(#receiveBroadcastStream, [arguments]), 46 | returnValue: Stream.empty()) as _i5.Stream); 47 | } 48 | 49 | /// A class which mocks [MethodChannel]. 50 | /// 51 | /// See the documentation for Mockito's code generation for more information. 52 | class MockMethodChannel extends _i1.Mock implements _i4.MethodChannel { 53 | MockMethodChannel() { 54 | _i1.throwOnMissingStub(this); 55 | } 56 | 57 | @override 58 | String get name => 59 | (super.noSuchMethod(Invocation.getter(#name), returnValue: '') as String); 60 | @override 61 | _i2.MethodCodec get codec => (super.noSuchMethod(Invocation.getter(#codec), 62 | returnValue: _FakeMethodCodec_0()) as _i2.MethodCodec); 63 | @override 64 | _i3.BinaryMessenger get binaryMessenger => 65 | (super.noSuchMethod(Invocation.getter(#binaryMessenger), 66 | returnValue: _FakeBinaryMessenger_1()) as _i3.BinaryMessenger); 67 | @override 68 | _i5.Future invokeMethod(String? method, [dynamic arguments]) => 69 | (super.noSuchMethod(Invocation.method(#invokeMethod, [method, arguments]), 70 | returnValue: Future.value()) as _i5.Future); 71 | @override 72 | _i5.Future?> invokeListMethod(String? method, 73 | [dynamic arguments]) => 74 | (super.noSuchMethod( 75 | Invocation.method(#invokeListMethod, [method, arguments]), 76 | returnValue: Future?>.value()) as _i5.Future?>); 77 | @override 78 | _i5.Future?> invokeMapMethod(String? method, 79 | [dynamic arguments]) => 80 | (super.noSuchMethod( 81 | Invocation.method(#invokeMapMethod, [method, arguments]), 82 | returnValue: Future?>.value()) as _i5.Future?>); 83 | @override 84 | void setMethodCallHandler( 85 | _i5.Future Function(_i2.MethodCall)? handler) => 86 | super.noSuchMethod(Invocation.method(#setMethodCallHandler, [handler]), 87 | returnValueForMissingStub: null); 88 | } 89 | --------------------------------------------------------------------------------