diff --git a/.eslintignore b/.eslintignore index 168d4a14..90c978b3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1 @@ -example/RNBackgroundExample/node_modules -example/RNBackgroundExample/e2e +example/ diff --git a/.eslintrc.js b/.eslintrc.js index 40c6dcd0..256c16b8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,8 @@ module.exports = { root: true, - extends: '@react-native-community', + extends: '@react-native', + plugins: ['unused-imports'], + rules: { + 'unused-imports/no-unused-imports': 'error', + }, }; diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml deleted file mode 100644 index 992ed83f..00000000 --- a/.github/workflows/android.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Test Android Example App - -on: [push, pull_request] - -jobs: - react-native-android: - runs-on: macos-latest - if: "!contains(github.event.head_commit.message, '[skip ci]')" - - steps: - - name: Checkout project - uses: actions/checkout@v2 - - - name: Specify node version - uses: actions/setup-node@v1 - with: - node-version: 12 - - - name: Use specific Java version for sdkmanager to work - uses: joschi/setup-jdk@v2 - with: - java-version: 'openjdk8' - architecture: 'x64' - - - name: Setup Android emulator - run: | - echo y | sudo $ANDROID_HOME/tools/bin/sdkmanager "system-images;android-27;google_apis;x86" > /dev/null - $ANDROID_HOME/tools/bin/avdmanager -s create avd -n emu -k "system-images;android-27;google_apis;x86" -b "x86" -c 1G -d 7 -f - - - name: Install node_modules - working-directory: example/RNBackgroundExample/ - run: - yarn install --frozen-lockfile - - - name: Deploy - working-directory: example/RNBackgroundExample/ - run: - yarn e2e/deploy/android - - - name: Start Emulator - working-directory: example/RNBackgroundExample/ - timeout-minutes: 5 - run: | - export PATH=$PATH:$ANDROID_HOME/platform-tools - $ANDROID_HOME/emulator/emulator @emu -noaudio -no-boot-anim -netdelay none -accel on -no-snapshot & - - - name: Wait for emulator to be ready - working-directory: example/RNBackgroundExample/ - timeout-minutes: 15 - run: | - sh ./wait-for-emulator.sh - - - name: Android test - working-directory: example/RNBackgroundExample/ - timeout-minutes: 8 - run: | - mkdir -p ./artifacts - node e2e/start-server.js & - adb reverse tcp:8080 tcp:8080 - yarn e2e/test/android --record-videos failing - - - uses: actions/upload-artifact@master - name: Provide videos of failed E2E tests - if: failure() - with: - name: android-failing-e2e-videos - path: example/RNBackgroundExample/artifacts diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml deleted file mode 100644 index 45f65815..00000000 --- a/.github/workflows/deploy-release.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Deploy & Release - -on: - push: - branches: - - master - -jobs: - deploy-release: - runs-on: ubuntu-18.04 - if: "!contains(github.event.head_commit.message, '[skip ci]')" - - steps: - - name: Checkout project - uses: actions/checkout@v2 - - - name: Specify node version - uses: actions/setup-node@v1 - with: - node-version: 12 - - - name: Release to NPM - if: github.ref == 'refs/heads/master' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - yarn install --frozen-lockfile - yarn release diff --git a/.github/workflows/iOS.yml b/.github/workflows/iOS.yml deleted file mode 100644 index 7d7c658c..00000000 --- a/.github/workflows/iOS.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Test iOS Example App - -on: [push, pull_request] - -jobs: - react-native-ios: - runs-on: macos-latest - if: "!contains(github.event.head_commit.message, '[skip ci]')" - - steps: - - name: Checkout project - uses: actions/checkout@v2 - - - name: Specify node version - uses: actions/setup-node@v1 - with: - node-version: 12 - - - name: Setup Detox - run: | - brew tap wix/brew - brew install applesimutils - - - name: Install node_modules - working-directory: example/RNBackgroundExample/ - run: - yarn install --frozen-lockfile - - - name: Deploy - working-directory: example/RNBackgroundExample/ - run: - yarn deploy/release/ios - - - name: iOS test - working-directory: example/RNBackgroundExample/ - timeout-minutes: 8 - run: | - npx detox clean-framework-cache && npx detox build-framework-cache - node e2e/start-server.js & - yarn e2e/test/ios diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 8b07f4b2..7fd6416e 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -14,10 +14,12 @@ jobs: - name: setup node uses: actions/setup-node@v1 with: - node-version: 12 + node-version: 20 - name: install node_modules - run: yarn install --frozen-lockfile + run: | + yarn install --frozen-lockfile + yarn --cwd example/RNBGUExample install --frozen-lockfile - name: node lint run: diff --git a/README.md b/README.md index d64c6725..989a4d8c 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,23 @@ -# react-native-background-upload [![npm version](https://badge.fury.io/js/react-native-background-upload.svg)](https://badge.fury.io/js/react-native-background-upload) ![GitHub Actions status](https://github.com/Vydia/react-native-background-upload/workflows/Test%20iOS%20Example%20App/badge.svg) ![GitHub Actions status](https://github.com/Vydia/react-native-background-upload/workflows/Test%20Android%20Example%20App/badge.svg) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) +# react-native-background-upload -The only React Native http post file uploader with android and iOS background support. If you are uploading large files like videos, use this so your users can background your app during a long upload. - -NOTE: Use major version 4 with RN 47.0 and greater. If you have RN less than 47, use 3.0. To view all available versions: -`npm show react-native-background-upload versions` +OpenSpace home-grown background uploader for React Native. on iOS it uses URLSession, on Android it uses CoroutineWorker and Ktor. +Documentation has been modified to reflect the changes made to this library. # Installation ## 1. Install package -`npm install --save react-native-background-upload` - -or - `yarn add react-native-background-upload` Note: if you are installing on React Native < 0.47, use `react-native-background-upload@3.0.0` instead of `react-native-background-upload` -## 2. Link Native Code - -### Autolinking (React Native >= 0.60) +## 2. Native Setup -##### iOS +### iOS `cd ./ios && pod install && cd ../` -##### Android - -###### ProGuard -Add this to your ProGuard configuration: - -`-keep class net.gotev.uploadservice.** { *; }` - -### Automatic Native Library Linking (React Native < 0.60) - -`react-native link react-native-background-upload` - -### Or, Manually Link It - -#### iOS - -1. In the XCode's "Project navigator", right click on your project's Libraries folder ➜ `Add Files to <...>` -2. Go to `node_modules` ➜ `react-native-background-upload` ➜ `ios` ➜ select `VydiaRNFileUploader.xcodeproj` -3. Add `VydiaRNFileUploader.a` to `Build Phases -> Link Binary With Libraries` - -#### Android -1. Add the following lines to `android/settings.gradle`: - - ```gradle - include ':react-native-background-upload' - project(':react-native-background-upload').projectDir = new File(settingsDir, '../node_modules/react-native-background-upload/android') - ``` -2. Add the compile and resolutionStrategy line to the dependencies in `android/app/build.gradle`: - - ```gradle - configurations.all { resolutionStrategy.force 'com.squareup.okhttp3:okhttp:3.4.1' } // required by react-native-background-upload until React Native supports okhttp >= okhttp 3.5 - - dependencies { - compile project(':react-native-background-upload') - } - ``` - - -3. Add the import and link the package in `MainApplication.java`: - - ```java - import com.vydia.RNUploader.UploaderReactPackage; <-- add this import - - public class MainApplication extends Application implements ReactApplication { - @Override - protected List getPackages() { - return Arrays.asList( - new MainReactPackage(), - new UploaderReactPackage() // <-- add this line - ); - } - } - ``` - -4. Ensure Android SDK versions. Open your app's `android/app/build.gradle` file. Ensure `compileSdkVersion` and `targetSdkVersion` are 25. Otherwise you'll get compilation errors. - ## 3. Expo To use this library with [Expo](https://expo.io) one must first detach (eject) the project and follow [step 2](#2-link-native-code) instructions. Additionally on iOS there is a must to add a Header Search Path to other dependencies which are managed using Pods. To do so one has to add `$(SRCROOT)/../../../ios/Pods/Headers/Public` to Header Search Path in `VydiaRNFileUploader` module using XCode. @@ -88,48 +25,54 @@ To use this library with [Expo](https://expo.io) one must first detach (eject) t # Usage ```js -import Upload from 'react-native-background-upload' +import Upload from 'react-native-background-upload'; const options = { url: 'https://myservice.com/path/to/post', path: 'file://path/to/file/on/device', method: 'POST', type: 'raw', - maxRetries: 2, // set retry count (Android only). Default 2 headers: { 'content-type': 'application/octet-stream', // Customize content-type - 'my-custom-header': 's3headervalueorwhateveryouneed' + 'my-custom-header': 's3headervalueorwhateveryouneed', }, - // Below are options only supported on Android - notification: { - enabled: true + android: { + notificationChannel: 'my-channel-id', + notificationId: 'my-progress-notification', + notificationTitle: 'Uploading...', + notificationTitleNoWifi: 'Waiting for Wifi...', + notificationTitleNoInternet: 'Waiting for Internet...', }, - useUtf8Charset: true -} - -Upload.startUpload(options).then((uploadId) => { - console.log('Upload started') - Upload.addListener('progress', uploadId, (data) => { - console.log(`Progress: ${data.progress}%`) - }) - Upload.addListener('error', uploadId, (data) => { - console.log(`Error: ${data.error}%`) - }) - Upload.addListener('cancelled', uploadId, (data) => { - console.log(`Cancelled!`) - }) - Upload.addListener('completed', uploadId, (data) => { - // data includes responseCode: number and responseBody: Object - console.log('Completed!') - }) -}).catch((err) => { - console.log('Upload error!', err) -}) + useUtf8Charset: true, +}; + +Upload.addListener('progress', uploadId, (data) => { + console.log(`Progress: ${data.progress}%`); +}); +Upload.addListener('error', uploadId, (data) => { + console.log(`Error: ${data.error}%`); +}); +Upload.addListener('cancelled', uploadId, (data) => { + console.log(`Cancelled!`); +}); +Upload.addListener('completed', uploadId, (data) => { + // data includes responseCode: number and responseBody: Object + console.log('Completed!'); +}); +Upload.android.addNotificationListener(() => { + console.log('Progress notification pressed!'); +}); + +Upload.startUpload(options) + .then((uploadId) => console.log('Upload started', uploadId)) + .catch((err) => console.log('Upload error!', err)); ``` ## Multipart Uploads -Just set the `type` option to `multipart` and set the `field` option. Example: +**🚧 COMING SOON** + +Just set the `type` option to `multipart` and set the `field` option. Example: ``` const options = { @@ -153,58 +96,37 @@ All top-level methods are available as named exports or methods on the default e The primary method you will use, this starts the upload process. -Returns a promise with the string ID of the upload. Will reject if there is a connection problem, the file doesn't exist, or there is some other problem. +Returns a promise with the string ID of the upload. Will reject if the file doesn't exist or unknown native problems. `options` is an object with following values: -*Note: You must provide valid URIs. react-native-background-upload does not escape the values you provide.* - -|Name|Type|Required|Default|Description|Example| -|---|---|---|---|---|---| -|`url`|string|Required||URL to upload to|`https://myservice.com/path/to/post`| -|`path`|string|Required||File path on device|`file://something/coming/from%20the%20device.png`| -|`type`|'raw' or 'multipart'|Optional|`raw`|Primary upload type.|| -|`method`|string|Optional|`POST`|HTTP method|| -|`customUploadId`|string|Optional||`startUpload` returns a Promise that includes the upload ID, which can be used for future status checks. By default, the upload ID is automatically generated. This parameter allows a custom ID to use instead of the default.|| -|`headers`|object|Optional||HTTP headers|`{ 'Accept': 'application/json' }`| -|`field`|string|Required if `type: 'multipart'`||The form field name for the file. Only used when `type: 'multipart`|`uploaded-file`| -|`parameters`|object|Optional||Additional form fields to include in the HTTP request. Only used when `type: 'multipart`|| -|`notification`|Notification object (see below)|Optional||Android only. |`{ enabled: true, onProgressTitle: "Uploading...", autoClear: true }`| -|`useUtf8Charset`|boolean|Optional||Android only. Set to true to use `utf-8` as charset. || -|`appGroup`|string|Optional|iOS only. App group ID needed for share extensions to be able to properly call the library. See: https://developer.apple.com/documentation/foundation/nsfilemanager/1412643-containerurlforsecurityapplicati +_Note: You must provide valid URIs. react-native-background-upload does not escape the values you provide._ + +| Name | Type | Required | Default | Description | Example | +| ---------------- | ------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `url` | string | Required | | URL to upload to | `https://myservice.com/path/to/post` | +| `path` | string | Required | | File path on device | `file://something/coming/from%20the%20device.png` | +| `type` | 'raw' or 'multipart' | Optional | `raw` | Primary upload type. | | +| `method` | string | Optional | `POST` | HTTP method | | +| `customUploadId` | string | Optional | | `startUpload` returns a Promise that includes the upload ID, which can be used for future status checks. By default, the upload ID is automatically generated. This parameter allows a custom ID to use instead of the default. | | +| `headers` | object | Optional | | HTTP headers | `{ 'Accept': 'application/json' }` | +| `field` | string | Required if `type: 'multipart'` | | The form field name for the file. Only used when `type: 'multipart` | `uploaded-file` | +| `parameters` | object | Optional | | Additional form fields to include in the HTTP request. Only used when `type: 'multipart` | | +| `notification` | Notification object (see below) | Optional | | Android only. | `{ enabled: true, onProgressTitle: "Uploading...", autoClear: true }` | +| `useUtf8Charset` | boolean | Optional | | Android only. Set to true to use `utf-8` as charset. | | +| `appGroup` | string | Optional | iOS only. App group ID needed for share extensions to be able to properly call the library. See: https://developer.apple.com/documentation/foundation/nsfilemanager/1412643-containerurlforsecurityapplicati | ### Notification Object (Android Only) -|Name|Type|Required|Description|Example| -|---|---|---|---|---| -|`enabled`|boolean|Optional|Enable or diasable notifications. Works only on Android version < 8.0 Oreo. On Android versions >= 8.0 Oreo is required by Google's policy to display a notification when a background service run|`{ enabled: true }`| -|`autoClear`|boolean|Optional|Autoclear notification on complete|`{ autoclear: true }`| -|`notificationChannel`|string|Optional|Sets android notificaion channel|`{ notificationChannel: "My-Upload-Service" }`| -|`enableRingTone`|boolean|Optional|Sets whether or not to enable the notification sound when the upload gets completed with success or error|`{ enableRingTone: true }`| -|`onProgressTitle`|string|Optional|Sets notification progress title|`{ onProgressTitle: "Uploading" }`| -|`onProgressMessage`|string|Optional|Sets notification progress message|`{ onProgressMessage: "Uploading new video" }`| -|`onCompleteTitle`|string|Optional|Sets notification complete title|`{ onCompleteTitle: "Upload finished" }`| -|`onCompleteMessage`|string|Optional|Sets notification complete message|`{ onCompleteMessage: "Your video has been uploaded" }`| -|`onErrorTitle`|string|Optional|Sets notification error title|`{ onErrorTitle: "Upload error" }`| -|`onErrorMessage`|string|Optional|Sets notification error message|`{ onErrorMessage: "An error occured while uploading a video" }`| -|`onCancelledTitle`|string|Optional|Sets notification cancelled title|`{ onCancelledTitle: "Upload cancelled" }`| -|`onCancelledMessage`|string|Optional|Sets notification cancelled message|`{ onCancelledMessage: "Video upload was cancelled" }`| - - -### getFileInfo(path) - -Returns some useful information about the file in question. Useful if you want to set a MIME type header. - -`path` is a string, such as `file://path.to.the.file.png` - -Returns a Promise that resolves to an object containing: - -|Name|Type|Required|Description|Example| -|---|---|---|---|---| -|`name`|string|Required|The file name within its directory.|`image2.png`| -|`exists`|boolean|Required|Is there a file matching this path?|| -|`size`|number|If `exists`|File size, in bytes|| -|`extension`|string|If `exists`|File extension|`mov`| -|`mimeType`|string|If `exists`|The MIME type for the file.|`video/mp4`| + +Android forces us to display a progress notification to show overall upload progress. + +| Name | Type | Required | Description | Example | +| ----------------------------- | ------ | -------- | ---------------------------------------------------------------- | --------------------------- | +| `notificationChannel` | string | Optional | Sets android notification channel | `background-upload-channel` | +| `notificationId` | string | Optional | A custom ID for the notification | `upload-progress` | +| `notificationTitle` | string | Optional | Sets the default title for the notification | `Uploading...` | +| `notificationTitleNoWifi` | string | Optional | Sets notification title for uploads awaiting wifi | `Waiting for Wifi...` | +| `notificationTitleNoInternet` | string | Optional | Sets notification title for uploads awaiting internet connection | `Waiting for Internet...` | ### cancelUpload(uploadId) @@ -220,100 +142,69 @@ Adds an event listener, possibly confined to a single upload. `eventType` Event to listen for. Values: 'progress' | 'error' | 'completed' | 'cancelled' -`uploadId` The upload ID from `startUpload` to filter events for. If null, this will include all uploads. +`uploadId` The upload ID from `startUpload` to filter events for. If null, this will include all uploads. `listener` Function to call when the event occurs. Returns an [EventSubscription](https://github.com/facebook/react-native/blob/master/Libraries/vendor/emitter/EmitterSubscription.js). To remove the listener, call `remove()` on the `EventSubscription`. +### android.addNotificationListener(listener) + +When the upload progress notification is pressed, it will open the app and fire this event. +There's no event data for this. + ## Events ### progress Event Data -|Name|Type|Required|Description| -|---|---|---|---| -|`id`|string|Required|The ID of the upload.| -|`progress`|0-100|Required|Percentage completed.| +| Name | Type | Required | Description | +| ---------- | ------ | -------- | --------------------- | +| `id` | string | Required | The ID of the upload. | +| `progress` | 0-100 | Required | Percentage completed. | ### error Event Data -|Name|Type|Required|Description| -|---|---|---|---| -|`id`|string|Required|The ID of the upload.| -|`error`|string|Required|Error message.| +| Name | Type | Required | Description | +| ------- | ------ | -------- | --------------------- | +| `id` | string | Required | The ID of the upload. | +| `error` | string | Required | Error message. | ### completed Event Data -|Name|Type|Required|Description| -|---|---|---|---| -|`id`|string|Required|The ID of the upload.| -|`responseCode`|string|Required|HTTP status code received| -|`responseBody`|string|Required|HTTP response body| -|`responseHeaders`|string|Required|HTTP response headers (Android)| +| Name | Type | Required | Description | +| ----------------- | ------ | -------- | ------------------------------- | +| `id` | string | Required | The ID of the upload. | +| `responseCode` | string | Required | HTTP status code received | +| `responseBody` | string | Required | HTTP response body | +| `responseHeaders` | string | Required | HTTP response headers (Android) | ### cancelled Event Data -|Name|Type|Required|Description| -|---|---|---|---| -|`id`|string|Required|The ID of the upload.| - -# Customizing Android Build Properties -You may want to customize the `compileSdk, buildToolsVersion, and targetSdkVersion` versions used by this package. For that, add this to `android/build.gradle`: - -``` -ext { - targetSdkVersion = 23 - compileSdkVersion = 23 - buildToolsVersion = '23.0.2' -} -``` - -Add it above `allProjects` and you're good. Your `android/build.gradle` might then resemble: -``` -buildscript { - repositories { - jcenter() - } -} - -ext { - targetSdkVersion = 27 - compileSdkVersion = 27 - buildToolsVersion = '23.0.2' -} - -allprojects { - -``` +| Name | Type | Required | Description | +| ---- | ------ | -------- | --------------------- | +| `id` | string | Required | The ID of the upload. | # FAQs -Is there an example/sandbox app to test out this package? - -> Yes, there is a simple react native app that comes with an [express](https://github.com/expressjs/express) server where you can see react-native-background-upload in action and try things out in an isolated local environment. - -[RNBackgroundExample](https://github.com/Vydia/react-native-background-upload/blob/master/example/RNBackgroundExample) - Does it support iOS camera roll assets? > Yes, as of version 4.3.0. Does it support multiple file uploads? -> Yes and No. It supports multiple concurrent uploads, but only a single upload per request. That should be fine for 90%+ of cases. +> Yes and No. It supports multiple concurrent uploads, but only a single upload per request. That should be fine for 90%+ of cases. Why should I use this file uploader instead of others that I've Googled like [react-native-uploader](https://github.com/aroth/react-native-uploader)? -> This package has two killer features not found anywhere else (as of 12/16/2016). First, it works on both iOS and Android. Others are iOS only. Second, it supports background uploading. This means that users can background your app and the upload will continue. This does not happen with other uploaders. - +> This package has two killer features not found anywhere else (as of 12/16/2016). First, it works on both iOS and Android. Others are iOS only. Second, it supports background uploading. This means that users can background your app and the upload will continue. This does not happen with other uploaders. # Contributing @@ -321,45 +212,6 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md). # Common Issues -## BREAKING CHANGE IN 3.0 -This is for 3.0 only. This does NOT apply to 4.0, as recent React Native versions have upgraded the `okhttp` dependencies. Anyway... - -In 3.0, you need to add -```gradle - configurations.all { resolutionStrategy.force 'com.squareup.okhttp3:okhttp:3.4.1' } -``` -to your app's app's `android/app/build.gradle` file. - -Just add it above (not within) `dependencies` and you'll be fine. - - -## BREAKING CHANGE IN 2.0 -Two big things happened in version 2.0. First, the Android package name had to be changed, as it conflicted with our own internal app. My bad. Second, we updated the android upload service dependency to the latest, but that requires the app have a compileSdkVersion and targetSdkVersion or 25. - -To upgrade: -In `MainApplication.java`: - -Change - - ```java - import com.vydia.UploaderReactPackage; - ``` - -to - - ```java - import com.vydia.RNUploader.UploaderReactPackage; - ``` - -Then open your app's `android/app/build.gradle` file. -Ensure `compileSdkVersion` and `targetSdkVersion` are 25. - -Done! - - ## Gratitude -Thanks to: -- [android-upload-service](https://github.com/gotev/android-upload-service) It made Android dead simple to support. - -- [MIME type from path on iOS](http://stackoverflow.com/questions/2439020/wheres-the-iphone-mime-type-database) Thanks for the answer! +Many thanks to the [Original Library](https://github.com/Vydia/react-native-background-upload) for the boilerplate and inspiration diff --git a/android/build.gradle b/android/build.gradle index 9bc546ac..dee87982 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,8 +1,8 @@ buildscript { ext { - kotlinVersion = '1.4.0' + kotlinVersion = '1.6.20' buildToolsVersion = '29.0.2' - compileSdkVersion = 29 + compileSdkVersion = 31 // this helps us use the latest Worker version targetSdkVersion = 29 minSdkVersion = 18 } @@ -13,14 +13,13 @@ buildscript { google() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.3' + classpath 'com.android.tools.build:gradle:3.5.4' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" } } apply plugin: 'com.android.library' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' def DEFAULT_COMPILE_SDK_VERSION = 28 def DEFAULT_BUILD_TOOLS_VERSION = "28.0.3" @@ -31,11 +30,18 @@ def safeExtGet(prop, fallback) { } android { + buildFeatures { + /* + This is a replacement for the deprecated 'kotlin-android-extensions' library. More + information can be found here: https://developer.android.com/topic/libraries/view-binding/migration + */ + viewBinding = true + } compileSdkVersion safeExtGet('compileSdkVersion', DEFAULT_COMPILE_SDK_VERSION) buildToolsVersion safeExtGet('buildToolsVersion', DEFAULT_BUILD_TOOLS_VERSION) defaultConfig { - minSdkVersion 21 + minSdkVersion 24 targetSdkVersion safeExtGet('targetSdkVersion', DEFAULT_TARGET_SDK_VERSION) versionCode 1 versionName "1.0" @@ -46,6 +52,13 @@ android { lintOptions { abortOnError false } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } } repositories { @@ -58,13 +71,19 @@ def _kotlinVersion = _ext.has('detoxKotlinVersion') ? _ext.detoxKotlinVersion : def _kotlinStdlib = _ext.has('detoxKotlinStdlib') ? _ext.detoxKotlinStdlib : 'kotlin-stdlib-jdk8' dependencies { - implementation "androidx.core:core-ktx:1.0.1" + implementation "androidx.core:core-ktx:1.7.0" implementation 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:$_kotlinStdlib:$_kotlinVersion" - implementation 'net.gotev:uploadservice-okhttp:4.7.0' + implementation("com.squareup.okhttp3:okhttp:4.10.0") + + implementation 'com.google.code.gson:gson:2.8.9' + + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4' + + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' - implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.0.0' + implementation "androidx.work:work-runtime-ktx:2.8.1" } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 4d977ff9..e62ef6f8 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1,15 @@ + xmlns:tools="http://schemas.android.com/tools" + package="com.vydia.RNUploader"> + + + + + + + + diff --git a/android/src/main/java/com/vydia/RNUploader/EventReporter.kt b/android/src/main/java/com/vydia/RNUploader/EventReporter.kt new file mode 100644 index 00000000..32daf999 --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader/EventReporter.kt @@ -0,0 +1,67 @@ +package com.vydia.RNUploader + +import android.util.Log +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import okhttp3.Response + +// Sends events to React Native +class EventReporter { + companion object { + private const val TAG = "UploadReceiver" + fun cancelled(uploadId: String) = + sendEvent("cancelled", Arguments.createMap().apply { + putString("id", uploadId) + }) + + fun error(uploadId: String, exception: Throwable) = + sendEvent("error", Arguments.createMap().apply { + putString("id", uploadId) + putString("error", exception.message ?: "Unknown exception") + }) + + fun success(uploadId: String, response: Response) = + CoroutineScope(Dispatchers.IO).launch { + sendEvent("completed", Arguments.createMap().apply { + putString("id", uploadId) + putInt("responseCode", response.code) + putString("responseBody", response.body?.string().let { + if (it.isNullOrBlank()) response.message else it + }) + putMap("responseHeaders", Arguments.createMap().apply { + response.headers.names().forEach { name -> + putString(name, response.headers.values(name).joinToString(", ")) + } + }) + }) + } + + fun progress(uploadId: String, bytesSentTotal: Long, contentLength: Long) = + sendEvent("progress", Arguments.createMap().apply { + putString("id", uploadId) + putDouble("progress", (bytesSentTotal.toDouble() * 100 / contentLength)) //0-100 + }) + + fun notification() = sendEvent("notification") + + /** Sends an event to the JS module */ + private fun sendEvent(eventName: String, params: WritableMap = Arguments.createMap()) { + val reactContext = UploaderModule.reactContext ?: return + + // Right after JS reloads, react instance might not be available yet + if (!reactContext.hasActiveReactInstance()) return + + try { + val jsModule = reactContext.getJSModule(RCTDeviceEventEmitter::class.java) + jsModule.emit("RNFileUploader-$eventName", params) + } catch (exc: Throwable) { + Log.e(TAG, "sendEvent() failed", exc) + } + } + } + +} diff --git a/android/src/main/java/com/vydia/RNUploader/GlobalRequestObserverDelegate.kt b/android/src/main/java/com/vydia/RNUploader/GlobalRequestObserverDelegate.kt deleted file mode 100644 index c89d495a..00000000 --- a/android/src/main/java/com/vydia/RNUploader/GlobalRequestObserverDelegate.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.vydia.RNUploader - -import android.content.Context -import android.util.Log -import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.WritableMap -import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter -import net.gotev.uploadservice.data.UploadInfo -import net.gotev.uploadservice.network.ServerResponse -import net.gotev.uploadservice.observer.request.RequestObserverDelegate - -class GlobalRequestObserverDelegate(reactContext: ReactApplicationContext) : RequestObserverDelegate { - private val TAG = "UploadReceiver" - - private var reactContext: ReactApplicationContext = reactContext - - override fun onCompleted(context: Context, uploadInfo: UploadInfo) { - } - - override fun onCompletedWhileNotObserving() { - } - - override fun onError(context: Context, uploadInfo: UploadInfo, exception: Throwable) { - val params = Arguments.createMap() - params.putString("id", uploadInfo.uploadId) - - // Make sure we do not try to call getMessage() on a null object - if (exception != null) { - params.putString("error", exception.message) - } else { - params.putString("error", "Unknown exception") - } - - sendEvent("error", params, context) - } - - override fun onProgress(context: Context, uploadInfo: UploadInfo) { - val params = Arguments.createMap() - params.putString("id", uploadInfo.uploadId) - params.putInt("progress", uploadInfo.progressPercent) //0-100 - - sendEvent("progress", params, context) - } - - override fun onSuccess(context: Context, uploadInfo: UploadInfo, serverResponse: ServerResponse) { - val headers = Arguments.createMap() - for ((key, value) in serverResponse.headers) { - headers.putString(key, value) - } - val params = Arguments.createMap() - params.putString("id", uploadInfo.uploadId) - params.putInt("responseCode", serverResponse.code) - params.putString("responseBody", serverResponse.bodyString) - params.putMap("responseHeaders", headers) - sendEvent("completed", params, context) - } - - /** - * Sends an event to the JS module. - */ - private fun sendEvent(eventName: String, params: WritableMap?, context: Context) { - reactContext?.getJSModule(RCTDeviceEventEmitter::class.java)?.emit("RNFileUploader-$eventName", params) - ?: Log.e(TAG, "sendEvent() failed due reactContext == null!") - } -} diff --git a/android/src/main/java/com/vydia/RNUploader/NotificationActions.kt b/android/src/main/java/com/vydia/RNUploader/NotificationActions.kt deleted file mode 100644 index 0aed437d..00000000 --- a/android/src/main/java/com/vydia/RNUploader/NotificationActions.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.vydia.RNUploader - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent - -class NotificationActions { - var INTENT_ACTION = "com.vydia.RNUploader.notification.action" - - val PARAM_ACTION = "action" - val PARAM_UPLOAD_ID = "uploadId" - - val ACTION_CANCEL_UPLOAD = "cancelUpload" - - - fun getCancelUploadAction(context: Context?, - requestCode: Int, - uploadID: String?): PendingIntent? { - val intent = Intent(INTENT_ACTION) - intent.putExtra(PARAM_ACTION, ACTION_CANCEL_UPLOAD) - intent.putExtra(PARAM_UPLOAD_ID, uploadID) - return PendingIntent.getBroadcast(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT) - } -} diff --git a/android/src/main/java/com/vydia/RNUploader/NotificationActionsReceiver.kt b/android/src/main/java/com/vydia/RNUploader/NotificationActionsReceiver.kt deleted file mode 100644 index 8d7752ce..00000000 --- a/android/src/main/java/com/vydia/RNUploader/NotificationActionsReceiver.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.vydia.RNUploader - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.util.Log -import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.WritableMap -import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter -import net.gotev.uploadservice.UploadService - -class NotificationActionsReceiver : BroadcastReceiver() { - - private val TAG = "NotificationActReceiver" - - private val reactContext: ReactApplicationContext? = null - override fun onReceive(context: Context?, intent: Intent?) { - if (intent == null || NotificationActions().INTENT_ACTION == intent.action) { - return - } - - if (NotificationActions().ACTION_CANCEL_UPLOAD == intent.getStringExtra(NotificationActions().PARAM_ACTION)) { - onUserRequestedUploadCancellation(context!!, intent.getStringExtra(NotificationActions().PARAM_UPLOAD_ID)!!) - } - } - - private fun onUserRequestedUploadCancellation(context: Context, uploadId: String) { - Log.e("CANCEL_UPLOAD", "User requested cancellation of upload with ID: $uploadId") - UploadService.stopUpload(uploadId) - val params = Arguments.createMap() - params.putString("id", uploadId) - sendEvent("cancelled", params, context) - } - - /** - * Sends an event to the JS module. - */ - private fun sendEvent(eventName: String, params: WritableMap?, context: Context) { - reactContext?.getJSModule(RCTDeviceEventEmitter::class.java)?.emit("RNFileUploader-$eventName", params) - ?: Log.e(TAG, "sendEvent() failed due reactContext == null!") - } - -} diff --git a/android/src/main/java/com/vydia/RNUploader/NotificationReceiver.kt b/android/src/main/java/com/vydia/RNUploader/NotificationReceiver.kt new file mode 100644 index 00000000..e519faaf --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader/NotificationReceiver.kt @@ -0,0 +1,17 @@ +package com.vydia.RNUploader + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +// there's no way to directly open the app from the notification without reloading it, +// so we use a BroadcastReceiver to listen to the notification intent +class NotificationReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + context ?: return + val packageName = context.packageName + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) + context.startActivity(launchIntent) + EventReporter.notification() + } +} diff --git a/android/src/main/java/com/vydia/RNUploader/Upload.kt b/android/src/main/java/com/vydia/RNUploader/Upload.kt new file mode 100644 index 00000000..5afdac69 --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader/Upload.kt @@ -0,0 +1,57 @@ +package com.vydia.RNUploader + +import com.facebook.react.bridge.ReadableMap +import java.util.* + +// Data model of a single upload +// Can be created from RN's ReadableMap +// Can be used for JSON deserialization +data class Upload( + val id: String, + val url: String, + val path: String, + val method: String, + val maxRetries: Int, + val wifiOnly: Boolean, + val headers: Map, + val notificationId: String, + val notificationTitle: String, + val notificationTitleNoInternet: String, + val notificationTitleNoWifi: String, + val notificationChannel: String, +) { + class MissingOptionException(optionName: String) : + IllegalArgumentException("Missing '$optionName'") + + companion object { + fun fromReadableMap(map: ReadableMap) = Upload( + id = map.getString("customUploadId") ?: UUID.randomUUID().toString(), + url = map.getString(Upload::url.name) ?: throw MissingOptionException(Upload::url.name), + path = map.getString(Upload::path.name) ?: throw MissingOptionException(Upload::path.name), + method = map.getString(Upload::method.name) ?: "POST", + maxRetries = if (map.hasKey(Upload::maxRetries.name)) map.getInt(Upload::maxRetries.name) else 5, + wifiOnly = if (map.hasKey(Upload::wifiOnly.name)) map.getBoolean(Upload::wifiOnly.name) else false, + headers = map.getMap(Upload::headers.name).let { headers -> + if (headers == null) return@let mapOf() + val map = mutableMapOf() + for (entry in headers.entryIterator) { + map[entry.key] = entry.value.toString() + } + return@let map + }, + notificationId = map.getString(Upload::notificationId.name) + ?: throw MissingOptionException(Upload::notificationId.name), + notificationTitle = map.getString(Upload::notificationTitle.name) + ?: throw MissingOptionException(Upload::notificationTitle.name), + notificationTitleNoInternet = map.getString(Upload::notificationTitleNoInternet.name) + ?: throw MissingOptionException(Upload::notificationTitleNoInternet.name), + notificationTitleNoWifi = map.getString(Upload::notificationTitleNoWifi.name) + ?: throw MissingOptionException(Upload::notificationTitleNoWifi.name), + notificationChannel = map.getString(Upload::notificationChannel.name) + ?: throw MissingOptionException(Upload::notificationChannel.name), + ) + } +} + + + diff --git a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt b/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt new file mode 100644 index 00000000..ee1bd78a --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt @@ -0,0 +1,64 @@ +package com.vydia.RNUploader + +import android.content.Context +import android.os.Handler +import android.os.Looper +import androidx.work.WorkManager +import com.vydia.RNUploader.UploaderModule.Companion.WORKER_TAG + +// Stores and aggregates total progress from all workers +class UploadProgress { + + companion object { + private fun storage(context: Context) = + context.getSharedPreferences("RNFileUpload-Progress", Context.MODE_PRIVATE) + + @Synchronized + fun set(context: Context, uploadId: String, bytesUploaded: Long, fileSize: Long) = + storage(context).edit() + .putLong("$uploadId-uploaded", bytesUploaded) + .putLong("$uploadId-size", fileSize) + .apply() + + @Synchronized + fun remove(context: Context, uploadId: String) = + storage(context).edit() + .remove("$uploadId-uploaded") + .remove("$uploadId-size") + .apply() + + @Synchronized + fun total(context: Context): Double { + val storage = storage(context) + + val totalBytesUploaded = storage.all.keys + .filter { it.endsWith("-uploaded") } + .sumOf { storage.getLong(it, 0L) } + + val totalFileSize = storage.all.keys + .filter { it.endsWith("-size") } + .sumOf { storage.getLong(it, 0L) } + + if (totalFileSize == 0L) return 0.0 + return (totalBytesUploaded.toDouble() * 100 / totalFileSize) + } + + private val handler = Handler(Looper.getMainLooper()) + + // Attempt to clear in 2 seconds. This is the simplest way to let the + // last worker reset the overall progress. + // Clearing progress ensures the notification starts at 0% next time. + fun scheduleClearing(context: Context) = + handler.postDelayed({ clearIfNeeded(context) }, 2000) + + @Synchronized + fun clearIfNeeded(context: Context) { + val workManager = WorkManager.getInstance(context) + val works = workManager.getWorkInfosByTag(WORKER_TAG).get() + if (works.any { !it.state.isFinished }) return + + val storage = storage(context) + storage.edit().clear().apply() + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt b/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt new file mode 100644 index 00000000..57da5aac --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt @@ -0,0 +1,78 @@ +package com.vydia.RNUploader + +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.* +import okhttp3.Headers.Companion.toHeaders +import okhttp3.RequestBody.Companion.asRequestBody +import okio.Buffer +import okio.BufferedSink +import okio.ForwardingSink +import okio.buffer +import java.io.File +import java.io.IOException +import kotlin.coroutines.resumeWithException + +// Throttling interval of progress reports +private const val PROGRESS_INTERVAL = 500 // milliseconds + + +// make an upload request using okhttp +suspend fun okhttpUpload( + client: OkHttpClient, + upload: Upload, + file: File, + onProgress: (Long) -> Unit +) = + suspendCancellableCoroutine { continuation -> + val requestBody = file.asRequestBody() + var lastProgressReport = 0L + fun throttled(): Boolean { + val now = System.currentTimeMillis() + if (now - lastProgressReport < PROGRESS_INTERVAL) return true + lastProgressReport = now + return false + } + + val request = Request.Builder() + .url(upload.url) + .headers(upload.headers.toHeaders()) + .method(upload.method, withProgressListener(requestBody) { progress -> + if (!throttled()) onProgress(progress) + }) + .build() + + val call = client.newCall(request) + continuation.invokeOnCancellation { call.cancel() } + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) = + continuation.resumeWithException(e) + + override fun onResponse(call: Call, response: Response) = + continuation.resumeWith(Result.success(response)) + }) + } + +// create a request body that allows us to listen to progress. +// okhttp has no built-in way of reporting progress +private fun withProgressListener( + body: RequestBody, + onProgress: (Long) -> Unit +) = object : RequestBody() { + override fun contentType() = body.contentType() + override fun contentLength() = body.contentLength() + override fun writeTo(sink: BufferedSink) { + val countingSink = object : ForwardingSink(sink) { + var bytesWritten = 0L + + override fun write(source: Buffer, byteCount: Long) { + super.write(source, byteCount) + bytesWritten += byteCount + onProgress(bytesWritten) + } + } + + val bufferedSink = countingSink.buffer() + body.writeTo(bufferedSink) + bufferedSink.flush() + } +} \ No newline at end of file diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt new file mode 100644 index 00000000..a3fc701b --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt @@ -0,0 +1,284 @@ +package com.vydia.RNUploader + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.ConnectivityManager +import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED +import android.net.NetworkCapabilities.TRANSPORT_WIFI +import android.os.Build +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Response +import java.io.File +import java.io.IOException +import java.net.UnknownHostException +import java.util.concurrent.TimeUnit + +// All workers will start `doWork` immediately but only 1 request is active at a time. +private const val MAX_CONCURRENCY = 1 + +// Retry delay +private val RETRY_DELAY = TimeUnit.SECONDS.toMillis(10L) + +// Max total time for a single request to complete +// This is 24hrs so plenty of time for large uploads +// Worst case is the time maxes out and the upload gets restarted. +// Not using unlimited time to prevent unexpected behaviors. +private const val REQUEST_TIMEOUT = 24L +private val REQUEST_TIMEOUT_UNIT = TimeUnit.HOURS + +// Control max concurrent requests using semaphore to instead of using +// `maxConnectionsCount` in HttpClient as the latter introduces a delay between requests +private val semaphore = Semaphore(MAX_CONCURRENCY) + +// Use Okhttp as it provides the most standard behaviors even though it's not coroutine friendly +private val client = OkHttpClient.Builder() + .callTimeout(REQUEST_TIMEOUT, REQUEST_TIMEOUT_UNIT) + .build() + +private enum class Connectivity { NoWifi, NoInternet, Ok } + +class UploadWorker(private val context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + + enum class Input { Params } + + private lateinit var upload: Upload + private var retries = 0 + private var connectivity = Connectivity.Ok + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + // Retrieve the upload. If this throws errors, error reporting won't work. + // However, the only way it has errors is the implementation is incorrect, + // which can be caught in development + val paramsJson = inputData.getString(Input.Params.name) ?: throw Throwable("No Params") + upload = Gson().fromJson(paramsJson, Upload::class.java) + + // initialization, errors thrown here won't be retried + try { + // `setForeground` is recommended for long-running workers. + // Foreground mode helps prioritize the worker, reducing the risk + // of it being killed during low memory or Doze/App Standby situations. + // ⚠️ This should be called in the foreground + setForeground(getForegroundInfo()) + } catch (error: Throwable) { + if (!checkAndHandleCancellation()) handleError(error) + throw error + } + + + // Complex work, errors thrown below here trigger retry. + // We don't let WorkManager manage retries and network constraints as it's very buggy. + // i.e. we'd occasionally get BackgroundServiceStartNotAllowedException, + // or ForegroundServiceStartNotAllowedException, or "isStopped" gets set to "true" + // for no reason + var isRetried = false + while (true) { + try { + // - "delay" should be within the "try" block to account for worker cancellation, + // which cancels the delay immediately and throws CancellationException. + // - Linear backoff instead of exponential. One reason for this is we retry on + // invalid connections. Exponential will take too long. If the server flakes and + // returns 500s, we don't retry but consider the request successful. + // This is consistent with iOS behavior. User gets notifications for + // these server issues and can manually retry. Since 500s are currently rare, + // it's likely ok. If they're too frequent, we can consider adding exponential + // backoff for them. + if (isRetried) delay(RETRY_DELAY) + isRetried = true + + val response = upload() ?: continue + handleSuccess(response) + return@withContext Result.success() + } catch (error: Throwable) { + if (checkAndHandleCancellation()) throw error + if (checkRetry(error)) continue + handleError(error) + throw error + } + } + + // This should never happen. Only here to satisfy the type check + return@withContext Result.failure() + } + + private suspend fun upload(): Response? = withContext(Dispatchers.IO) { + val file = File(upload.path) + val size = file.length() + + // Register progress asap so the total progress is accurate + // This needs to happen before the semaphore wait + handleProgress(0, size) + + // Don't bother to run on an invalid network + if (!validateAndReportConnectivity()) return@withContext null + + // wait for its turn to run + semaphore.acquire() + + try { + val response = okhttpUpload(client, upload, file) { progress -> + launch { handleProgress(progress, size) } + } + + handleProgress(size, size) + return@withContext response + } + // don't catch, propagate error up + finally { + semaphore.release() + } + } + + private suspend fun handleProgress(bytesSentTotal: Long, fileSize: Long) { + UploadProgress.set(context, upload.id, bytesSentTotal, fileSize) + EventReporter.progress(upload.id, bytesSentTotal, fileSize) + setForeground(getForegroundInfo()) + } + + private fun handleSuccess(response: Response) { + UploadProgress.scheduleClearing(context) + EventReporter.success(upload.id, response) + } + + private fun handleError(error: Throwable) { + UploadProgress.remove(context, upload.id) + UploadProgress.scheduleClearing(context) + EventReporter.error(upload.id, error) + } + + // Check if cancelled by user or new worker with same ID + // Worker won't rerun, perform teardown + private fun checkAndHandleCancellation(): Boolean { + if (!isStopped) return false + + UploadProgress.remove(context, upload.id) + UploadProgress.scheduleClearing(context) + EventReporter.cancelled(upload.id) + return true + } + + /** @return whether to retry */ + private suspend fun checkRetry(error: Throwable): Boolean { + var unlimitedRetry = false + + // Error was thrown due to unmet network preferences. + // Also happens every time you switch from one network to any other + if (!validateAndReportConnectivity()) unlimitedRetry = true + // Due to the flaky nature of networking, sometimes the network is + // valid but the URL is still inaccessible, so keep waiting until + // the URL is accessible + else if (error is UnknownHostException) unlimitedRetry = true + // There are many IOExceptions that only differ by messages, + // so we can't check using class, but theoretically, + // only the one caused by file not existing should stop the retry. + // The rest should be related to flaky network or flaky file I/O, + // where we can retry without limit. + else if (error is IOException) { + try { + if (!File(upload.path).exists()) return false + unlimitedRetry = true + } catch (_: Throwable) { + // read file error, can't do anything but retry + unlimitedRetry = false + } + } + + retries = if (unlimitedRetry) 0 else retries + 1 + return retries <= upload.maxRetries + } + + // Checks connection and alerts connection issues + private suspend fun validateAndReportConnectivity(): Boolean { + this.connectivity = validateConnectivity(context, upload.wifiOnly) + // alert connectivity mode + setForeground(getForegroundInfo()) + return this.connectivity == Connectivity.Ok + } + + // builds the notification required to enable Foreground mode + override suspend fun getForegroundInfo(): ForegroundInfo { + // All workers share the same notification that shows the total progress + val id = upload.notificationId.hashCode() + val channel = upload.notificationChannel + val progress = UploadProgress.total(context) + val progress2Decimals = "%.2f".format(progress) + val title = when (connectivity) { + Connectivity.NoWifi -> upload.notificationTitleNoWifi + Connectivity.NoInternet -> upload.notificationTitleNoInternet + Connectivity.Ok -> upload.notificationTitle + } + + // Custom layout for progress notification. + // The default hides the % text. This one shows it on the right, + // like most examples in various docs. + val content = RemoteViews(context.packageName, R.layout.notification) + content.setTextViewText(R.id.notification_title, title) + content.setTextViewText(R.id.notification_progress, "${progress2Decimals}%") + content.setProgressBar(R.id.notification_progress_bar, 100, progress.toInt(), false) + + val notification = NotificationCompat.Builder(context, channel).run { + // Starting Android 12, the notification shows up with a confusing delay of 10s. + // This fixes that delay. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + foregroundServiceBehavior = Notification.FOREGROUND_SERVICE_IMMEDIATE + + // Required by android. Here we use the system's default upload icon + setSmallIcon(android.R.drawable.stat_sys_upload) + // These prevent the notification from being force-dismissed or dismissed when pressed + setOngoing(true) + setAutoCancel(false) + // These help show the same custom content when the notification collapses and expands + setCustomContentView(content) + setCustomBigContentView(content) + // opens the app when the notification is pressed + setContentIntent(openAppIntent(context)) + build() + } + + // Starting Android 14, FOREGROUND_SERVICE_TYPE_DATA_SYNC is mandatory, otherwise app will crash + return if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) + ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + else + ForegroundInfo(id, notification) + } +} + +// This is outside and synchronized to ensure consistent status across workers +@Synchronized +private fun validateConnectivity(context: Context, wifiOnly: Boolean): Connectivity { + val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = manager.activeNetwork + val capabilities = manager.getNetworkCapabilities(network) + + val hasInternet = capabilities?.hasCapability(NET_CAPABILITY_VALIDATED) == true + + // not wifiOnly, return early + if (!wifiOnly) return if (hasInternet) Connectivity.Ok else Connectivity.NoInternet + + // handle wifiOnly + return if (hasInternet && capabilities?.hasTransport(TRANSPORT_WIFI) == true) + Connectivity.Ok + else + Connectivity.NoWifi // don't return NoInternet here, more direct to request to join wifi +} + + +private fun openAppIntent(context: Context): PendingIntent? { + val intent = Intent(context, NotificationReceiver::class.java) + val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + return PendingIntent.getBroadcast(context, "RNFileUpload-notification".hashCode(), intent, flags) +} diff --git a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt index 0258e95c..0b6db02b 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt @@ -1,329 +1,112 @@ package com.vydia.RNUploader -import android.app.Application -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import android.os.Build import android.util.Log -import android.webkit.MimeTypeMap -import com.facebook.react.BuildConfig +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf import com.facebook.react.bridge.* -import net.gotev.uploadservice.UploadService -import net.gotev.uploadservice.UploadServiceConfig.httpStack -import net.gotev.uploadservice.UploadServiceConfig.initialize -import net.gotev.uploadservice.data.UploadNotificationConfig -import net.gotev.uploadservice.data.UploadNotificationStatusConfig -import net.gotev.uploadservice.observer.request.GlobalRequestObserver -import net.gotev.uploadservice.okhttp.OkHttpStack -import net.gotev.uploadservice.protocols.binary.BinaryUploadRequest -import net.gotev.uploadservice.protocols.multipart.MultipartUploadRequest -import okhttp3.OkHttpClient -import java.io.File -import java.util.concurrent.TimeUnit +import com.google.gson.Gson -class UploaderModule(val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext), LifecycleEventListener { - private val TAG = "UploaderBridge" - private var notificationChannelID = "BackgroundUploadChannel" - private var isGlobalRequestObserver = false - override fun getName(): String { - return "RNFileUploader" - } +class UploaderModule(context: ReactApplicationContext) : + ReactContextBaseJavaModule(context) { - /* - Gets file information for the path specified. Example valid path is: /storage/extSdCard/DCIM/Camera/20161116_074726.mp4 - Returns an object such as: {extension: "mp4", size: "3804316", exists: true, mimeType: "video/mp4", name: "20161116_074726.mp4"} - */ - @ReactMethod - fun getFileInfo(path: String?, promise: Promise) { - try { - val params = Arguments.createMap() - val fileInfo = File(path) - params.putString("name", fileInfo.name) - if (!fileInfo.exists() || !fileInfo.isFile) { - params.putBoolean("exists", false) - } else { - params.putBoolean("exists", true) - params.putString("size", fileInfo.length().toString()) //use string form of long because there is no putLong and converting to int results in a max size of 17.2 gb, which could happen. Javascript will need to convert it to a number - val extension = MimeTypeMap.getFileExtensionFromUrl(path) - params.putString("extension", extension) - val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase()) - params.putString("mimeType", mimeType) - } - promise.resolve(params) - } catch (exc: Exception) { - exc.printStackTrace() - Log.e(TAG, exc.message, exc) - promise.reject(exc) - } + companion object { + const val TAG = "RNFileUploader.UploaderModule" + const val WORKER_TAG = "RNFileUploader" + var reactContext: ReactApplicationContext? = null + private set } - private fun configureUploadServiceHTTPStack(options: ReadableMap, promise: Promise) { - var followRedirects = true - var followSslRedirects = true - var retryOnConnectionFailure = true - var connectTimeout = 15 - var writeTimeout = 30 - var readTimeout = 30 - //TODO: make 'cache' customizable - if (options.hasKey("followRedirects")) { - if (options.getType("followRedirects") != ReadableType.Boolean) { - promise.reject(IllegalArgumentException("followRedirects must be a boolean.")) - return - } - followRedirects = options.getBoolean("followRedirects") - } - if (options.hasKey("followSslRedirects")) { - if (options.getType("followSslRedirects") != ReadableType.Boolean) { - promise.reject(IllegalArgumentException("followSslRedirects must be a boolean.")) - return - } - followSslRedirects = options.getBoolean("followSslRedirects") - } - if (options.hasKey("retryOnConnectionFailure")) { - if (options.getType("retryOnConnectionFailure") != ReadableType.Boolean) { - promise.reject(IllegalArgumentException("retryOnConnectionFailure must be a boolean.")) - return - } - retryOnConnectionFailure = options.getBoolean("retryOnConnectionFailure") - } - if (options.hasKey("connectTimeout")) { - if (options.getType("connectTimeout") != ReadableType.Number) { - promise.reject(IllegalArgumentException("connectTimeout must be a number.")) - return - } - connectTimeout = options.getInt("connectTimeout") - } - if (options.hasKey("writeTimeout")) { - if (options.getType("writeTimeout") != ReadableType.Number) { - promise.reject(IllegalArgumentException("writeTimeout must be a number.")) - return - } - writeTimeout = options.getInt("writeTimeout") - } - if (options.hasKey("readTimeout")) { - if (options.getType("readTimeout") != ReadableType.Number) { - promise.reject(IllegalArgumentException("readTimeout must be a number.")) - return - } - readTimeout = options.getInt("readTimeout") - } - httpStack = OkHttpStack(OkHttpClient().newBuilder() - .followRedirects(followRedirects) - .followSslRedirects(followSslRedirects) - .retryOnConnectionFailure(retryOnConnectionFailure) - .connectTimeout(connectTimeout.toLong(), TimeUnit.SECONDS) - .writeTimeout(writeTimeout.toLong(), TimeUnit.SECONDS) - .readTimeout(readTimeout.toLong(), TimeUnit.SECONDS) - .cache(null) - .build()) + private val workManager = WorkManager.getInstance(context) + + init { + reactContext = context + // workers may be killed abruptly for whatever reasons, + // so they might not have had a chance to clear the progress data. + UploadProgress.clearIfNeeded(context) } + + override fun getName(): String = "RNFileUploader" + + /* * Starts a file upload. * Returns a promise with the string ID of the upload. */ @ReactMethod - fun startUpload(options: ReadableMap, promise: Promise) { - for (key in arrayOf("url", "path")) { - if (!options.hasKey(key)) { - promise.reject(java.lang.IllegalArgumentException("Missing '$key' field.")) - return - } - if (options.getType(key) != ReadableType.String) { - promise.reject(java.lang.IllegalArgumentException("$key must be a string.")) - return - } - } - if (options.hasKey("headers") && options.getType("headers") != ReadableType.Map) { - promise.reject(java.lang.IllegalArgumentException("headers must be a hash.")) - return - } - if (options.hasKey("notification") && options.getType("notification") != ReadableType.Map) { - promise.reject(java.lang.IllegalArgumentException("notification must be a hash.")) - return - } - configureUploadServiceHTTPStack(options, promise) - var requestType: String? = "raw" - if (options.hasKey("type")) { - requestType = options.getString("type") - if (requestType == null) { - promise.reject(java.lang.IllegalArgumentException("type must be string.")) - return - } - if (requestType != "raw" && requestType != "multipart") { - promise.reject(java.lang.IllegalArgumentException("type should be string: raw or multipart.")) - return - } - } - val notification: WritableMap = WritableNativeMap() - notification.putBoolean("enabled", true) - if (options.hasKey("notification")) { - notification.merge(options.getMap("notification")!!) - } - - val application = reactContext.applicationContext as Application - - reactContext.addLifecycleEventListener(this) - - if (notification.hasKey("notificationChannel")) { - notificationChannelID = notification.getString("notificationChannel")!! - } - - createNotificationChannel() - - initialize(application, notificationChannelID, BuildConfig.DEBUG) - - if(!isGlobalRequestObserver) { - isGlobalRequestObserver = true - GlobalRequestObserver(application, GlobalRequestObserverDelegate(reactContext)) - } - - val url = options.getString("url") - val filePath = options.getString("path") - val method = if (options.hasKey("method") && options.getType("method") == ReadableType.String) options.getString("method") else "POST" - val maxRetries = if (options.hasKey("maxRetries") && options.getType("maxRetries") == ReadableType.Number) options.getInt("maxRetries") else 2 - val customUploadId = if (options.hasKey("customUploadId") && options.getType("method") == ReadableType.String) options.getString("customUploadId") else null + fun startUpload(rawOptions: ReadableMap, promise: Promise) { try { - val request = if (requestType == "raw") { - BinaryUploadRequest(this.reactApplicationContext, url!!) - .setFileToUpload(filePath!!) - } else { - if (!options.hasKey("field")) { - promise.reject(java.lang.IllegalArgumentException("field is required field for multipart type.")) - return - } - if (options.getType("field") != ReadableType.String) { - promise.reject(java.lang.IllegalArgumentException("field must be string.")) - return - } - MultipartUploadRequest(this.reactApplicationContext, url!!) - .addFileToUpload(filePath!!, options.getString("field")!!) + val id = startUpload(rawOptions) + promise.resolve(id) + } catch (exc: Throwable) { + if (exc !is Upload.MissingOptionException) { + exc.printStackTrace() + Log.e(TAG, exc.message, exc) } - request.setMethod(method!!) - .setMaxRetries(maxRetries) - if (notification.getBoolean("enabled")) { - val notificationConfig = UploadNotificationConfig( - notificationChannelId = notificationChannelID, - isRingToneEnabled = notification.hasKey("enableRingTone") && notification.getBoolean("enableRingTone"), - progress = UploadNotificationStatusConfig( - title = if (notification.hasKey("onProgressTitle")) notification.getString("onProgressTitle")!! else "", - message = if (notification.hasKey("onProgressMessage")) notification.getString("onProgressMessage")!! else "" - ), - success = UploadNotificationStatusConfig( - title = if (notification.hasKey("onCompleteTitle")) notification.getString("onCompleteTitle")!! else "", - message = if (notification.hasKey("onCompleteMessage")) notification.getString("onCompleteMessage")!! else "", - autoClear = notification.hasKey("autoClear") && notification.getBoolean("autoClear") - ), - error = UploadNotificationStatusConfig( - title = if (notification.hasKey("onErrorTitle")) notification.getString("onErrorTitle")!! else "", - message = if (notification.hasKey("onErrorMessage")) notification.getString("onErrorMessage")!! else "" - ), - cancelled = UploadNotificationStatusConfig( - title = if (notification.hasKey("onCancelledTitle")) notification.getString("onCancelledTitle")!! else "", - message = if (notification.hasKey("onCancelledMessage")) notification.getString("onCancelledMessage")!! else "" - ) - ) - request.setNotificationConfig { _, _ -> - notificationConfig - } - } - if (options.hasKey("parameters")) { - if (requestType == "raw") { - promise.reject(java.lang.IllegalArgumentException("Parameters supported only in multipart type")) - return - } - val parameters = options.getMap("parameters") - val keys = parameters!!.keySetIterator() - while (keys.hasNextKey()) { - val key = keys.nextKey() - if (parameters.getType(key) != ReadableType.String) { - promise.reject(java.lang.IllegalArgumentException("Parameters must be string key/values. Value was invalid for '$key'")) - return - } - request.addParameter(key, parameters.getString(key)!!) - } - } - if (options.hasKey("headers")) { - val headers = options.getMap("headers") - val keys = headers!!.keySetIterator() - while (keys.hasNextKey()) { - val key = keys.nextKey() - if (headers.getType(key) != ReadableType.String) { - promise.reject(java.lang.IllegalArgumentException("Headers must be string key/values. Value was invalid for '$key'")) - return - } - request.addHeader(key, headers.getString(key)!!) - } - } - if (customUploadId != null) - request.setUploadID(customUploadId) - - val uploadId = request.startUpload() - promise.resolve(uploadId) - } catch (exc: java.lang.Exception) { - exc.printStackTrace() - Log.e(TAG, exc.message, exc) promise.reject(exc) } } + /** + * @return whether the upload was started + */ + private fun startUpload(options: ReadableMap): String { + val upload = Upload.fromReadableMap(options) + val data = Gson().toJson(upload) + + val request = OneTimeWorkRequestBuilder() + .addTag(WORKER_TAG) + .setInputData(workDataOf(UploadWorker.Input.Params.name to data)) + .build() + + workManager + // Using KEEP policy to prevent it from cancelling the work if it's already running. + // Otherwise, it will emit "cancelled" and then go on to emit "progress" events, + // which is confusing and quite difficult to manage. "cancelled" should be reserved for + // when the user explicitly cancels the upload. + .beginUniqueWork(upload.id, ExistingWorkPolicy.KEEP, request) + .enqueue() + + return upload.id + } + + /* * Cancels file upload * Accepts upload ID as a first argument, this upload will be cancelled * Event "cancelled" will be fired when upload is cancelled. */ @ReactMethod - fun cancelUpload(cancelUploadId: String?, promise: Promise) { - if (cancelUploadId !is String) { - promise.reject(java.lang.IllegalArgumentException("Upload ID must be a string")) - return - } + fun cancelUpload(uploadId: String, promise: Promise) { try { - UploadService.stopUpload(cancelUploadId) + workManager.cancelUniqueWork(uploadId) promise.resolve(true) - } catch (exc: java.lang.Exception) { + } catch (exc: Throwable) { exc.printStackTrace() Log.e(TAG, exc.message, exc) promise.reject(exc) } } + /* * Cancels all file uploads */ @ReactMethod fun stopAllUploads(promise: Promise) { try { - UploadService.stopAllUploads() + workManager.cancelAllWorkByTag(WORKER_TAG) promise.resolve(true) - } catch (exc: java.lang.Exception) { + } catch (exc: Throwable) { exc.printStackTrace() Log.e(TAG, exc.message, exc) promise.reject(exc) } } - // Customize the notification channel as you wish. This is only for a bare minimum example - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= 26) { - val channel = NotificationChannel( - notificationChannelID, - "Background Upload Channel", - NotificationManager.IMPORTANCE_LOW - ) - val manager = reactApplicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel(channel) - } - } - - override fun onHostResume() { - } - override fun onHostPause() { - } - - override fun onHostDestroy() { - } } + diff --git a/android/src/main/java/com/vydia/RNUploader/UploaderReactPackage.java b/android/src/main/java/com/vydia/RNUploader/UploaderReactPackage.java index 227bec81..dca70178 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploaderReactPackage.java +++ b/android/src/main/java/com/vydia/RNUploader/UploaderReactPackage.java @@ -15,21 +15,21 @@ */ public class UploaderReactPackage implements ReactPackage { - // Deprecated in RN 0.47, @todo remove after < 0.47 support remove - public List> createJSModules() { - return Collections.emptyList(); - } + // Deprecated in RN 0.47, @todo remove after < 0.47 support remove + public List> createJSModules() { + return Collections.emptyList(); + } - @Override - public List createViewManagers(ReactApplicationContext reactContext) { - return Collections.emptyList(); - } + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } - @Override - public List createNativeModules( - ReactApplicationContext reactContext) { - List modules = new ArrayList<>(); - modules.add(new UploaderModule(reactContext)); - return modules; - } + @Override + public List createNativeModules( + ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + modules.add(new UploaderModule(reactContext)); + return modules; + } } diff --git a/android/src/main/res/layout/notification.xml b/android/src/main/res/layout/notification.xml new file mode 100644 index 00000000..b5a95f19 --- /dev/null +++ b/android/src/main/res/layout/notification.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/example/RNBGUExample/.bundle/config b/example/RNBGUExample/.bundle/config new file mode 100644 index 00000000..848943bb --- /dev/null +++ b/example/RNBGUExample/.bundle/config @@ -0,0 +1,2 @@ +BUNDLE_PATH: "vendor/bundle" +BUNDLE_FORCE_RUBY_PLATFORM: 1 diff --git a/example/RNBGUExample/.eslintrc.js b/example/RNBGUExample/.eslintrc.js new file mode 100644 index 00000000..f9cf6b53 --- /dev/null +++ b/example/RNBGUExample/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + root: true, + extends: '@react-native', + rules: { + curly: ['off', 'multi-or-nest', 'consistent'], + 'react-native/no-inline-styles': 'off', + }, +}; diff --git a/example/RNBackgroundExample/.gitignore b/example/RNBGUExample/.gitignore similarity index 65% rename from example/RNBackgroundExample/.gitignore rename to example/RNBGUExample/.gitignore index 03e8f739..d5ae4566 100644 --- a/example/RNBackgroundExample/.gitignore +++ b/example/RNBGUExample/.gitignore @@ -20,6 +20,7 @@ DerivedData *.hmap *.ipa *.xcuserstate +**/.xcode.env.local # Android/IntelliJ # @@ -28,6 +29,10 @@ build/ .gradle local.properties *.iml +*.hprof +.cxx/ +*.keystore +!debug.keystore # node.js # @@ -35,12 +40,6 @@ node_modules/ npm-debug.log yarn-error.log -# BUCK -buck-out/ -\.buckd/ -*.keystore -!debug.keystore - # fastlane # # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the @@ -48,16 +47,28 @@ buck-out/ # For more information about the recommended setup visit: # https://docs.fastlane.tools/best-practices/source-control/ -*/fastlane/report.xml -*/fastlane/Preview.html -*/fastlane/screenshots +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output # Bundle artifact *.jsbundle -artifacts/ +# Ruby / CocoaPods +**/Pods/ +/vendor/bundle/ -# CocoaPods -/ios/Pods/ +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* -*.hprof +# testing +/coverage + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/example/RNBackgroundExample/.prettierrc.js b/example/RNBGUExample/.prettierrc.js similarity index 65% rename from example/RNBackgroundExample/.prettierrc.js rename to example/RNBGUExample/.prettierrc.js index 5c4de1a4..2b540746 100644 --- a/example/RNBackgroundExample/.prettierrc.js +++ b/example/RNBGUExample/.prettierrc.js @@ -1,6 +1,7 @@ module.exports = { + arrowParens: 'avoid', + bracketSameLine: true, bracketSpacing: false, - jsxBracketSameLine: true, singleQuote: true, trailingComma: 'all', }; diff --git a/example/RNBGUExample/.watchmanconfig b/example/RNBGUExample/.watchmanconfig new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/example/RNBGUExample/.watchmanconfig @@ -0,0 +1 @@ +{} diff --git a/example/RNBGUExample/App.tsx b/example/RNBGUExample/App.tsx new file mode 100644 index 00000000..e7e44655 --- /dev/null +++ b/example/RNBGUExample/App.tsx @@ -0,0 +1,194 @@ +/** + * Sample React Native App + * https://github.com/facebook/react-native + * + * @format + * @flow + */ + +import React, {useEffect, useState} from 'react'; +import { + SafeAreaView, + StyleSheet, + ScrollView, + View, + Text, + StatusBar, + Button, +} from 'react-native'; +import notifee, {AndroidImportance} from '@notifee/react-native'; +import {Colors} from 'react-native/Libraries/NewAppScreen'; + +import Upload, {UploadOptions} from 'react-native-background-upload'; + +import * as RNFS from 'react-native-fs'; + +const TEST_FILE = `${RNFS.DocumentDirectoryPath}/1MB.bin`; +const TEST_FILE_URL = + 'https://gist.githubusercontent.com/khaykov/a6105154becce4c0530da38e723c2330/raw/41ab415ac41c93a198f7da5b47d604956157c5c3/gistfile1.txt'; +const UPLOAD_URL = 'https://httpbin.org/put/404'; + +const App = () => { + const [uploadId, setUploadId] = useState(); + const [progress, setProgress] = useState(); + const [testFileDownload, setTestFileDownload] = useState< + 'downloading' | 'downloaded' + >(); + + useEffect(() => { + Upload.addListener('progress', null, data => { + setProgress(data.progress); + console.log(`Progress: ${data.progress}%`); + }); + Upload.addListener('error', null, data => { + console.log(`Error: ${data.error}%`); + }); + Upload.addListener('completed', null, data => { + console.log('Completed!', data); + }); + }, []); + + useEffect(() => { + RNFS.exists('file://' + TEST_FILE) + .then(exists => { + if (exists) return; + + setTestFileDownload('downloading'); + return RNFS.downloadFile({fromUrl: TEST_FILE_URL, toFile: TEST_FILE}) + .promise; + }) + .then(() => setTestFileDownload('downloaded')); + }, []); + + const onPressUpload = async () => { + await notifee.requestPermission({alert: true, sound: true}); + + const channelId = 'RNBGUExample'; + await notifee.createChannel({ + id: channelId, + name: channelId, + importance: AndroidImportance.LOW, + }); + + const uploadOpts: UploadOptions = { + android: { + notificationId: channelId, + notificationTitle: channelId, + notificationTitleNoWifi: 'No wifi', + notificationTitleNoInternet: 'No internet', + notificationChannel: channelId, + }, + type: 'raw', + url: UPLOAD_URL, + path: TEST_FILE, + method: 'POST', + headers: {}, + }; + + Upload.startUpload(uploadOpts) + .then(uploadId => { + console.log( + `Upload started with options: ${JSON.stringify(uploadOpts)}`, + ); + setUploadId(uploadId); + setProgress(0); + }) + .catch(function (err) { + setUploadId(undefined); + setProgress(undefined); + console.log('Upload error!', err); + }); + }; + + return ( + <> + + + + {testFileDownload === 'downloading' && ( + Downloading test file... + )} + + {testFileDownload === 'downloaded' && ( + + + +