Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
kfiroo committed Dec 3, 2016
1 parent 5d801ff commit d629941
Show file tree
Hide file tree
Showing 6 changed files with 447 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ jspm_packages

# Optional REPL history
.node_repl_history
/.idea/
180 changes: 180 additions & 0 deletions CachedImage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
'use strict';

const _ = require('lodash');
const React = require('react');
const ReactNative = require('react-native');

const ImageCacheProvider = require('./ImageCacheProvider');

const {
Image,
ActivityIndicator,
NetInfo
} = ReactNative;

const {
StyleSheet
} = ReactNative;

const styles = StyleSheet.create({
image: {
backgroundColor: 'transparent'
},
loader: {
backgroundColor: 'transparent',
flex: 1
}
});

const CacheableImage = React.createClass({
propTypes: {
renderImage: React.PropTypes.func.isRequired,
activityIndicatorProps: React.PropTypes.object.isRequired,
defaultSource: Image.propTypes.source,
useQueryParamsInCacheKey: React.PropTypes.oneOfType([
React.PropTypes.bool,
React.PropTypes.array
]).isRequired
},

getDefaultProps() {
return {
renderImage: props => (<Image {...props}/>),
activityIndicatorProps: {},
useQueryParamsInCacheKey: false
};
},

getInitialState() {
this._isMounted = false;
return {
isRemote: false,
cachedImagePath: null,
jobId: null,
networkAvailable: true
};
},

safeSetState(newState) {
if (!this._isMounted) {
return;
}
return this.setState(newState);
},

componentWillMount() {
this._isMounted = true;
NetInfo.isConnected.addEventListener('change', this.handleConnectivityChange);
// initial
NetInfo.isConnected.fetch()
.then(isConnected => {
this.safeSetState({
networkAvailable: isConnected
});
});

this.processSource(this.props.source);
},

componentWillUnmount() {
this._isMounted = false;
NetInfo.isConnected.removeEventListener('change', this.handleConnectivityChange);
},

componentWillReceiveProps(nextProps) {
if (this.props.source !== nextProps.source) {
this.processSource(nextProps.source);
}
},

handleConnectivityChange(isConnected) {
this.safeSetState({
networkAvailable: isConnected
});
},

processSource(source) {
const url = _.get(source, ['uri'], null);
if (ImageCacheProvider.isCacheable(url)) {
const options = _.pick(this.props, ['useQueryParamsInCacheKey']);
// try to get the image path from cache
ImageCacheProvider.getCachedImagePath(url, options)
// try to put the image in cache if
.catch(() => ImageCacheProvider.cacheImage(url, options))
.then(cachedImagePath => {
this.safeSetState({
cachedImagePath
});
})
.catch(err => {
console.log('>>> error ', err);
this.safeSetState({
cachedImagePath: null
});
});
this.safeSetState({
isRemote: true
});
} else {
this.safeSetState({
isRemote: false
});
}
},

render() {
if (!this.state.isRemote) {
return this.renderLocal();
}
if (this.state.cachedImagePath) {
return this.renderCache();
}
if (this.props.defaultSource) {
return this.renderDefaultSource();
}
return this.renderLoader();
},

renderLocal() {
const props = _.omit(this.props, ['defaultSource', 'activityIndicatorProps', 'style']);
const style = this.props.style || styles.image;
return this.props.renderImage({
...props,
style
});
},

renderCache() {
const props = _.omit(this.props, ['defaultSource', 'activityIndicatorProps', 'style']);
const style = this.props.style || styles.image;
return this.props.renderImage({
...props,
style,
source: {
uri: 'file://' + this.state.cachedImagePath
}
});
},

renderDefaultSource() {
const {children, defaultSource, ...props} = this.props;
return (
<CacheableImage {...props} source={defaultSource}>
{children}
</CacheableImage>
);
},

renderLoader() {
const props = _.omit(this.props.activityIndicatorProps, ['style']);
const style = [this.props.style, this.props.activityIndicatorProps.style || styles.loader];
return (
<ActivityIndicator
{...props}
style={style}
/>
);
}
});

module.exports = CacheableImage;
192 changes: 192 additions & 0 deletions ImageCacheProvider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
'use strict';

const _ = require('lodash');
const RNFS = require('react-native-fs');

const {
DocumentDirectoryPath
} = RNFS;

const SHA1 = require("crypto-js/sha1");
const URL = require('url-parse');

const defaultOptions = {
useQueryParamsInCacheKey: false
};

const activeDownloads = {};

function serializeObjectKeys(obj) {
return _(obj)
.toPairs()
.sortBy(a => a[0])
.map(a => a[1])
.value();
}

