A better cache network image

There is a package called cached_network_image but it’s totally insane (the dependencies are insane: it has even sqflite).

So, I created a simpler one that assumes that files on the OS temporary folder are automagically deleted if the OS requires disk space. Also, for Android, I’m assuming the “Clear cache” button will also delete this folder, but not the app’s data itself.

Basically, it’s a copy of the original Flutter’s NetworkImageProvider that considers HTTP caching and uses https://wsrv.nl image cache (one personal need because of cloud egress costs).

It won’t work on Flutter web.

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

import 'package:logging/logging.dart';
import 'package:path/path.dart' as Path;
import 'package:path_provider/path_provider.dart' as PathProvider;

// Method signature for _loadAsync decode callbacks.
typedef _SimpleDecoderCallback =
    Future<ui.Codec> Function(ui.ImmutableBuffer buffer);

final class CachedNetworkImage extends ImageProvider<NetworkImage>
    implements NetworkImage {
  /// Creates an object that fetches the image at the given URL.
  CachedNetworkImage(
    this.url, {
    this.scale = 1.0,
    this.headers,
    this.webHtmlElementStrategy = WebHtmlElementStrategy.never,
  });

  static final _logger = Logger("${CachedNetworkImage}");
  static const _cacheKeyPrefix = "cached_image_";
  static Directory? _cacheDir;

  @override
  final String url;

  @override
  final double scale;

  @override
  final Map<String, String>? headers;

  @override
  final WebHtmlElementStrategy webHtmlElementStrategy;

  double? _devicePixelRatio;
  Size? _targetSize;

  // Utility method to get cache directory
  static Future<Directory> get cacheDir async {
    if (_cacheDir != null) return _cacheDir!;

    _cacheDir = await Directory(
      Path.join((await PathProvider.getTemporaryDirectory()).path, "image_cache"),
    ).create(recursive: true);

    return _cacheDir!;
  }

  // Generate cache file path
  Future<String> _getCacheFilePath() async {
    final hash = url.hashCode.toString();
    final dir = await cacheDir;

    return Path.join(dir.path, "$_cacheKeyPrefix$hash");
  }

  // Parse cache control headers
  _CacheControl _parseCacheControl(HttpClientResponse response) {
    final cacheControl = response.headers[HttpHeaders.cacheControlHeader];
    final expires = response.headers[HttpHeaders.expiresHeader];
    final lastModified = response.headers[HttpHeaders.lastModifiedHeader];
    final etag = response.headers[HttpHeaders.etagHeader];

    DateTime? expiresDate;
    final expiresHeader = expires?.first;

    if (expiresHeader != null) {
      try {
        expiresDate = HttpDate.parse(expiresHeader);
      } catch (_) {
        expiresDate = DateTime.tryParse(expiresHeader);
      }
    }

    return _CacheControl(
      maxAge: _parseMaxAge(cacheControl?.first),
      etag: etag?.first,
      lastModified: lastModified?.first,
      expires: expiresDate,
    );
  }

  // Check if cached file is still valid
  Future<bool> _isCacheValid(String filePath) async {
    final file = File(filePath);

    if (file.existsSync() == false) {
      _logger.fine("_isCacheValid: false, 'cos cache file does not exist");
      return false;
    }

    try {
      final metadata = await _readMetadata(filePath);

      if (metadata == null) {
        _logger.fine("_isCacheValid: false, 'cos metadata file does not exist");
        return false;
      }

      final now = DateTime.now();

      if (metadata.expires != null && metadata.expires!.isBefore(now)) {
        _logger.fine(
          "_isCacheValid: false, 'cos file has expired "
          "(expires: ${metadata.expires})",
        );

        return false;
      }

      if (metadata.maxAge != null) {
        final fileAge = now.difference(metadata.lastAccessed);

        if (fileAge.inSeconds > metadata.maxAge!) {
          _logger.fine(
            "_isCacheValid: false, 'cos max age exceeded "
            "(maxAge: ${metadata.maxAge})",
          );

          return false;
        }
      }

      _logger.fine("_isCacheValid: true");

      return true;
    } catch (e) {
      _logger.warning("_isCacheValid: false, 'cos ${e.runtimeType}", e);

      return false;
    }
  }

  // Helper method to determine if response is cacheable
  bool _isCacheable(HttpClientResponse response, _CacheControl cacheControl) {
    if (response.headers[HttpHeaders.cacheControlHeader]?.any(
          (v) => v.contains("no-store"),
        ) ??
        false) {
      _logger.fine("_isCacheable: false, 'cos no-store cache control is set");

      return false;
    }

    final isCacheable = cacheControl.maxAge != null || cacheControl.expires != null;

    if (isCacheable == false) {
      _logger.fine("_isCacheable: false, 'cos maxAge or expires is null");
    }

    return isCacheable;
  }

  // Save image and metadata to cache
  Future<void> _saveToCache(
    String filePath,
    Uint8List bytes,
    _CacheControl cacheControl,
  ) async {
    _logger.fine("_saveToCache: Saving image");
    await File(filePath).writeAsBytes(bytes);
    _logger.fine("_saveToCache: Saving metadata");
    await _saveMetadata(filePath, cacheControl);
  }

  // Parse max-age value from Cache-Control header
  int? _parseMaxAge(String? cacheControl) {
    if (cacheControl == null) {
      _logger.fine("_parseMaxAge: false, 'cos no cache control header");
      return null;
    }

    final maxAgeMatch = RegExp(r"max-age=(\d+)").firstMatch(cacheControl);

    if (maxAgeMatch != null) {
      final maxAge = int.tryParse(maxAgeMatch.group(1)!);

      _logger.fine("_parseMaxAge: ${maxAge}");

      return maxAge;
    }

    _logger.fine("_parseMaxAge: false, 'cos max-age was not found");
    return null;
  }

  // Read metadata from the metadata file
  Future<_CacheControl?> _readMetadata(String imagePath) async {
    final metadataFile = File("$imagePath.metadata");

    if (metadataFile.existsSync() == false) {
      _logger.fine("_readMetadata: false, 'cos file does not exist");
      return null;
    }

    try {
      final json = await metadataFile.readAsString();
      final map = jsonDecode(json) as Map<String, dynamic>;

      _logger.fine("_readMetadata: ${map}");

      return _CacheControl.fromMap(map);
    } catch (e) {
      _logger.fine("_readMetadata: false, 'cos ${e.runtimeType}", e);
      return null;
    }
  }

  // Save metadata to a separate file
  Future<void> _saveMetadata(String imagePath, _CacheControl cacheControl) async {
    final metadataFile = File("$imagePath.metadata");
    final map = cacheControl.toMap();
    final json = jsonEncode(map);

    _logger.fine("_saveMetadata: ${map}");

    await metadataFile.writeAsString(json);
  }

  @override
  Future<NetworkImage> obtainKey(ImageConfiguration configuration) {
    _devicePixelRatio = configuration.devicePixelRatio;
    _targetSize = configuration.size;

    return SynchronousFuture<NetworkImage>(this);
  }

  String get _cdnUrl {
    if (_targetSize == null || _devicePixelRatio == null) {
      return "https://wsrv.nl"
          "?url=${Uri.encodeComponent(url)}"
          "&maxage=1y";
    }

    return "https://wsrv.nl"
        "?url=${Uri.encodeComponent(url)}"
        "&w=${_targetSize!.width.round()}"
        "&h=${_targetSize!.height.round()}"
        "&dpr=${_devicePixelRatio!.round()}"
        "&maxage=1y";
  }

  @override
  @Deprecated("Use loadImage instead")
  ImageStreamCompleter loadBuffer(NetworkImage key, DecoderBufferCallback decode) {
    return loadImage(key, _decoderBufferToImageDecoderCallback(decode));
  }

  ImageDecoderCallback _decoderBufferToImageDecoderCallback(
    // ignore: deprecated_member_use
    DecoderBufferCallback decode,
  ) {
    return (ImmutableBuffer buffer, {ui.TargetImageSizeCallback? getTargetSize}) {
      return decode(buffer);
    };
  }

  @override
  ImageStreamCompleter loadImage(NetworkImage key, ImageDecoderCallback decode) {
    // Ownership of this controller is handed off to [_loadAsync]; it is that
    // method's responsibility to close the controller's stream when the image
    // has been loaded or an error is thrown.
    final chunkEvents = StreamController<ImageChunkEvent>();

    return MultiFrameImageStreamCompleter(
      codec: _loadAsync(key, chunkEvents, decode: decode),
      chunkEvents: chunkEvents.stream,
      scale: key.scale,
      debugLabel: key.url,
      informationCollector:
          () => <DiagnosticsNode>[
            DiagnosticsProperty<ImageProvider>("Image provider", this),
            DiagnosticsProperty<NetworkImage>("Image key", key),
          ],
    );
  }

  // Do not access this field directly; use [_httpClient] instead.
  // We set `autoUncompress` to false to ensure that we can trust the value of
  // the `Content-Length` HTTP header. We automatically uncompress the content
  // in our call to [consolidateHttpClientResponseBytes].
  static final HttpClient _sharedHttpClient = HttpClient()..autoUncompress = false;

  static HttpClient get _httpClient {
    HttpClient? client;

    assert(() {
      if (debugNetworkImageHttpClientProvider != null) {
        client = debugNetworkImageHttpClientProvider!();
      }

      return true;
    }(), "");

    return client ?? _sharedHttpClient;
  }

  Future<ui.Codec> _attemptLoadUrl(
    String urlToLoad,
    String filePath,
    StreamController<ImageChunkEvent> chunkEvents,
    _SimpleDecoderCallback decode,
  ) async {
    _logger.fine("Trying to load from ${urlToLoad}");

    final resolved = Uri.base.resolve(urlToLoad);
    final request = await _httpClient.getUrl(resolved);
    final metadata = await _readMetadata(filePath);

    if (metadata?.etag != null) {
      request.headers.add(HttpHeaders.ifNoneMatchHeader, metadata!.etag!);
    }

    if (metadata?.lastModified != null) {
      request.headers.add(
        HttpHeaders.ifModifiedSinceHeader,
        metadata!.lastModified!,
      );
    }

    headers?.forEach((String name, String value) {
      request.headers.add(name, value);
    });

    final response = await request.close();

    switch (response.statusCode) {
      case HttpStatus.ok:
        break;
      case HttpStatus.notModified:
        _logger.fine("Network call returned 304 - Not Modified");
        final bytes = await File(filePath).readAsBytes();
        return decode(await ui.ImmutableBuffer.fromUint8List(bytes));
      default:
        await response.drain<List<int>>(<int>[]);
        throw NetworkImageLoadException(
          statusCode: response.statusCode,
          uri: resolved,
        );
    }

    final bytes = await consolidateHttpClientResponseBytes(
      response,
      onBytesReceived: (int cumulative, int? total) {
        chunkEvents.add(
          ImageChunkEvent(
            cumulativeBytesLoaded: cumulative,
            expectedTotalBytes: total,
          ),
        );
      },
    );

    if (bytes.lengthInBytes == 0) {
      throw Exception("NetworkImage is an empty file: $resolved");
    }

    final cacheControl = _parseCacheControl(response);
    if (_isCacheable(response, cacheControl)) {
      await _saveToCache(filePath, bytes, cacheControl);
    }

    return decode(await ui.ImmutableBuffer.fromUint8List(bytes));
  }

  Future<ui.Codec> _loadAsync(
    NetworkImage key,
    StreamController<ImageChunkEvent> chunkEvents, {
    required _SimpleDecoderCallback decode,
  }) async {
    try {
      assert(key == this, "");

      final filePath = await _getCacheFilePath();

      if (await _isCacheValid(filePath)) {
        _logger.fine("Returning cached image");

        final bytes = await File(filePath).readAsBytes();

        return decode(await ui.ImmutableBuffer.fromUint8List(bytes));
      }

      try {
        return await _attemptLoadUrl(_cdnUrl, filePath, chunkEvents, decode);
      } catch (e) {
        _logger.warning("CDN load failed, falling back to original URL", e);

        return await _attemptLoadUrl(url, filePath, chunkEvents, decode);
      }
    } catch (e) {
      // Depending on where the exception was thrown, the image cache may not
      // have had a chance to track the key in the cache at all.
      // Schedule a microtask to give the cache a chance to add the key.
      scheduleMicrotask(() {
        PaintingBinding.instance.imageCache.evict(key);
      });

      rethrow;
    } finally {
      unawaited(chunkEvents.close());
    }
  }

  @override
  // ignore: avoid_equals_and_hash_code_on_mutable_classes
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }

    return other is CachedNetworkImage &&
        other.url == url &&
        other.scale == scale &&
        _devicePixelRatio == other._devicePixelRatio &&
        _targetSize == other._targetSize;
  }

  @override
  // ignore: avoid_equals_and_hash_code_on_mutable_classes
  int get hashCode => Object.hash(url, scale);

  @override
  String toString() =>
      "${objectRuntimeType(this, "NetworkImage")}"
      '("$url", scale: ${scale.toStringAsFixed(1)})';
}

final class _CacheControl {
  _CacheControl({
    this.maxAge,
    this.etag,
    this.lastModified,
    this.expires,
    DateTime? lastAccessed,
  }) : lastAccessed = lastAccessed ?? DateTime.now();

  factory _CacheControl.fromMap(Map<String, dynamic> json) {
    return _CacheControl(
      maxAge: json["maxAge"] as int?,
      etag: json["etag"] as String?,
      lastModified: json["lastModified"] as String?,
      expires:
          json["expires"] != null ? DateTime.parse(json["expires"] as String) : null,
      lastAccessed: DateTime.parse(json["lastAccessed"] as String),
    );
  }

  final int? maxAge;
  final String? etag;
  final String? lastModified;
  final DateTime? expires;
  final DateTime lastAccessed;

  Map<String, dynamic> toMap() => {
    "maxAge": maxAge,
    "etag": etag,
    "lastModified": lastModified,
    "expires": expires?.toIso8601String(),
    "lastAccessed": lastAccessed.toIso8601String(),
  };
}
7 Likes

So why don’t you publish it in pub.dev?

I don’t think it has a quality to be published… it doesn’t work on Flutter Web (because it requires dart:io) and there is also a 3rd party CDN that people could not want to use.