diff --git a/README.md b/README.md index 9c6de34..8dcc585 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,27 @@ +# Why a fork ? + +This fork is meant to propose a solution to use FirebaseImage plugin on any platform, even if `sqflite` and `dart:io` are not available. In particular, it is now possible to use this plugin on the Web. + +:warning: Warnings: + +- This is not a maintained nor a tested fork. I use it only for a hobby project running on both Web and Android platforms. +- On `Web` platform, the "cache" is done in memory. This is an important limitation since memory is neither shared between tabs nor persistent. + +## Fork implementation + +I'm a complete beginner with Flutter/Dart so I went for the simplest solution here. I used [this blog article](https://medium.com/@rody.davis.jr/how-to-build-a-native-cross-platform-project-with-flutter-372b9e4b504f) as inspiration. + +Main ideas: + +- The `FirebaseImageCacheManager` class is abstracted by `AbstractedFirebaseImageCacheManager`. +- The sqflite-based `FirebaseImageCacheManager` is kept as it is (few minor naming changes). +- A memory-based `FirebaseImageCacheManager` is implemented using a simple `Map` object. +- Depending on the presence of `sqflite` dart package, the right cache manager is exported. + # 🔥 Firebase Image Provider [![pub package](https://img.shields.io/pub/v/firebase_image.svg)](https://pub.dartlang.org/packages/firebase_image) - A cached Flutter ImageProvider for Firebase Cloud Storage image objects. ## How to use @@ -14,6 +33,7 @@ Supply the `FirebaseImage` widget with the image's URI (e.g. `gs://bucket123/use See the below for example code. ## How does it work? + The code downloads the image (object) into memory as a byte array. Unless disabled using the `cacheRefreshStrategy: CacheRefreshStrategy.NEVER` option, it gets the object's last update time from metadata (a millisecond precision integer timstamp) and uses that as a defacto version number. Therefore, any update to that remote object will result in the new version being downloaded. @@ -23,6 +43,7 @@ The image byte array in memory then gets saved to a file in the temporary direct Metadata retrival is a 'Class B Operation' and has 50,000 free operations per month. After that, it is billed at $0.04 / 100,000 operations and so the default behaviour of `cacheRefreshStrategy: CacheRefreshStrategy.BY_METADATA_DATE` may incur extra cost if the object never changes. This makes this implementation a cost effective stratergy for caching as the entire object doesn't have to be transfered just to check if there have been any updates. Essentailly, any images will only need to be downloaded once per device. ## Example + ```dart import 'package:flutter/material.dart'; import 'package:firebase_image/firebase_image.dart'; @@ -54,6 +75,7 @@ class IconImage extends StatelessWidget { - [ ] Create unit tests ## Contributing + If you want to contribute, please fork the project and play around there! If you're stuck for ideas, check [Issues](https://github.com/mattreid1/firebase_image/issues) or the above To Do list for inspiration. diff --git a/lib/src/cache_manager/abstract.dart b/lib/src/cache_manager/abstract.dart new file mode 100644 index 0000000..32900d0 --- /dev/null +++ b/lib/src/cache_manager/abstract.dart @@ -0,0 +1,57 @@ +import 'dart:typed_data'; + +import 'package:firebase_image/firebase_image.dart'; +import 'package:firebase_image/src/firebase_image.dart'; +import 'package:firebase_image/src/image_object.dart'; + +abstract class AbstractFirebaseImageCacheManager { + final CacheRefreshStrategy cacheRefreshStrategy; + + AbstractFirebaseImageCacheManager( + this.cacheRefreshStrategy, + ); + + Future open() async {} + + Future close() async {} + + Future getObject( + String uri, FirebaseImage image) async { + throw UnimplementedError(); + } + + Future> getAllObjects() async { + throw UnimplementedError(); + } + + Future getLocalFileBytes(FirebaseImageObject? object) async { + throw UnimplementedError(); + } + + Future upsertRemoteFileToCache( + FirebaseImageObject object, int maxSizeBytes) async { + throw UnimplementedError(); + } + + Future getRemoteFileBytes( + FirebaseImageObject object, int maxSizeBytes) { + return object.reference.getData(maxSizeBytes); + } + + Future getRemoteVersion( + FirebaseImageObject object, int defaultValue) async { + return (await object.reference.getMetadata()) + .updated + ?.millisecondsSinceEpoch ?? + defaultValue; + } + + Future checkForUpdate( + FirebaseImageObject object, FirebaseImage image) async { + int remoteVersion = await getRemoteVersion(object, -1); + if (remoteVersion != object.version) { + // If true, download new image for next load + await this.upsertRemoteFileToCache(object, image.maxSizeBytes); + } + } +} diff --git a/lib/src/cache_manager/memory_cache.dart b/lib/src/cache_manager/memory_cache.dart new file mode 100644 index 0000000..a6f4203 --- /dev/null +++ b/lib/src/cache_manager/memory_cache.dart @@ -0,0 +1,64 @@ +import 'dart:typed_data'; + +import 'package:firebase_image/firebase_image.dart'; +import 'package:firebase_image/src/cache_manager/abstract.dart'; +import 'package:firebase_image/src/firebase_image.dart'; +import 'package:firebase_image/src/image_object.dart'; + +class MemoryCacheEntry { + /// Model for an entry in the memory cache + FirebaseImageObject object; + Uint8List? bytes; + + MemoryCacheEntry({ + required this.object, + required this.bytes, + }); +} + +class FirebaseImageCacheManager extends AbstractFirebaseImageCacheManager { + /// Key is the URI of the image + final memoryCache = Map(); + + FirebaseImageCacheManager(cacheRefreshStrategy) : super(cacheRefreshStrategy); + + // Interface methods + + Future getObject( + String uri, FirebaseImage image) async { + final cacheEntry = memoryCache[uri]; + if (cacheEntry == null) { + return null; + } else { + final returnObject = cacheEntry.object; + if (CacheRefreshStrategy.BY_METADATA_DATE == this.cacheRefreshStrategy) { + checkForUpdate(returnObject, image); // Check for update in background + } + return returnObject; + } + } + + Future> getAllObjects() async { + final List objects = []; + memoryCache.forEach((k, v) => objects.add(v.object)); + return objects; + } + + Future getLocalFileBytes(FirebaseImageObject? object) async { + final cacheEntry = memoryCache[object?.uri]; + return cacheEntry?.bytes; + } + + Future upsertRemoteFileToCache( + FirebaseImageObject object, int maxSizeBytes) async { + if (CacheRefreshStrategy.BY_METADATA_DATE == this.cacheRefreshStrategy) { + object.version = await getRemoteVersion(object, 0); + } + Uint8List? bytes = await getRemoteFileBytes(object, maxSizeBytes); + + // "store" bytes in memory + memoryCache[object.uri] = MemoryCacheEntry(object: object, bytes: bytes); + + return bytes; + } +} diff --git a/lib/src/cache_manager.dart b/lib/src/cache_manager/sqflite_cache.dart similarity index 64% rename from lib/src/cache_manager.dart rename to lib/src/cache_manager/sqflite_cache.dart index 7c3a0e3..6f22285 100644 --- a/lib/src/cache_manager.dart +++ b/lib/src/cache_manager/sqflite_cache.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_image/firebase_image.dart'; +import 'package:firebase_image/src/cache_manager/abstract.dart'; import 'package:firebase_image/src/firebase_image.dart'; import 'package:firebase_image/src/image_object.dart'; import 'package:firebase_storage/firebase_storage.dart'; @@ -10,7 +11,7 @@ import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; -class FirebaseImageCacheManager { +class FirebaseImageCacheManager extends AbstractFirebaseImageCacheManager { static const String key = 'firebase_image'; late Database db; @@ -18,11 +19,9 @@ class FirebaseImageCacheManager { static const String table = 'images'; late String basePath; - final CacheRefreshStrategy cacheRefreshStrategy; + FirebaseImageCacheManager(cacheRefreshStrategy) : super(cacheRefreshStrategy); - FirebaseImageCacheManager( - this.cacheRefreshStrategy, - ); + // Interface methods Future open() async { db = await openDatabase( @@ -40,43 +39,13 @@ class FirebaseImageCacheManager { }, version: 1, ); - basePath = await _createFilePath(); + basePath = await _fileCreatePath(); } - Future insert(FirebaseImageObject model) async { - await db.insert(table, model.toMap()); - return model; - } - - Future update(FirebaseImageObject model) async { - await db.update( - table, - model.toMap(), - where: 'uri = ?', - whereArgs: [model.uri], - ); - return model; - } - - Future upsert(FirebaseImageObject object) async { - if (await checkDatabaseForEntry(object)) { - return await update(object); - } else { - return await insert(object); - } - } - - Future checkDatabaseForEntry(FirebaseImageObject object) async { - final List> maps = await db.query( - table, - columns: const ['uri'], - where: 'uri = ?', - whereArgs: [object.uri], - ); - return maps.length > 0; - } + Future close() => db.close(); - Future get(String uri, FirebaseImage image) async { + Future getObject( + String uri, FirebaseImage image) async { final List> maps = await db.query( table, columns: const [ @@ -91,7 +60,7 @@ class FirebaseImageCacheManager { if (maps.length > 0) { FirebaseImageObject returnObject = FirebaseImageObject.fromMap(maps.first); - returnObject.reference = getImageRef(returnObject, image.firebaseApp); + returnObject.reference = _getImageRef(returnObject, image.firebaseApp); if (CacheRefreshStrategy.BY_METADATA_DATE == this.cacheRefreshStrategy) { checkForUpdate(returnObject, image); // Check for update in background } @@ -100,51 +69,20 @@ class FirebaseImageCacheManager { return null; } - Reference getImageRef(FirebaseImageObject object, FirebaseApp? firebaseApp) { - FirebaseStorage storage = - FirebaseStorage.instanceFor(app: firebaseApp, bucket: object.bucket); - return storage.ref().child(object.remotePath); - } - - Future checkForUpdate( - FirebaseImageObject object, FirebaseImage image) async { - int remoteVersion = (await object.reference.getMetadata()) - .updated - ?.millisecondsSinceEpoch ?? - -1; - if (remoteVersion != object.version) { - // If true, download new image for next load - await this.upsertRemoteFileToCache(object, image.maxSizeBytes); - } - } - - Future> getAll() async { + Future> getAllObjects() async { final List> maps = await db.query(table); return List.generate(maps.length, (i) { return FirebaseImageObject.fromMap(maps[i]); }); } - Future delete(String uri) async { - return await db.delete( - table, - where: 'uri = ?', - whereArgs: [uri], - ); - } - - Future localFileBytes(FirebaseImageObject? object) async { + Future getLocalFileBytes(FirebaseImageObject? object) async { if (await _fileExists(object)) { return File(object!.localPath!).readAsBytes(); } return null; } - Future remoteFileBytes( - FirebaseImageObject object, int maxSizeBytes) { - return object.reference.getData(maxSizeBytes); - } - Future upsertRemoteFileToCache( FirebaseImageObject object, int maxSizeBytes) async { if (CacheRefreshStrategy.BY_METADATA_DATE == this.cacheRefreshStrategy) { @@ -153,33 +91,75 @@ class FirebaseImageCacheManager { ?.millisecondsSinceEpoch ?? 0; } - Uint8List? bytes = await remoteFileBytes(object, maxSizeBytes); - await putFile(object, bytes); + Uint8List? bytes = await getRemoteFileBytes(object, maxSizeBytes); + await _filePut(object, bytes); return bytes; } - Future putFile( + // Firestore&-related methods + + Reference _getImageRef(FirebaseImageObject object, FirebaseApp? firebaseApp) { + FirebaseStorage storage = + FirebaseStorage.instanceFor(app: firebaseApp, bucket: object.bucket); + return storage.ref().child(object.remotePath); + } + + // Filesystem-related methods + + Future _fileCreatePath() async { + final directory = await getTemporaryDirectory(); + return join(directory.path, key); + } + + Future _fileExists(FirebaseImageObject? object) async { + if (object?.localPath == null) { + return false; + } + return File(object!.localPath!).exists(); + } + + Future _filePut( FirebaseImageObject object, final bytes) async { String path = basePath + "/" + object.remotePath; path = path.replaceAll("//", "/"); - //print(join(basePath, object.remotePath)); Join isn't working? final localFile = await File(path).create(recursive: true); await localFile.writeAsBytes(bytes); object.localPath = localFile.path; - return await upsert(object); + return await _dbUpsert(object); } - Future _fileExists(FirebaseImageObject? object) async { - if (object?.localPath == null) { - return false; - } - return File(object!.localPath!).exists(); + // DB-related methods + + Future _dbCheckForEntry(FirebaseImageObject object) async { + final List> maps = await db.query( + table, + columns: const ['uri'], + where: 'uri = ?', + whereArgs: [object.uri], + ); + return maps.length > 0; } - Future _createFilePath() async { - final directory = await getTemporaryDirectory(); - return join(directory.path, key); + Future _dbInsert(FirebaseImageObject model) async { + await db.insert(table, model.toMap()); + return model; } - Future close() => db.close(); + Future _dbUpdate(FirebaseImageObject model) async { + await db.update( + table, + model.toMap(), + where: 'uri = ?', + whereArgs: [model.uri], + ); + return model; + } + + Future _dbUpsert(FirebaseImageObject object) async { + if (await _dbCheckForEntry(object)) { + return await _dbUpdate(object); + } else { + return await _dbInsert(object); + } + } } diff --git a/lib/src/cache_manager/universal.dart b/lib/src/cache_manager/universal.dart new file mode 100644 index 0000000..aaf8e15 --- /dev/null +++ b/lib/src/cache_manager/universal.dart @@ -0,0 +1,2 @@ +export 'package:firebase_image/src/cache_manager/memory_cache.dart' + if (dart.library.io) 'package:firebase_image/src/cache_manager/sqflite_cache.dart'; diff --git a/lib/src/firebase_image.dart b/lib/src/firebase_image.dart index 5526269..dbc440b 100644 --- a/lib/src/firebase_image.dart +++ b/lib/src/firebase_image.dart @@ -1,14 +1,18 @@ import 'dart:typed_data'; import 'dart:ui'; +import 'dart:convert' show base64Decode; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_image/firebase_image.dart'; -import 'package:firebase_image/src/cache_manager.dart'; +import 'package:firebase_image/src/cache_manager/universal.dart'; import 'package:firebase_image/src/image_object.dart'; import 'package:firebase_storage/firebase_storage.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +final Uint8List emptyImagePlaceholder = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAABhGlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV/TSv2oONhBxCFDdbIgWsRRq1CECqFWaNXB5NIvaNKQpLg4Cq4FBz8Wqw4uzro6uAqC4AeIm5uToouU+L+00CLGg+N+vLv3uHsHCPUy06zABKDptplKxMVMdlUMviKAHvQhhkGZWcacJCXhOb7u4ePrXZRneZ/7c/SrOYsBPpF4lhmmTbxBPL1pG5z3icOsKKvE58TjJl2Q+JHrSpPfOBdcFnhm2Eyn5onDxGKhg5UOZkVTI44RR1RNp3wh02SV8xZnrVxlrXvyF4Zy+soy12mOIIFFLEGCCAVVlFCGjSitOikWUrQf9/APu36JXAq5SmDkWEAFGmTXD/4Hv7u18lOTzaRQHOh6cZyPUSC4CzRqjvN97DiNE8D/DFzpbX+lDsx8kl5ra5EjYGAbuLhua8oecLkDDD0Zsim7kp+mkM8D72f0TVlg8BboXWv21trH6QOQpq6SN8DBITBWoOx1j3d3d/b275lWfz9Z83Kd00lbqwAAAAlwSFlzAAAuIwAALiMBeKU/dgAAAAd0SU1FB+UECQs0OeqvXcUAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC'); + class FirebaseImage extends ImageProvider { // Default: True. Specified whether or not an image should be cached (optional) final bool shouldCache; @@ -16,6 +20,9 @@ class FirebaseImage extends ImageProvider { /// Default: 1.0. The scale to display the image at (optional) final double scale; + // Default null. What to return if no bytes are returned (example: wrong uri, image too large,...) + final Uint8List Function()? getDefaultBytesCallback; + /// Default: 2.5MB. The maximum size in bytes to be allocated in the device's memory for the image (optional) final int maxSizeBytes; @@ -31,6 +38,7 @@ class FirebaseImage extends ImageProvider { /// Fetches, saves and returns an ImageProvider for any image in a readable Firebase Cloud Storeage bucket. /// /// [location] The URI of the image, in the bucket, to be displayed + /// [getDefaultBytesCallback] Default: null. The bytes to return if no image is fetched. /// [shouldCache] Default: True. Specified whether or not an image should be cached (optional) /// [scale] Default: 1.0. The scale to display the image at (optional) /// [maxSizeBytes] Default: 2.5MB. The maximum size in bytes to be allocated in the device's memory for the image (optional) @@ -41,6 +49,7 @@ class FirebaseImage extends ImageProvider { this.shouldCache = true, this.scale = 1.0, this.maxSizeBytes = 2500 * 1000, // 2.5MB + this.getDefaultBytesCallback, this.cacheRefreshStrategy = CacheRefreshStrategy.BY_METADATA_DATE, this.firebaseApp, }) : _imageObject = FirebaseImageObject( @@ -49,11 +58,6 @@ class FirebaseImage extends ImageProvider { reference: _getImageRef(location, firebaseApp), ); - /// Returns the image as bytes - Future getBytes() { - return _fetchImage(); - } - static String _getBucket(String location) { final uri = Uri.parse(location); return '${uri.scheme}://${uri.authority}'; @@ -70,7 +74,7 @@ class FirebaseImage extends ImageProvider { return storage.ref().child(_getImagePath(location)); } - Future _fetchImage() async { + Future _fetchImage() async { Uint8List? bytes; FirebaseImageCacheManager cacheManager = FirebaseImageCacheManager( cacheRefreshStrategy, @@ -79,10 +83,10 @@ class FirebaseImage extends ImageProvider { if (shouldCache) { await cacheManager.open(); FirebaseImageObject? localObject = - await cacheManager.get(_imageObject.uri, this); + await cacheManager.getObject(_imageObject.uri, this); if (localObject != null) { - bytes = await cacheManager.localFileBytes(localObject); + bytes = await cacheManager.getLocalFileBytes(localObject); if (bytes == null) { bytes = await cacheManager.upsertRemoteFileToCache( _imageObject, this.maxSizeBytes); @@ -92,16 +96,27 @@ class FirebaseImage extends ImageProvider { _imageObject, this.maxSizeBytes); } } else { - bytes = - await cacheManager.remoteFileBytes(_imageObject, this.maxSizeBytes); + bytes = await cacheManager.getRemoteFileBytes( + _imageObject, this.maxSizeBytes); } - return bytes!; + return bytes; + } + + Future _fetchImageOrDefault() async { + Uint8List? bytes; + try { + bytes = await _fetchImage(); + } on FirebaseException { + // Image does not exist -> silently catch error + bytes = null; + } + return bytes ?? getDefaultBytes(); } Future _fetchImageCodec() async { return await PaintingBinding.instance! - .instantiateImageCodec(await _fetchImage()); + .instantiateImageCodec(await _fetchImageOrDefault()); } @override @@ -131,4 +146,11 @@ class FirebaseImage extends ImageProvider { @override String toString() => '$runtimeType("${_imageObject.uri}", scale: ${this.scale})'; + + Uint8List getDefaultBytes() { + if (this.getDefaultBytesCallback == null) { + return emptyImagePlaceholder; + } + return this.getDefaultBytesCallback!(); + } }