├── .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 |
--------------------------------------------------------------------------------