diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..13b97c7 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,82 @@ +module.exports = { + env: { + es2020: true, + jest: true, + }, + parser: '@babel/eslint-parser', + extends: [ + 'standard', + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + ], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 11, + sourceType: 'module', + }, + plugins: [ + 'react', + 'react-hooks', + ], + settings: { + react: { + version: 'detect', + }, + }, + rules: { + indent: [ + 'error', + 2, { + SwitchCase: 1, + ignoredNodes: [ + 'TemplateLiteral', + ], + }, + ], + 'template-curly-spacing': 'off', + 'linebreak-style': [ + 'error', + 'unix', + ], + quotes: [ + 'error', + 'single', + ], + semi: [ + 'error', + 'never', + ], + 'comma-dangle': [ + 'error', + { + arrays: 'always-multiline', + objects: 'always-multiline', + imports: 'always-multiline', + exports: 'never', + functions: 'never', + }, + ], + 'no-func-assign': 'off', + 'no-class-assign': 'off', + 'no-useless-escape': 'off', + curly: [2, 'multi', 'consistent'], + 'react/prop-types': 'off', // TODO: TURN ON AND FIX ALL WARNINGS + 'react/display-name': 'off', + }, + globals: { + describe: 'readonly', + test: 'readonly', + jest: 'readonly', + expect: 'readonly', + fetch: 'readonly', + navigator: 'readonly', + __DEV__: 'readonly', + XMLHttpRequest: 'readonly', + FormData: 'readonly', + React$Element: 'readonly', + requestAnimationFrame: 'readonly', + }, +} diff --git a/__tests__/mainTest.js b/__tests__/mainTest.js index cb79a40..71537d2 100644 --- a/__tests__/mainTest.js +++ b/__tests__/mainTest.js @@ -29,19 +29,22 @@ test('download function', () => { }); test('begin event', () => { + const mockedHeaders = { Etag: '123' } return new Promise(resolve => { const beginDT = RNBackgroundDownloader.download({ id: 'testBegin', url: 'test', destination: 'test' - }).begin((expectedBytes) => { + }).begin(({ expectedBytes, headers }) => { expect(expectedBytes).toBe(9001); + expect(headers).toBe(mockedHeaders); expect(beginDT.state).toBe('DOWNLOADING'); resolve(); }); NativeEventEmitter.listeners.downloadBegin({ id: 'testBegin', - expectedBytes: 9001 + expectedBytes: 9001, + headers: mockedHeaders, }); }); }); @@ -172,4 +175,4 @@ test('wrong handler type', () => { expect(() => { dt.error('not function'); }).toThrow(); -}); \ No newline at end of file +}); diff --git a/babel.config.js b/babel.config.js index 686bb1b..cf1f9fb 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,3 @@ module.exports = { - presets: ["module:metro-react-native-babel-preset"] -} \ No newline at end of file + presets: ['module:metro-react-native-babel-preset'], +} diff --git a/index.js b/index.js index 8ef577d..a5bf0db 100644 --- a/index.js +++ b/index.js @@ -1,119 +1,116 @@ -import { NativeModules, NativeEventEmitter } from 'react-native'; -const { RNBackgroundDownloader } = NativeModules; -const RNBackgroundDownloaderEmitter = new NativeEventEmitter(RNBackgroundDownloader); -import DownloadTask from './lib/downloadTask'; +import { NativeModules, NativeEventEmitter } from 'react-native' +import DownloadTask from './lib/downloadTask' +const { RNBackgroundDownloader } = NativeModules +const RNBackgroundDownloaderEmitter = new NativeEventEmitter(RNBackgroundDownloader) -const tasksMap = new Map(); -let headers = {}; +const tasksMap = new Map() +let headers = {} RNBackgroundDownloaderEmitter.addListener('downloadProgress', events => { - for (let event of events) { - let task = tasksMap.get(event.id); - if (task) { - task._onProgress(event.percent, event.written, event.total); - } - } -}); + for (const event of events) { + const task = tasksMap.get(event.id) + if (task) + task._onProgress(event.percent, event.written, event.total) + } +}) RNBackgroundDownloaderEmitter.addListener('downloadComplete', ({ id, location }) => { - let task = tasksMap.get(id); - if (task) { - task._onDone({ location }); - } - tasksMap.delete(id); -}); + const task = tasksMap.get(id) + if (task) + task._onDone({ location }) + + tasksMap.delete(id) +}) RNBackgroundDownloaderEmitter.addListener('downloadFailed', event => { - let task = tasksMap.get(event.id); - if (task) { - task._onError(event.error, event.errorcode); - } - tasksMap.delete(event.id); -}); + const task = tasksMap.get(event.id) + if (task) + task._onError(event.error, event.errorcode) + + tasksMap.delete(event.id) +}) RNBackgroundDownloaderEmitter.addListener('downloadBegin', ({ id, expectedBytes, headers }) => { - let task = tasksMap.get(id); - if (task) { - task._onBegin({ expectedBytes, headers }); - } -}); + const task = tasksMap.get(id) + if (task) + task._onBegin({ expectedBytes, headers }) +}) -export function setHeaders(h = {}) { - if (typeof h !== 'object') { - throw new Error('[RNBackgroundDownloader] headers must be an object'); - } - headers = h; +export function setHeaders (h = {}) { + if (typeof h !== 'object') + throw new Error('[RNBackgroundDownloader] headers must be an object') + + headers = h } -export function checkForExistingDownloads() { - return RNBackgroundDownloader.checkForExistingDownloads() - .then(foundTasks => { - return foundTasks.map(taskInfo => { - let task = new DownloadTask(taskInfo,tasksMap.get(taskInfo.id)); - if (taskInfo.state === RNBackgroundDownloader.TaskRunning) { - task.state = 'DOWNLOADING'; - } else if (taskInfo.state === RNBackgroundDownloader.TaskSuspended) { - task.state = 'PAUSED'; - } else if (taskInfo.state === RNBackgroundDownloader.TaskCanceling) { - task.stop(); - return null; - } else if (taskInfo.state === RNBackgroundDownloader.TaskCompleted) { - if (taskInfo.bytesWritten === taskInfo.totalBytes) { - task.state = 'DONE'; - } else { - // IOS completed the download but it was not done. - return null; - } - } - tasksMap.set(taskInfo.id, task); - return task; - }).filter(task => task !== null); - }); +export function checkForExistingDownloads () { + return RNBackgroundDownloader.checkForExistingDownloads() + .then(foundTasks => { + return foundTasks.map(taskInfo => { + const task = new DownloadTask(taskInfo, tasksMap.get(taskInfo.id)) + if (taskInfo.state === RNBackgroundDownloader.TaskRunning) { + task.state = 'DOWNLOADING' + } else if (taskInfo.state === RNBackgroundDownloader.TaskSuspended) { + task.state = 'PAUSED' + } else if (taskInfo.state === RNBackgroundDownloader.TaskCanceling) { + task.stop() + return null + } else if (taskInfo.state === RNBackgroundDownloader.TaskCompleted) { + if (taskInfo.bytesWritten === taskInfo.totalBytes) + task.state = 'DONE' + else + // IOS completed the download but it was not done. + return null + } + tasksMap.set(taskInfo.id, task) + return task + }).filter(task => task !== null) + }) } export function completeHandler (jobId) { return RNBackgroundDownloader.completeHandler(jobId) } -export function download(options) { - if (!options.id || !options.url || !options.destination) { - throw new Error('[RNBackgroundDownloader] id, url and destination are required'); +export function download (options) { + if (!options.id || !options.url || !options.destination) + throw new Error('[RNBackgroundDownloader] id, url and destination are required') + + if (options.headers && typeof options.headers === 'object') + options.headers = { + ...headers, + ...options.headers, } - if (options.headers && typeof options.headers === 'object') { - options.headers = { - ...headers, - ...options.headers - }; - } else { - options.headers = headers; - } - RNBackgroundDownloader.download(options); - let task = new DownloadTask(options.id); - tasksMap.set(options.id, task); - return task; + else + options.headers = headers + + RNBackgroundDownloader.download(options) + const task = new DownloadTask(options.id) + tasksMap.set(options.id, task) + return task } export const directories = { - documents: RNBackgroundDownloader.documents -}; + documents: RNBackgroundDownloader.documents, +} export const Network = { - WIFI_ONLY: RNBackgroundDownloader.OnlyWifi, - ALL: RNBackgroundDownloader.AllNetworks -}; + WIFI_ONLY: RNBackgroundDownloader.OnlyWifi, + ALL: RNBackgroundDownloader.AllNetworks, +} export const Priority = { - HIGH: RNBackgroundDownloader.PriorityHigh, - MEDIUM: RNBackgroundDownloader.PriorityNormal, - LOW: RNBackgroundDownloader.PriorityLow -}; + HIGH: RNBackgroundDownloader.PriorityHigh, + MEDIUM: RNBackgroundDownloader.PriorityNormal, + LOW: RNBackgroundDownloader.PriorityLow, +} export default { - download, - checkForExistingDownloads, - completeHandler, - setHeaders, - directories, - Network, - Priority -}; + download, + checkForExistingDownloads, + completeHandler, + setHeaders, + directories, + Network, + Priority, +} diff --git a/lib/downloadTask.js b/lib/downloadTask.js index 747205a..c50dc5d 100644 --- a/lib/downloadTask.js +++ b/lib/downloadTask.js @@ -1,10 +1,9 @@ -import { NativeModules } from 'react-native'; -const { RNBackgroundDownloader } = NativeModules; +import { NativeModules } from 'react-native' +const { RNBackgroundDownloader } = NativeModules -function validateHandler(handler) { - if (!(typeof handler === 'function')) { - throw new TypeError(`[RNBackgroundDownloader] expected argument to be a function, got: ${typeof handler}`); - } +function validateHandler (handler) { + if (!(typeof handler === 'function')) + throw new TypeError(`[RNBackgroundDownloader] expected argument to be a function, got: ${typeof handler}`) } export default class DownloadTask { state = 'PENDING' @@ -13,89 +12,85 @@ export default class DownloadTask { totalBytes = 0 constructor (taskInfo, originalTask) { - if (typeof taskInfo === 'string') { - this.id = taskInfo; - } else { - this.id = taskInfo.id; - this.percent = taskInfo.percent; - this.bytesWritten = taskInfo.bytesWritten; - this.totalBytes = taskInfo.totalBytes; - } + if (typeof taskInfo === 'string') { + this.id = taskInfo + } else { + this.id = taskInfo.id + this.percent = taskInfo.percent + this.bytesWritten = taskInfo.bytesWritten + this.totalBytes = taskInfo.totalBytes + } - if (originalTask) { - this._beginHandler = originalTask._beginHandler - this._progressHandler = originalTask._progressHandler - this._doneHandler = originalTask._doneHandler - this._errorHandler = originalTask._errorHandler - } + if (originalTask) { + this._beginHandler = originalTask._beginHandler + this._progressHandler = originalTask._progressHandler + this._doneHandler = originalTask._doneHandler + this._errorHandler = originalTask._errorHandler + } } begin (handler) { - validateHandler(handler); - this._beginHandler = handler; - return this; + validateHandler(handler) + this._beginHandler = handler + return this } progress (handler) { - validateHandler(handler); - this._progressHandler = handler; - return this; + validateHandler(handler) + this._progressHandler = handler + return this } done (handler) { - validateHandler(handler); - this._doneHandler = handler; - return this; + validateHandler(handler) + this._doneHandler = handler + return this } error (handler) { - validateHandler(handler); - this._errorHandler = handler; - return this; + validateHandler(handler) + this._errorHandler = handler + return this } _onBegin ({ expectedBytes, headers }) { - this.state = 'DOWNLOADING'; - if (this._beginHandler) { - this._beginHandler({ expectedBytes, headers }); - } + this.state = 'DOWNLOADING' + if (this._beginHandler) + this._beginHandler({ expectedBytes, headers }) } _onProgress (percent, bytesWritten, totalBytes) { - this.percent = percent; - this.bytesWritten = bytesWritten; - this.totalBytes = totalBytes; - if (this._progressHandler) { - this._progressHandler(percent, bytesWritten, totalBytes); - } + this.percent = percent + this.bytesWritten = bytesWritten + this.totalBytes = totalBytes + if (this._progressHandler) + this._progressHandler(percent, bytesWritten, totalBytes) } _onDone ({ location }) { - this.state = 'DONE'; - if (this._doneHandler) { - this._doneHandler({ location }); - } + this.state = 'DONE' + if (this._doneHandler) + this._doneHandler({ location }) } _onError (error, errorCode) { - this.state = 'FAILED'; - if (this._errorHandler) { - this._errorHandler(error, errorCode); - } + this.state = 'FAILED' + if (this._errorHandler) + this._errorHandler(error, errorCode) } pause () { - this.state = 'PAUSED'; - RNBackgroundDownloader.pauseTask(this.id); + this.state = 'PAUSED' + RNBackgroundDownloader.pauseTask(this.id) } resume () { - this.state = 'DOWNLOADING'; - RNBackgroundDownloader.resumeTask(this.id); + this.state = 'DOWNLOADING' + RNBackgroundDownloader.resumeTask(this.id) } stop () { - this.state = 'STOPPED'; - RNBackgroundDownloader.stopTask(this.id); + this.state = 'STOPPED' + RNBackgroundDownloader.stopTask(this.id) } } diff --git a/package.json b/package.json index 23c64a3..e1246aa 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "test": "jest", "prepublish": "jest && npm run lint", - "lint": "eslint index.js lib/**" + "lint": "eslint ." }, "repository": { "type": "git", @@ -40,15 +40,32 @@ } ], "license": "Apache-2.0", + "lint-staged": { + "*.js": "eslint --cache" + }, "peerDependencies": { "react-native": ">=0.57.0" }, "devDependencies": { + "@babel/core": "^7.16.0", + "@babel/preset-env": "^7.16.4", + "@babel/runtime": "^7.16.3", + "@babel/eslint-parser": "^7.16.3", + "@react-native-community/eslint-config": "^3.0.1", + "eslint": "7.32.0", + "eslint-config-standard": "^16.0.3", + "eslint-config-standard-jsx": "^10.0.0", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^5.1.1", + "eslint-plugin-react": "^7.27.1", + "eslint-plugin-react-hooks": "^4.3.0", + "eslint-plugin-standard": "^5.0.0", "@babel/core": "^7.4.0", "@babel/runtime": "^7.4.2", "@react-native-community/eslint-config": "^0.0.3", "babel-jest": "^24.5.0", - "eslint": "^5.16.0", + "lint-staged": ">=12", "immer": "^3.2.0", "jest": "^24.5.0", "metro-react-native-babel-preset": "^0.53.1", diff --git a/testApp/App.js b/testApp/App.js index ec4f752..b2b5b1a 100644 --- a/testApp/App.js +++ b/testApp/App.js @@ -6,227 +6,226 @@ * @flow */ -import React, { Component } from 'react'; -import { Text, SafeAreaView, TextInput, Button, FlatList, View, AsyncStorage, TouchableOpacity, Slider } from 'react-native'; -import Icon from 'react-native-vector-icons/Ionicons'; -import RNFS from 'react-native-fs'; -import produce from 'immer'; -import RNBGD from '../index'; -import styles from './Style'; +import React, { Component } from 'react' +import { Text, SafeAreaView, TextInput, Button, FlatList, View, AsyncStorage, TouchableOpacity, Slider } from 'react-native' +import Icon from 'react-native-vector-icons/Ionicons' +import RNFS from 'react-native-fs' +import produce from 'immer' +import RNBGD from '../index' +import styles from './Style' -const testURL = 'https://speed.hetzner.de/100MB.bin'; -const urlRegex = /^(?:https?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/; +const testURL = 'https://speed.hetzner.de/100MB.bin' +const urlRegex = /^(?:https?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/ -function isValid(url) { - return urlRegex.test(url); +function isValid (url) { + return urlRegex.test(url) } export default class App extends Component { - constructor(props) { - super(props); - this.idsToData = {}; - } + constructor (props) { + super(props) + this.idsToData = {} + } state = { - url: '', - status: 'idle', - percent: 0, - downloads: [], - downloadsData: {}, + url: '', + status: 'idle', + percent: 0, + downloads: [], + downloadsData: {}, }; - async componentDidMount() { - const tasks = await RNBGD.checkForExistingDownloads(); - if (tasks && tasks.length) { - await this.loadDownloads(); - const downloadsData = {}; - const downloads = []; - for (let task of tasks) { - downloads.push(task.id); - downloadsData[task.id] = { - url: this.idsToData[task.id].url, - percent: task.percent, - total: task.totalBytes, - status: task.state === 'DOWNLOADING' ? 'downloading' : 'paused', - task: task - }; - this.attachToTask(task, this.idsToData[task.id].filePath); - } - this.setState({ - downloadsData, - downloads - }); + async componentDidMount () { + const tasks = await RNBGD.checkForExistingDownloads() + if (tasks && tasks.length) { + await this.loadDownloads() + const downloadsData = {} + const downloads = [] + for (const task of tasks) { + downloads.push(task.id) + downloadsData[task.id] = { + url: this.idsToData[task.id].url, + percent: task.percent, + total: task.totalBytes, + status: task.state === 'DOWNLOADING' ? 'downloading' : 'paused', + task: task, + } + this.attachToTask(task, this.idsToData[task.id].filePath) } + this.setState({ + downloadsData, + downloads, + }) + } } - saveDownloads() { - AsyncStorage.setItem('idsToData', JSON.stringify(this.idsToData)); + saveDownloads () { + AsyncStorage.setItem('idsToData', JSON.stringify(this.idsToData)) } - async loadDownloads() { - const mapStr = await AsyncStorage.getItem('idsToData'); - try { - this.idsToData = JSON.parse(mapStr) || {}; - } catch (e) { - console.error(e); - } + async loadDownloads () { + const mapStr = await AsyncStorage.getItem('idsToData') + try { + this.idsToData = JSON.parse(mapStr) || {} + } catch (e) { + console.error(e) + } } - pauseOrResume(id) { - let newStatus; - const download = this.state.downloadsData[id]; - if (download.status === 'downloading') { - download.task.pause(); - newStatus = 'paused'; - } else if (download.status === 'paused') { - download.task.resume(); - newStatus = 'downloading'; - } else { - console.error(`Unknown status for play or pause: ${download.status}`); - return; - } + pauseOrResume (id) { + let newStatus + const download = this.state.downloadsData[id] + if (download.status === 'downloading') { + download.task.pause() + newStatus = 'paused' + } else if (download.status === 'paused') { + download.task.resume() + newStatus = 'downloading' + } else { + console.error(`Unknown status for play or pause: ${download.status}`) + return + } - this.setState(produce(draft => { - draft.downloadsData[id].status = newStatus; - })); + this.setState(produce(draft => { + draft.downloadsData[id].status = newStatus + })) } - cancel(id) { - const download = this.state.downloadsData[id]; - download.task.stop(); - delete this.idsToData[id]; - this.saveDownloads(); - this.setState(produce(draft => { - delete draft.downloadsData[id]; - draft.downloads.splice(draft.downloads.indexOf(id), 1); - })); + cancel (id) { + const download = this.state.downloadsData[id] + download.task.stop() + delete this.idsToData[id] + this.saveDownloads() + this.setState(produce(draft => { + delete draft.downloadsData[id] + draft.downloads.splice(draft.downloads.indexOf(id), 1) + })) } - renderRow({ item: downloadId }) { - const download = this.state.downloadsData[downloadId]; - let iconName = 'ios-pause'; - if (download.status === 'paused') { - iconName = 'ios-play'; - } + renderRow ({ item: downloadId }) { + const download = this.state.downloadsData[downloadId] + let iconName = 'ios-pause' + if (download.status === 'paused') + iconName = 'ios-play' - return ( - - - - {downloadId} - {download.url} - - - - - this.pauseOrResume(downloadId)}> - - - this.cancel(downloadId)}> - - - + return ( + + + + {downloadId} + {download.url} - ); + + + + this.pauseOrResume(downloadId)}> + + + this.cancel(downloadId)}> + + + + + ) } - attachToTask(task, filePath) { - task.begin(expectedBytes => { - this.setState(produce(draft => { - draft.downloadsData[task.id].total = expectedBytes; - draft.downloadsData[task.id].status = 'downloading'; - })); - }) - .progress(percent => { - this.setState(produce(draft => { - draft.downloadsData[task.id].percent = percent; - })); - }) - .done(async() => { - try { - console.log(`Finished downloading: ${task.id}, deleting it...`); - await RNFS.unlink(filePath); - console.log(`Deleted ${task.id}`); - } catch (e) { - console.error(e); - } - delete this.idsToData[task.id]; - this.saveDownloads(); - this.setState(produce(draft => { - delete draft.downloadsData[task.id]; - draft.downloads.splice(draft.downloads.indexOf(task.id), 1); - })); - }) - .error(err => { - console.error(`Download ${task.id} has an error: ${err}`); - delete this.idsToData[task.id]; - this.saveDownloads(); - this.setState(produce(draft => { - delete draft.downloadsData[task.id]; - draft.downloads.splice(draft.downloads.indexOf(task.id), 1); - })); - }); - } - - addDownload() { - const id = Math.random() - .toString(36) - .substr(2, 6); - const filePath = `${RNBGD.directories.documents}/${id}`; - const url = this.state.url || testURL; - const task = RNBGD.download({ - id: id, - url: url, - destination: filePath, - }); - this.attachToTask(task, filePath); - this.idsToData[id] = { - url, - filePath - }; - this.saveDownloads(); - + attachToTask (task, filePath) { + task.begin(expectedBytes => { this.setState(produce(draft => { - draft.downloadsData[id] = { - url: url, - status: 'idle', - task: task - }; - draft.downloads.push(id); - draft.url = ''; - })); + draft.downloadsData[task.id].total = expectedBytes + draft.downloadsData[task.id].status = 'downloading' + })) + }) + .progress(percent => { + this.setState(produce(draft => { + draft.downloadsData[task.id].percent = percent + })) + }) + .done(async () => { + try { + console.log(`Finished downloading: ${task.id}, deleting it...`) + await RNFS.unlink(filePath) + console.log(`Deleted ${task.id}`) + } catch (e) { + console.error(e) + } + delete this.idsToData[task.id] + this.saveDownloads() + this.setState(produce(draft => { + delete draft.downloadsData[task.id] + draft.downloads.splice(draft.downloads.indexOf(task.id), 1) + })) + }) + .error(err => { + console.error(`Download ${task.id} has an error: ${err}`) + delete this.idsToData[task.id] + this.saveDownloads() + this.setState(produce(draft => { + delete draft.downloadsData[task.id] + draft.downloads.splice(draft.downloads.indexOf(task.id), 1) + })) + }) } - render() { - return ( - - { - this.setState({ url: text.toLowerCase() }); - }} - value={this.state.url} - /> -