ionic-image-loader
Version:
Ionic Component and Service to load images in a background thread and cache them for later use
601 lines (490 loc) • 18 kB
text/typescript
import { Injectable } from '@angular/core';
import { File, FileEntry, FileError, DirectoryEntry } from '@ionic-native/file';
import { FileTransfer, FileTransferObject } from '@ionic-native/file-transfer';
import { ImageLoaderConfig } from "./image-loader-config";
import { Platform } from 'ionic-angular';
import * as _ from 'lodash';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/first';
interface IndexItem {
name: string;
modificationTime: Date;
size: number;
}
interface QueueItem {
imageUrl: string;
resolve: Function;
reject: Function;
}
export class ImageLoader {
get nativeAvailable(): boolean {
return File.installed() && FileTransfer.installed();
}
/**
* Indicates if the cache service is ready.
* When the cache service isn't ready, images are loaded via browser instead.
* @type {boolean}
*/
private isCacheReady: boolean = false;
/**
* Indicates if this service is initialized.
* This service is initialized once all the setup is done.
* @type {boolean}
*/
private isInit: boolean = false;
/**
* Number of concurrent requests allowed
* @type {number}
*/
private concurrency: number = 5;
/**
* Queue items
* @type {Array}
*/
private queue: QueueItem[] = [];
private transferInstances: FileTransferObject[] = [];
private processing: number = 0;
private cacheIndex: IndexItem[] = [];
private currentCacheSize: number = 0;
private indexed: boolean = false;
private get shouldIndex(): boolean {
return (this.config.maxCacheAge > -1) || (this.config.maxCacheSize > -1);
}
private get isWKWebView(): boolean {
return this.platform.is('ios') && (<any>window).webkit;
}
private get isIonicWKWebView(): boolean {
return this.isWKWebView && location.host === 'localhost:8080';
}
constructor(
private config: ImageLoaderConfig,
private file: File,
private fileTransfer: FileTransfer,
private platform: Platform
) {
platform.ready().then(() => {
if (!platform.is('cordova')) {
// we are running on a browser, or using livereload
// plugin will not function in this case
this.isInit = true;
this.throwWarning('You are running on a browser or using livereload, IonicImageLoader will not function, falling back to browser loading.');
} else {
Observable.fromEvent(document, 'deviceready').first().subscribe(res => {
if (this.nativeAvailable) {
this.initCache();
} else {
// we are running on a browser, or using livereload
// plugin will not function in this case
this.isInit = true;
this.throwWarning('You are running on a browser or using livereload, IonicImageLoader will not function, falling back to browser loading.');
}
})
}
});
}
/**
* Preload an image
* @param imageUrl {string} Image URL
* @returns {Promise<string>} returns a promise that resolves with the cached image URL
*/
preload(imageUrl: string): Promise<string> {
return this.getImagePath(imageUrl);
}
/**
* Clears the cache
*/
clearCache(): void {
if (!this.platform.is('cordova')) return;
const clear = () => {
if (!this.isInit) {
// do not run this method until our service is initialized
setTimeout(clear.bind(this), 500);
return;
}
// pause any operations
this.isInit = false;
this.file.removeRecursively(this.file.cacheDirectory, this.config.cacheDirectoryName)
.then(() => {
if (this.isWKWebView && !this.isIonicWKWebView) {
// also clear the temp files
this.file.removeRecursively(this.file.tempDirectory, this.config.cacheDirectoryName)
.catch((error) => {
// Noop catch. Removing the tempDirectory might fail,
// as it is not persistent.
})
.then(() => {
this.initCache(true);
});
} else {
this.initCache(true);
}
})
.catch(this.throwError.bind(this));
};
clear();
}
/**
* Gets the filesystem path of an image.
* This will return the remote path if anything goes wrong or if the cache service isn't ready yet.
* @param imageUrl {string} The remote URL of the image
* @returns {Promise<string>} Returns a promise that will always resolve with an image URL
*/
getImagePath(imageUrl: string): Promise<string> {
if (typeof imageUrl !== 'string' || imageUrl.length <= 0) {
return Promise.reject('The image url provided was empty or invalid.');
}
return new Promise<string>((resolve, reject) => {
const getImage = () => {
this.getCachedImagePath(imageUrl)
.then(resolve)
.catch(() => {
// image doesn't exist in cache, lets fetch it and save it
this.addItemToQueue(imageUrl, resolve, reject);
});
};
const check = () => {
if (this.isInit) {
if (this.isCacheReady) {
getImage();
} else {
this.throwWarning('The cache system is not running. Images will be loaded by your browser instead.');
resolve(imageUrl);
}
} else {
setTimeout(() => check(), 250);
}
};
check();
});
}
/**
* Add an item to the queue
* @param imageUrl
* @param resolve
* @param reject
*/
private addItemToQueue(imageUrl: string, resolve, reject): void {
this.queue.push({
imageUrl,
resolve,
reject
});
this.processQueue();
}
/**
* Check if we can process more items in the queue
* @returns {boolean}
*/
private get canProcess(): boolean {
return (
this.queue.length > 0
&& this.processing < this.concurrency
);
}
/**
* Processes one item from the queue
*/
private processQueue() {
// make sure we can process items first
if (!this.canProcess) return;
// increase the processing number
this.processing++;
// take the first item from queue
const currentItem: QueueItem = this.queue.splice(0, 1)[0];
// create FileTransferObject instance if needed
// we would only reach here if current jobs < concurrency limit
// so, there's no need to check anything other than the length of
// the FileTransferObject instances we have in memory
if (this.transferInstances.length === 0) {
this.transferInstances.push(this.fileTransfer.create());
}
const transfer: FileTransferObject = this.transferInstances.splice(0, 1)[0];
// process more items concurrently if we can
if (this.canProcess) this.processQueue();
// function to call when done processing this item
// this will reduce the processing number
// then will execute this function again to process any remaining items
const done = () => {
this.processing--;
this.transferInstances.push(transfer);
this.processQueue();
};
const localPath = this.file.cacheDirectory + this.config.cacheDirectoryName + '/' + this.createFileName(currentItem.imageUrl);
transfer.download(currentItem.imageUrl, localPath)
.then((file: FileEntry) => {
if (this.shouldIndex) {
this.addFileToIndex(file).then(this.maintainCacheSize.bind(this));
}
return this.getCachedImagePath(currentItem.imageUrl);
})
.then((localUrl) => {
currentItem.resolve(localUrl);
done();
})
.catch((e) => {
currentItem.reject();
this.throwError(e);
done();
});
}
/**
* Initialize the cache service
* @param replace {boolean} Whether to replace the cache directory if it already exists
*/
private initCache(replace?: boolean): void {
this.concurrency = this.config.concurrency;
// create cache directories if they do not exist
this.createCacheDirectory(replace)
.catch(e => {
this.throwError(e);
this.isInit = true;
})
.then(() => this.indexCache())
.then(() => {
this.isCacheReady = true;
this.isInit = true;
});
}
/**
* Adds a file to index.
* Also deletes any files if they are older than the set maximum cache age.
* @param file {FileEntry} File to index
* @returns {Promise<any>}
*/
private addFileToIndex(file: FileEntry): Promise<any> {
return new Promise<any>((resolve, reject) => file.getMetadata(resolve, reject))
.then(metadata => {
if (
this.config.maxCacheAge > -1
&& (Date.now() - metadata.modificationTime.getTime()) > this.config.maxCacheAge
) {
// file age exceeds maximum cache age
return this.removeFile(file.name);
} else {
// file age doesn't exceed maximum cache age, or maximum cache age isn't set
this.currentCacheSize += metadata.size;
// add item to index
this.cacheIndex.push({
name: file.name,
modificationTime: metadata.modificationTime,
size: metadata.size
});
return Promise.resolve();
}
});
}
/**
* Indexes the cache if necessary
* @returns {any}
*/
private indexCache(): Promise<void> {
// only index if needed, to save resources
if (!this.shouldIndex) return Promise.resolve();
this.cacheIndex = [];
return this.file.listDir(this.file.cacheDirectory, this.config.cacheDirectoryName)
.then(files => Promise.all(files.map(this.addFileToIndex.bind(this))))
.then(() => {
this.cacheIndex = _.sortBy(this.cacheIndex, 'modificationTime');
this.indexed = true;
return Promise.resolve();
})
.catch(e => {
this.throwError(e);
return Promise.resolve();
});
}
/**
* This method runs every time a new file is added.
* It checks the cache size and ensures that it doesn't exceed the maximum cache size set in the config.
* If the limit is reached, it will delete old images to create free space.
*/
private maintainCacheSize(): void {
if (this.config.maxCacheSize > -1 && this.indexed) {
const maintain = () => {
if (this.currentCacheSize > this.config.maxCacheSize) {
// called when item is done processing
const next: Function = () => {
this.currentCacheSize -= file.size;
maintain();
};
// grab the first item in index since it's the oldest one
const file: IndexItem = this.cacheIndex.splice(0, 1)[0];
if (typeof file == 'undefined') return maintain();
// delete the file then process next file if necessary
this.removeFile(file.name)
.then(() => next())
.catch(() => next()); // ignore errors, nothing we can do about it
}
};
maintain();
}
}
/**
* Remove a file
* @param file {string} The name of the file to remove
*/
private removeFile(file: string): Promise<any> {
return this.file
.removeFile(this.file.cacheDirectory + this.config.cacheDirectoryName, file)
.then(() => {
if (this.isWKWebView && !this.isIonicWKWebView) {
return this.file
.removeFile(this.file.tempDirectory + this.config.cacheDirectoryName, file)
.catch(() => {
// Noop catch. Removing the files from tempDirectory might fail, as it is not persistent.
});
}
});
}
/**
* Get the local path of a previously cached image if exists
* @param url {string} The remote URL of the image
* @returns {Promise<string>} Returns a promise that resolves with the local path if exists, or rejects if doesn't exist
*/
private getCachedImagePath(url: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
// make sure cache is ready
if (!this.isCacheReady) {
return reject();
}
// get file name
const fileName = this.createFileName(url);
// get full path
const dirPath = this.file.cacheDirectory + this.config.cacheDirectoryName,
tempDirPath = this.file.tempDirectory + this.config.cacheDirectoryName;
// check if exists
this.file.resolveLocalFilesystemUrl(dirPath + '/' + fileName)
.then((fileEntry: FileEntry) => {
// file exists in cache
if (this.config.imageReturnType === 'base64') {
// read the file as data url and return the base64 string.
// should always be successful as the existence of the file
// is alreay ensured
this.file
.readAsDataURL(dirPath, fileName)
.then((base64: string) => {
base64 = base64.replace('data:null', 'data:*/*');
resolve(base64);
})
.catch(reject);
} else if (this.config.imageReturnType === 'uri') {
// now check if iOS device & using WKWebView Engine.
// in this case only the tempDirectory is accessible,
// therefore the file needs to be copied into that directory first!
if (this.isIonicWKWebView) {
// Ionic WKWebView can access all files, but we just need to replace file:/// with http://localhost:8080/
resolve(fileEntry.nativeURL.replace('file:///', 'http://localhost:8080/'));
} else if (this.isWKWebView) {
// check if file already exists in temp directory
this.file.resolveLocalFilesystemUrl(tempDirPath + '/' + fileName)
.then((tempFileEntry: FileEntry) => {
// file exists in temp directory
// return native path
resolve(tempFileEntry.nativeURL);
})
.catch(() => {
// file does not yet exist in the temp directory.
// copy it!
this.file.copyFile(dirPath, fileName, tempDirPath, fileName)
.then((tempFileEntry: FileEntry) => {
// now the file exists in the temp directory
// return native path
resolve(tempFileEntry.nativeURL);
})
.catch(reject);
});
} else {
// return native path
resolve(fileEntry.nativeURL);
}
}
})
.catch(reject); // file doesn't exist
});
}
/**
* Throws a console error if debug mode is enabled
* @param args {any[]} Error message
*/
private throwError(...args: any[]): void {
if (this.config.debugMode) {
args.unshift('ImageLoader Error: ');
console.error.apply(console, args);
}
}
/**
* Throws a console warning if debug mode is enabled
* @param args {any[]} Error message
*/
private throwWarning(...args: any[]): void {
if (this.config.debugMode) {
args.unshift('ImageLoader Warning: ');
console.warn.apply(console, args);
}
}
/**
* Check if the cache directory exists
* @param directory {string} The directory to check. Either this.file.tempDirectory or this.file.cacheDirectory
* @returns {Promise<boolean|FileError>} Returns a promise that resolves if exists, and rejects if it doesn't
*/
private cacheDirectoryExists(directory: string): Promise<boolean> {
return this.file.checkDir(directory, this.config.cacheDirectoryName);
}
/**
* Create the cache directories
* @param replace {boolean} override directory if exists
* @returns {Promise<DirectoryEntry|FileError>} Returns a promise that resolves if the directories were created, and rejects on error
*/
private createCacheDirectory(replace: boolean = false): Promise<any> {
let cacheDirectoryPromise: Promise<any>,
tempDirectoryPromise: Promise<any>;
if (replace) {
// create or replace the cache directory
cacheDirectoryPromise = this.file.createDir(this.file.cacheDirectory, this.config.cacheDirectoryName, replace);
} else {
// check if the cache directory exists.
// if it does not exist create it!
cacheDirectoryPromise = this.cacheDirectoryExists(this.file.cacheDirectory)
.catch(() => this.file.createDir(this.file.cacheDirectory, this.config.cacheDirectoryName, false));
}
if (this.isWKWebView && !this.isIonicWKWebView) {
if (replace) {
// create or replace the temp directory
tempDirectoryPromise = this.file.createDir(this.file.tempDirectory, this.config.cacheDirectoryName, replace);
} else {
// check if the temp directory exists.
// if it does not exist create it!
tempDirectoryPromise = this.cacheDirectoryExists(this.file.tempDirectory)
.catch(() => this.file.createDir(this.file.tempDirectory, this.config.cacheDirectoryName, false));
}
} else {
tempDirectoryPromise = Promise.resolve();
}
return Promise.all([cacheDirectoryPromise, tempDirectoryPromise]);
}
/**
* Creates a unique file name out of the URL
* @param url {string} URL of the file
* @returns {string} Unique file name
*/
private createFileName(url: string): string {
// hash the url to get a unique file name
return this.hashString(url).toString();
}
/**
* Converts a string to a unique 32-bit int
* @param string {string} string to hash
* @returns {number} 32-bit int
*/
private hashString(string: string): number {
let hash = 0, char;
if (string.length === 0) return hash;
for (let i = 0; i < string.length; i++) {
char = string.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return hash;
}
}