├── .gitignore ├── LICENSE ├── README.md ├── image.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | /.project 2 | /node_modules/ 3 | /npm-debug.log 4 | /.settings/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jayes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-cacheable-image 2 | An Image component for React Native that will cache itself to disk. 3 | 4 | ## Notes 5 | CacheableImage understands its state. Once you define a source the first time it's state has been set. You can create another component with the same source and it will load the same cached file without having to fetch from a remote URI. 6 | 7 | However, if you happen to change the source, the original cached file will be removed and a new cached image will be created. Basically, don't change the source once you've set it unless you need to. Create a new CacheableImage component and swap if you don't want the current image to be wiped from the cache. 8 | 9 | This is beneficial in say you have a User Profile Image. If the user changes their image, the current profile image will be removed from the cache and the new image will be saved to the cache. 10 | 11 | Local assets are not cached and are passed through. (ie, Default/Placeholder Images) 12 | 13 | This component has been tested with AWS CloudFront and as such only uses the path to the image to generate its hash. Any URL query params are ignored. 14 | 15 | Pull Requests for enhancing this component are welcome. 16 | 17 | ## Installation 18 | npm i react-native-cacheable-image --save 19 | 20 | ## Dependencies 21 | - [react-native-responsive-image](https://github.com/Dharmoslap/react-native-responsive-image) to provide responsive image handling. 22 | - [url-parse](https://github.com/unshiftio/url-parse) for url handling 23 | - [crypto-js](https://github.com/brix/crypto-js) for hashing 24 | - [react-native-fs](https://github.com/johanneslumpe/react-native-fs) for file system access 25 | 26 | ### Dependency Installation 27 | - For `react-native-fs`. You need to link the module. Either try `rnpm link react-native-fs` or `react-native link react-native-fs`. See react-native-fs for more information. 28 | 29 | ### Network Status 30 | #### Android 31 | 32 | Add the following line to your android/app/src/AndroidManifest.xml 33 | 34 | `` 35 | 36 | ## Usage 37 | ```javascript 38 | import CacheableImage from 'react-native-cacheable-image' 39 | ``` 40 | 41 | ### Props 42 | 43 | * `activityIndicatorProps` - pass this property to alter the ActivityIndicator 44 | * `defaultSource` - pass this property to provide a default source to fallback on (the defaultSource is attached to another CacheableImage component) 45 | * `useQueryParamsInCacheKey` - Defaults to false for backwards compatibility. Set to true to include query parameters in cache key generation. Set to an array of parameters to only include specific parameters in cache key generation. 46 | 47 | ## Example 48 | 49 | ```jsx 50 | 55 | 60 | 61 | Example 62 | 63 | 64 | 65 | ``` 66 | 67 | 68 | LEGAL DISCLAIMER 69 | ---------------- 70 | 71 | This software is published under the MIT License, which states that: 72 | 73 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 74 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 75 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 76 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 77 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 78 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 79 | > SOFTWARE. 80 | 81 | -------------------------------------------------------------------------------- /image.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { Image, ActivityIndicator, Platform } from 'react-native'; 4 | import RNFS, { DocumentDirectoryPath } from 'react-native-fs'; 5 | import ResponsiveImage from 'react-native-responsive-image'; 6 | 7 | // support RN 0.60 8 | import NetInfo from "@react-native-community/netinfo"; 9 | 10 | const SHA1 = require("crypto-js/sha1"); 11 | const URL = require('url-parse'); 12 | 13 | export default 14 | class CacheableImage extends React.Component { 15 | 16 | static propTypes = { 17 | activityIndicatorProps: PropTypes.object, 18 | defaultSource: Image.propTypes.source, 19 | useQueryParamsInCacheKey: PropTypes.oneOfType([ 20 | PropTypes.bool, 21 | PropTypes.array 22 | ]), 23 | checkNetwork: PropTypes.bool, 24 | networkAvailable: PropTypes.bool, 25 | downloadInBackground: PropTypes.bool, 26 | storagePermissionGranted: PropTypes.bool 27 | } 28 | 29 | static defaultProps = { 30 | style: { backgroundColor: 'transparent' }, 31 | activityIndicatorProps: { 32 | style: { backgroundColor: 'transparent', flex: 1 } 33 | }, 34 | useQueryParamsInCacheKey: false, // bc 35 | checkNetwork: true, 36 | networkAvailable: false, 37 | downloadInBackground: (Platform.OS === 'ios') ? false : true, 38 | storagePermissionGranted: true 39 | } 40 | 41 | state = { 42 | isRemote: false, 43 | cachedImagePath: null, 44 | cacheable: true 45 | } 46 | 47 | networkAvailable = this.props.networkAvailable 48 | 49 | downloading = false 50 | 51 | jobId = null 52 | 53 | setNativeProps(nativeProps) { 54 | if (this._imageComponent) { 55 | this._imageComponent.setNativeProps(nativeProps); 56 | } 57 | } 58 | 59 | imageDownloadBegin = info => { 60 | switch (info.statusCode) { 61 | case 404: 62 | case 403: 63 | break; 64 | default: 65 | this.downloading = true; 66 | this.jobId = info.jobId; 67 | } 68 | } 69 | 70 | imageDownloadProgress = info => { 71 | if ((info.contentLength / info.bytesWritten) == 1) { 72 | this.downloading = false; 73 | this.jobId = null; 74 | } 75 | } 76 | 77 | checkImageCache = (imageUri, cachePath, cacheKey) => { 78 | const dirPath = DocumentDirectoryPath+'/'+cachePath; 79 | const filePath = dirPath+'/'+cacheKey; 80 | 81 | RNFS 82 | .stat(filePath) 83 | .then((res) => { 84 | if (res.isFile() && res.size > 0) { 85 | // It's possible the component has already unmounted before setState could be called. 86 | // It happens when the defaultSource and source have both been cached. 87 | // An attempt is made to display the default however it's instantly removed since source is available 88 | 89 | // means file exists, ie, cache-hit 90 | this.setState({cacheable: true, cachedImagePath: filePath}); 91 | } 92 | else { 93 | throw Error("CacheableImage: Invalid file in checkImageCache()"); 94 | } 95 | }) 96 | .catch((err) => { 97 | 98 | // means file does not exist 99 | // first make sure network is available.. 100 | // if (! this.state.networkAvailable) { 101 | if (! this.networkAvailable) { 102 | return; 103 | } 104 | 105 | // then make sure directory exists.. then begin download 106 | // The NSURLIsExcludedFromBackupKey property can be provided to set this attribute on iOS platforms. 107 | // Apple will reject apps for storing offline cache data that does not have this attribute. 108 | // https://github.com/johanneslumpe/react-native-fs#mkdirfilepath-string-options-mkdiroptions-promisevoid 109 | RNFS 110 | .mkdir(dirPath, {NSURLIsExcludedFromBackupKey: true}) 111 | .then(() => { 112 | 113 | // before we change the cachedImagePath.. if the previous cachedImagePath was set.. remove it 114 | if (this.state.cacheable && this.state.cachedImagePath) { 115 | let delImagePath = this.state.cachedImagePath; 116 | this._deleteFilePath(delImagePath); 117 | } 118 | 119 | // If already downloading, cancel the job 120 | if (this.jobId) { 121 | this._stopDownload(); 122 | } 123 | 124 | let downloadOptions = { 125 | fromUrl: imageUri, 126 | toFile: filePath, 127 | background: this.props.downloadInBackground, 128 | begin: this.imageDownloadBegin, 129 | progress: this.imageDownloadProgress 130 | }; 131 | 132 | // directory exists.. begin download 133 | let download = RNFS 134 | .downloadFile(downloadOptions); 135 | 136 | this.downloading = true; 137 | this.jobId = download.jobId; 138 | 139 | download.promise 140 | .then((res) => { 141 | this.downloading = false; 142 | this.jobId = null; 143 | 144 | switch (res.statusCode) { 145 | case 404: 146 | case 403: 147 | this.setState({cacheable: false, cachedImagePath: null}); 148 | break; 149 | default: 150 | this.setState({cacheable: true, cachedImagePath: filePath}); 151 | } 152 | }) 153 | .catch((err) => { 154 | // error occurred while downloading or download stopped.. remove file if created 155 | this._deleteFilePath(filePath); 156 | 157 | // If there was no in-progress job, it may have been cancelled already (and this component may be unmounted) 158 | if (this.downloading) { 159 | this.downloading = false; 160 | this.jobId = null; 161 | this.setState({cacheable: false, cachedImagePath: null}); 162 | } 163 | }); 164 | }) 165 | .catch((err) => { 166 | this._deleteFilePath(filePath); 167 | this.setState({cacheable: false, cachedImagePath: null}); 168 | }); 169 | }); 170 | } 171 | 172 | _deleteFilePath = (filePath) => { 173 | RNFS 174 | .exists(filePath) 175 | .then((res) => { 176 | if (res) { 177 | RNFS 178 | .unlink(filePath) 179 | .catch((err) => {}); 180 | } 181 | }); 182 | } 183 | 184 | _processSource = (source, skipSourceCheck) => { 185 | 186 | if (this.props.storagePermissionGranted 187 | && source !== null 188 | && source != '' 189 | && typeof source === "object" 190 | && source.hasOwnProperty('uri') 191 | && ( 192 | skipSourceCheck || 193 | typeof skipSourceCheck === 'undefined' || 194 | (!skipSourceCheck && source != this.props.source) 195 | ) 196 | ) 197 | { // remote 198 | 199 | if (this.jobId) { // sanity 200 | this._stopDownload(); 201 | } 202 | 203 | const url = new URL(source.uri, null, true); 204 | 205 | // handle query params for cache key 206 | let cacheable = url.pathname; 207 | if (Array.isArray(this.props.useQueryParamsInCacheKey)) { 208 | this.props.useQueryParamsInCacheKey.forEach(function(k) { 209 | if (url.query.hasOwnProperty(k)) { 210 | cacheable = cacheable.concat(url.query[k]); 211 | } 212 | }); 213 | } 214 | else if (this.props.useQueryParamsInCacheKey) { 215 | cacheable = cacheable.concat(url.query); 216 | } 217 | 218 | const type = url.pathname.replace(/.*\.(.*)/, '$1'); 219 | const cacheKey = SHA1(cacheable) + (type.length < url.pathname.length ? '.' + type : ''); 220 | 221 | this.checkImageCache(source.uri, url.host, cacheKey); 222 | this.setState({isRemote: true}); 223 | } 224 | else { 225 | this.setState({isRemote: false}); 226 | } 227 | } 228 | 229 | _stopDownload = () => { 230 | if (!this.jobId) return; 231 | 232 | this.downloading = false; 233 | RNFS.stopDownload(this.jobId); 234 | this.jobId = null; 235 | } 236 | 237 | _handleConnectivityChange = isConnected => { 238 | this.networkAvailable = isConnected; 239 | if (this.networkAvailable && this.state.isRemote && !this.state.cachedImagePath) { 240 | this._processSource(this.props.source); 241 | } 242 | } 243 | 244 | componentWillReceiveProps(nextProps) { 245 | if (nextProps.source != this.props.source || nextProps.networkAvailable != this.networkAvailable) { 246 | this.networkAvailable = nextProps.networkAvailable; 247 | this._processSource(nextProps.source); 248 | } 249 | } 250 | 251 | shouldComponentUpdate(nextProps, nextState) { 252 | if (nextState === this.state && nextProps === this.props) { 253 | return false; 254 | } 255 | return true; 256 | } 257 | 258 | componentWillMount() { 259 | if (this.props.checkNetwork) { 260 | NetInfo.isConnected.addEventListener('connectionChange', this._handleConnectivityChange); 261 | // componentWillUnmount unsets this._handleConnectivityChange in case the component unmounts before this fetch resolves 262 | NetInfo.isConnected.fetch().done(this._handleConnectivityChange); 263 | } 264 | 265 | this._processSource(this.props.source, true); 266 | } 267 | 268 | componentWillUnmount() { 269 | if (this.props.checkNetwork) { 270 | NetInfo.isConnected.removeEventListener('connectionChange', this._handleConnectivityChange); 271 | this._handleConnectivityChange = null; 272 | } 273 | 274 | if (this.downloading && this.jobId) { 275 | this._stopDownload(); 276 | } 277 | } 278 | 279 | render() { 280 | if ((!this.state.isRemote && !this.props.defaultSource) || !this.props.storagePermissionGranted) { 281 | return this.renderLocal(); 282 | } 283 | 284 | if (this.state.cacheable && this.state.cachedImagePath) { 285 | return this.renderCache(); 286 | } 287 | 288 | if (this.props.defaultSource) { 289 | return this.renderDefaultSource(); 290 | } 291 | 292 | const { children, defaultSource, checkNetwork, networkAvailable, downloadInBackground, activityIndicatorProps, ...props } = this.props; 293 | const style = [activityIndicatorProps.style, this.props.style]; 294 | return ( 295 | 296 | ); 297 | } 298 | 299 | renderCache() { 300 | const { children, defaultSource, checkNetwork, networkAvailable, downloadInBackground, activityIndicatorProps, ...props } = this.props; 301 | return ( 302 | this._imageComponent = component}> 303 | {children} 304 | 305 | ); 306 | } 307 | 308 | renderLocal() { 309 | const { children, defaultSource, checkNetwork, networkAvailable, downloadInBackground, activityIndicatorProps, ...props } = this.props; 310 | return ( 311 | this._imageComponent = component}> 312 | {children} 313 | 314 | ); 315 | } 316 | 317 | renderDefaultSource() { 318 | const { children, defaultSource, checkNetwork, networkAvailable, ...props } = this.props; 319 | return ( 320 | this._imageComponent = component}> 321 | {children} 322 | 323 | ); 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-cacheable-image", 3 | "version": "2.1.0", 4 | "description": "An Image component for React Native that will cache itself to disk", 5 | "main": "image.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/jayesbe/react-native-cacheable-image.git" 12 | }, 13 | "keywords": [ 14 | "react-native" 15 | ], 16 | "author": "Jayesbe ", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/jayesbe/react-native-cacheable-image/issues" 20 | }, 21 | "homepage": "https://github.com/jayesbe/react-native-cacheable-image#readme", 22 | "dependencies": { 23 | "crypto-js": "^3.1.6", 24 | "prop-types": "^15.5.10", 25 | "react-native-fs": "^2.3.0", 26 | "react-native-responsive-image": "^2.0.0", 27 | "url-parse": "^1.1.1", 28 | "@react-native-community/netinfo": "^4.1.5" 29 | } 30 | } 31 | --------------------------------------------------------------------------------