function getQueryForCacheKey(url, useQueryParamsInCacheKey) {
if (_.isArray(useQueryParamsInCacheKey)) {
return serializeObjectKeys(_.pick(url.query, useQueryParamsInCacheKey));
}
if (useQueryParamsInCacheKey) {
return serializeObjectKeys(url.query);
}
return '';
}

function generateCacheKey(url, options) {
const parsedUrl = new URL(url, null, true);
const parts = parsedUrl.pathname.split('.');
const type = parts.length > 1 ? ('.' + parts.pop()) : '';
const pathname = parts.join('.');
const cacheable = pathname + getQueryForCacheKey(parsedUrl, options.useQueryParamsInCacheKey);
return SHA1(cacheable) + type;
}

function getCachePath(url, options) {
if (options.cacheGroup) {
return options.cacheGroup;
}
const parsedUrl = new URL(url);
return parsedUrl.host;
}

function getCachedImageFilePath(url, options) {
const cacheKey = generateCacheKey(url, options);
const cachePath = getCachePath(url, options);

const dirPath = DocumentDirectoryPath + '/' + cachePath;
return dirPath + '/' + cacheKey;
}

function deleteFile(filePath) {
return RNFS.exists(filePath)
.then(res => res && RNFS.unlink(filePath))
.catch((err) => {
// swallow error to always resolve
});
}

function ensurePath(filePath) {
const parts = filePath.split('/');
const dirPath = _.initial(parts).join('/');
return RNFS.mkdir(dirPath, {NSURLIsExcludedFromBackupKey: true});
}

/**
* returns a promise that is resolved when the download of the requested file
* is complete and the file is saved.
* if the download fails, or was stopped the partial file is deleted, and the
* promise is rejected
* @param fromUrl
* @param toFile
* @returns {Promise}
*/
function downloadImage(fromUrl, toFile) {
// use toFile as the key as is was created using the cacheKey
if (!_.has(activeDownloads, toFile)) {
// create an active download for this file
activeDownloads[toFile] = new Promise((resolve, reject) => {
const downloadOptions = {
fromUrl,
toFile
};
RNFS.downloadFile(downloadOptions).promise
.then(() => {
resolve(toFile);
})
.catch(err => deleteFile(toFile)
.then(() => reject(err))
)
.finally(() => {
// cleanup
delete activeDownloads[toFile];
});
});
}
return activeDownloads[toFile];
}

function createPrefetcer(list) {
const urls = _.clone(list);
return {
next() {
return urls.shift();
}
};
}

function runPrefetchTask(prefetcher, options) {
const url = prefetcher.next();
if (!url) {
return Promise.resolve();
}
// if url is cacheable - cache it
if (isCacheable(url)) {
// check cache
return getCachedImagePath(url, options)
// if not found download
.catch(() => cacheImage(url, options))
// then run next task
.then(() => runPrefetchTask(prefetcher, options));
}
// else get next
return runPrefetchTask(prefetcher, options);
}

// API

function isCacheable(url) {
return _.isString(url) && (_.startsWith(url, 'http://') || _.startsWith(url, 'https://'));
}

function getCachedImagePath(url, options = defaultOptions) {
const filePath = getCachedImageFilePath(url, options);
return RNFS.stat(filePath)
.then(res => {
if (!res.isFile()) {
// reject the promise if res is not a file
throw new Error('Failed to get image from cache');
}
return filePath;
})
.catch(err => {
throw err;
})
}

function cacheImage(url, options = defaultOptions) {
const filePath = getCachedImageFilePath(url, options);
return ensurePath(filePath)
.then(() => downloadImage(url, filePath));
}

function deleteCachedImage(url, options = defaultOptions) {
const filePath = getCachedImageFilePath(url, options);
return deleteFile(filePath);
}

function cacheMultipleImages(urls, options = defaultOptions) {
const prefetcher = createPrefetcer(urls);
const numberOfWorkers = urls.length;
const promises = _.times(numberOfWorkers, () =>
runPrefetchTask(prefetcher, options)
);
return Promise.all(promises);
}

function deleteMultipleCachedImages(urls, options = defaultOptions) {
return _.reduce(urls, (p, url) =>
p.then(() => deleteCachedImage(url, options)),
Promise.resolve()
);
}

module.exports = {
isCacheable,
getCachedImagePath,
cacheImage,
deleteCachedImage,
cacheMultipleImages,
deleteMultipleCachedImages
};
4 changes: 4 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
'use strict';

module.exports = require('./CachedImage');
module.exports.ImageCacheProvider = require('./ImageCacheProvider');
Loading

0 comments on commit d629941

Please sign in to comment.