diff --git a/lib/components/autocomplete.dart b/lib/components/autocomplete.dart index f082c0b55..a5f0fc127 100644 --- a/lib/components/autocomplete.dart +++ b/lib/components/autocomplete.dart @@ -1,6 +1,6 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/models/chat_mode.dart'; import 'package:rtchat/models/commands.dart'; import 'package:rtchat/models/messages.dart'; @@ -117,7 +117,9 @@ class _AutocompleteWidgetState extends State { width: 24, height: 24, filterQuality: FilterQuality.medium, - image: ResilientNetworkImage(emote.uri), + image: CachedNetworkImageProvider( + emote.uri.toString(), + ), ), ); }) diff --git a/lib/components/channel_search_results.dart b/lib/components/channel_search_results.dart index 4a6c3dfbd..63df221ec 100644 --- a/lib/components/channel_search_results.dart +++ b/lib/components/channel_search_results.dart @@ -1,11 +1,11 @@ import 'dart:async'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:rtchat/api.dart'; import 'package:rtchat/components/image/cross_fade_image.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/components/provider_icon.dart'; import 'package:rtchat/l10n/app_localizations.dart'; import 'package:rtchat/models/channels.dart'; @@ -262,7 +262,8 @@ class SearchResult { required this.language, }); - ResilientNetworkImage get image => ResilientNetworkImage(imageUrl); + CachedNetworkImageProvider get image => + CachedNetworkImageProvider(imageUrl.toString()); Channel get channel => Channel(provider, channelId, displayName); } @@ -463,7 +464,7 @@ class _ChannelSearchResultsWidgetState ClipRRect( borderRadius: BorderRadius.circular(24), child: CrossFadeImage( - placeholder: result.image.placeholderImage, + placeholder: MemoryImage(kTransparentImage), image: result.image, height: 48, width: 48, diff --git a/lib/components/chat_history/decorated_event.dart b/lib/components/chat_history/decorated_event.dart index 4232b0d9e..57de799cc 100644 --- a/lib/components/chat_history/decorated_event.dart +++ b/lib/components/chat_history/decorated_event.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:rtchat/components/image/cross_fade_image.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/models/style.dart'; class DecoratedEventWidget extends StatelessWidget { diff --git a/lib/components/chat_history/kick/events.dart b/lib/components/chat_history/kick/events.dart index 7c96e26b7..d6d599a6d 100644 --- a/lib/components/chat_history/kick/events.dart +++ b/lib/components/chat_history/kick/events.dart @@ -1,7 +1,7 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:rtchat/components/chat_history/decorated_event.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/l10n/app_localizations.dart'; import 'package:rtchat/models/messages/kick/event.dart'; import 'package:rtchat/models/messages/kick/user.dart'; @@ -32,7 +32,7 @@ Widget _decorated(KickUserModel? user, IconData icon, Widget child) { final picture = user?.profilePictureUrl; if (picture != null) { return DecoratedEventWidget.avatar( - avatar: ResilientNetworkImage(Uri.parse(picture)), + avatar: CachedNetworkImageProvider(picture), child: child, ); } diff --git a/lib/components/chat_history/tokens.dart b/lib/components/chat_history/tokens.dart index 9e95bfe6c..ac973bc1b 100644 --- a/lib/components/chat_history/tokens.dart +++ b/lib/components/chat_history/tokens.dart @@ -1,7 +1,7 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/models/layout.dart'; import 'package:rtchat/models/messages/tokens.dart'; import 'package:rtchat/models/style.dart'; @@ -48,7 +48,7 @@ Iterable renderToken( preferBelow: false, child: Image( height: styleModel.fontSize, - image: ResilientNetworkImage(token.url), + image: CachedNetworkImageProvider(token.url.toString()), errorBuilder: (context, error, stackTrace) => Text(token.code), ), ), diff --git a/lib/components/chat_history/twitch/badge.dart b/lib/components/chat_history/twitch/badge.dart index 8304b16bb..227397ac1 100644 --- a/lib/components/chat_history/twitch/badge.dart +++ b/lib/components/chat_history/twitch/badge.dart @@ -1,6 +1,6 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/models/messages/twitch/badge.dart'; class TwitchBadgeWidget extends StatelessWidget { @@ -36,7 +36,10 @@ class TwitchBadgeWidget extends StatelessWidget { } return Padding( padding: const EdgeInsets.only(right: 5), - child: Image(image: ResilientNetworkImage(url), height: height), + child: Image( + image: CachedNetworkImageProvider(url.toString()), + height: height, + ), ); } } diff --git a/lib/components/chat_history/twitch/cheer_event.dart b/lib/components/chat_history/twitch/cheer_event.dart index cde5b36f3..35b971f7c 100644 --- a/lib/components/chat_history/twitch/cheer_event.dart +++ b/lib/components/chat_history/twitch/cheer_event.dart @@ -1,6 +1,6 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:rtchat/components/chat_history/decorated_event.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/l10n/app_localizations.dart'; import 'package:rtchat/models/messages/twitch/event.dart'; import 'package:styled_text/styled_text.dart'; @@ -30,7 +30,9 @@ class TwitchCheerEventWidget extends StatelessWidget { ? AppLocalizations.of(context)!.anonymous : model.giverName ?? AppLocalizations.of(context)!.anonymous; return DecoratedEventWidget.avatar( - avatar: ResilientNetworkImage(getCorrespondingImageUrl(model.bits)), + avatar: CachedNetworkImageProvider( + getCorrespondingImageUrl(model.bits).toString(), + ), child: StyledText( text: AppLocalizations.of( context, diff --git a/lib/components/chat_history/twitch/follow_event.dart b/lib/components/chat_history/twitch/follow_event.dart index 6426f776a..9b725a064 100644 --- a/lib/components/chat_history/twitch/follow_event.dart +++ b/lib/components/chat_history/twitch/follow_event.dart @@ -1,9 +1,9 @@ import 'dart:math'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:rtchat/components/chat_history/decorated_event.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/l10n/app_localizations.dart'; import 'package:rtchat/models/messages/twitch/event.dart'; import 'package:rtchat/models/style.dart'; @@ -56,8 +56,8 @@ class _TwitchFollowEventWidgetState extends State { : followers .take(min(3, followers.length)) .map( - (follower) => ResilientNetworkImage( - follower.profilePictureUrl, + (follower) => CachedNetworkImageProvider( + follower.profilePictureUrl.toString(), ), ), child: @@ -74,8 +74,8 @@ class _TwitchFollowEventWidgetState extends State { width: 32, height: 32, child: Image( - image: ResilientNetworkImage( - follower.profilePictureUrl, + image: CachedNetworkImageProvider( + follower.profilePictureUrl.toString(), ), fit: BoxFit.cover, ), diff --git a/lib/components/chat_history/twitch/host_event.dart b/lib/components/chat_history/twitch/host_event.dart index 091c7f469..8d97c89b9 100644 --- a/lib/components/chat_history/twitch/host_event.dart +++ b/lib/components/chat_history/twitch/host_event.dart @@ -1,6 +1,6 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:rtchat/components/chat_history/decorated_event.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/l10n/app_localizations.dart'; import 'package:rtchat/models/messages/twitch/event.dart'; import 'package:styled_text/styled_text.dart'; @@ -13,7 +13,9 @@ class TwitchHostEventWidget extends StatelessWidget { @override Widget build(BuildContext context) { return DecoratedEventWidget.avatar( - avatar: ResilientNetworkImage(model.from.profilePictureUrl), + avatar: CachedNetworkImageProvider( + model.from.profilePictureUrl.toString(), + ), child: StyledText( text: AppLocalizations.of(context)!.hostEventMessage( model.from.displayName ?? AppLocalizations.of(context)!.anonymous, diff --git a/lib/components/chat_history/twitch/message_link_preview.dart b/lib/components/chat_history/twitch/message_link_preview.dart index 12ef0b4d3..86088de27 100644 --- a/lib/components/chat_history/twitch/message_link_preview.dart +++ b/lib/components/chat_history/twitch/message_link_preview.dart @@ -1,6 +1,6 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:metadata_fetch/metadata_fetch.dart'; import 'package:flutter/material.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; class TwitchClipData { final String? imageUrl; @@ -72,9 +72,7 @@ class _TwitchMessageLinkPreviewWidgetState leading: data.imageUrl == null ? null - : Image( - image: ResilientNetworkImage(Uri.parse(data.imageUrl!)), - ), + : Image(image: CachedNetworkImageProvider(data.imageUrl!)), title: data.title == null ? null : Text(data.title!), subtitle: data.description == null ? null : Text(data.description!), isThreeLine: true, diff --git a/lib/components/chat_history/twitch/raid_event.dart b/lib/components/chat_history/twitch/raid_event.dart index 1fcaa9ceb..47a30768c 100644 --- a/lib/components/chat_history/twitch/raid_event.dart +++ b/lib/components/chat_history/twitch/raid_event.dart @@ -1,8 +1,8 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:cloud_functions/cloud_functions.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:rtchat/components/chat_history/decorated_event.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/l10n/app_localizations.dart'; import 'package:rtchat/models/adapters/actions.dart'; import 'package:rtchat/models/channels.dart'; @@ -20,7 +20,9 @@ class TwitchRaidEventWidget extends StatelessWidget { @override Widget build(BuildContext context) { return DecoratedEventWidget.avatar( - avatar: ResilientNetworkImage(model.from.profilePictureUrl), + avatar: CachedNetworkImageProvider( + model.from.profilePictureUrl.toString(), + ), child: Row( children: [ Expanded( diff --git a/lib/components/chat_history/twitch/raiding_event.dart b/lib/components/chat_history/twitch/raiding_event.dart index ea10d1e37..c61a57aa7 100644 --- a/lib/components/chat_history/twitch/raiding_event.dart +++ b/lib/components/chat_history/twitch/raiding_event.dart @@ -1,7 +1,7 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:rtchat/components/chat_history/decorated_event.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/l10n/app_localizations.dart'; import 'package:rtchat/models/messages/twitch/raiding_event.dart'; import 'package:rtchat/models/views.dart'; @@ -35,8 +35,8 @@ class TwitchRaidingEventWidget extends StatelessWidget { ), DecoratedEventWidget.avatar( decoration: const BoxDecoration(color: Colors.transparent), - avatar: ResilientNetworkImage( - model.targetUser.profilePictureUrl, + avatar: CachedNetworkImageProvider( + model.targetUser.profilePictureUrl.toString(), ), child: Row( children: [ @@ -83,7 +83,9 @@ class TwitchRaidingEventWidget extends StatelessWidget { } else if (model.isSuccessful) { return GestureDetector( child: DecoratedEventWidget.avatar( - avatar: ResilientNetworkImage(model.targetUser.profilePictureUrl), + avatar: CachedNetworkImageProvider( + model.targetUser.profilePictureUrl.toString(), + ), child: Row( children: [ Expanded( @@ -119,7 +121,9 @@ class TwitchRaidingEventWidget extends StatelessWidget { ); } else { return DecoratedEventWidget.avatar( - avatar: ResilientNetworkImage(model.targetUser.profilePictureUrl), + avatar: CachedNetworkImageProvider( + model.targetUser.profilePictureUrl.toString(), + ), child: StyledText( text: AppLocalizations.of(context)!.raidingEventCanceled( model.targetUser.displayName ?? diff --git a/lib/components/chat_history/twitch/subscription_event.dart b/lib/components/chat_history/twitch/subscription_event.dart index a8963903d..1c5f20edb 100644 --- a/lib/components/chat_history/twitch/subscription_event.dart +++ b/lib/components/chat_history/twitch/subscription_event.dart @@ -1,7 +1,7 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:rtchat/components/chat_history/decorated_event.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/l10n/app_localizations.dart'; import 'package:rtchat/models/messages/tokens.dart'; import 'package:rtchat/models/messages/twitch/subscription_event.dart'; @@ -120,7 +120,7 @@ class TwitchSubscriptionMessageEventWidget extends StatelessWidget { preferBelow: false, child: Image( height: styleModel.fontSize, - image: ResilientNetworkImage(token.url), + image: CachedNetworkImageProvider(token.url.toString()), errorBuilder: (context, error, stackTrace) => Text(token.code), ), ), diff --git a/lib/components/drawer/sidebar.dart b/lib/components/drawer/sidebar.dart index 9c190fd5d..dba28a34d 100644 --- a/lib/components/drawer/sidebar.dart +++ b/lib/components/drawer/sidebar.dart @@ -82,10 +82,7 @@ class _DrawerHeader extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(24), child: CrossFadeImage( - placeholder: - userChannel - .profilePicture - .placeholderImage, + placeholder: MemoryImage(kTransparentImage), image: userChannel.profilePicture, height: 36, width: 36, diff --git a/lib/components/emote_picker.dart b/lib/components/emote_picker.dart index afc8a9f14..9c822323d 100644 --- a/lib/components/emote_picker.dart +++ b/lib/components/emote_picker.dart @@ -105,7 +105,7 @@ class EmotesList extends StatelessWidget { onPressed: () => onEmoteSelected(emote), splashRadius: 24, icon: CrossFadeImage( - placeholder: emote.image.placeholderImage, + placeholder: MemoryImage(kTransparentImage), image: emote.image, width: 36, height: 36, diff --git a/lib/components/image/cross_fade_image.dart b/lib/components/image/cross_fade_image.dart index d9e0241e0..f1c97106d 100644 --- a/lib/components/image/cross_fade_image.dart +++ b/lib/components/image/cross_fade_image.dart @@ -1,5 +1,73 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; + +// https://github.com/brianegan/transparent_image/blob/master/lib/transparent_image.dart +final kTransparentImage = Uint8List.fromList([ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE, +]); class CrossFadeImage extends StatelessWidget { const CrossFadeImage({ diff --git a/lib/components/image/placeholder_image.dart b/lib/components/image/placeholder_image.dart deleted file mode 100644 index 0d57b33eb..000000000 --- a/lib/components/image/placeholder_image.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:ui'; - -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:path_provider/path_provider.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; - -class PlaceholderImage extends ImageProvider { - final Uri uri; - final double scale; - - int get hash => uri.hashCode; - - const PlaceholderImage(this.uri, {this.scale = 1.0}); - - @override - ImageStreamCompleter loadImage( - PlaceholderImage key, - ImageDecoderCallback decode, - ) { - final chunkEvents = StreamController(); - return MultiFrameImageStreamCompleter( - chunkEvents: chunkEvents.stream, - codec: _loadAsync(key, chunkEvents, decode), - scale: scale, - debugLabel: key.uri.toString(), - informationCollector: _imageStreamInformationCollector(key), - ); - } - - InformationCollector? _imageStreamInformationCollector(PlaceholderImage key) { - InformationCollector? collector; - assert(() { - collector = - () => [ - DiagnosticsProperty('Image provider', this), - DiagnosticsProperty('Image key', key), - ]; - return true; - }()); - return collector; - } - - static Future _loadAsync( - PlaceholderImage key, - StreamController chunkEvents, - ImageDecoderCallback decode, - ) async { - final temp = await getTemporaryDirectory(); - final cacheFile = File('${temp.path}/${key.hash}'); - final etagFile = File('${temp.path}/${key.hash}.etag'); - - Codec? decodedCache; - - if (await cacheFile.exists() && await etagFile.exists()) { - try { - final bytes = await cacheFile.readAsBytes(); - if (bytes.isNotEmpty) { - decodedCache = await decode( - await ImmutableBuffer.fromUint8List(bytes), - ); - chunkEvents.add( - ImageChunkEvent( - cumulativeBytesLoaded: bytes.lengthInBytes, - expectedTotalBytes: bytes.lengthInBytes, - ), - ); - chunkEvents.close(); - return decodedCache; - } - } catch (e) { - FirebaseCrashlytics.instance.recordError(e, StackTrace.current); - } - } - return await decode(await ImmutableBuffer.fromUint8List(kTransparentImage)); - } - - @override - Future obtainKey(ImageConfiguration configuration) { - return SynchronousFuture(this); - } - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is PlaceholderImage && - other.uri == uri && - other.scale == scale; - } - - @override - int get hashCode => Object.hash(uri, scale); - - @override - String toString() => - '${objectRuntimeType(this, 'PlaceholderImage')}("$uri", scale: $scale)'; -} diff --git a/lib/components/image/resilient_network_image.dart b/lib/components/image/resilient_network_image.dart deleted file mode 100644 index 04a0479af..000000000 --- a/lib/components/image/resilient_network_image.dart +++ /dev/null @@ -1,270 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:ui'; - -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:rtchat/components/image/placeholder_image.dart'; - -// https://github.com/brianegan/transparent_image/blob/master/lib/transparent_image.dart -final kTransparentImage = Uint8List.fromList([ - 0x89, - 0x50, - 0x4E, - 0x47, - 0x0D, - 0x0A, - 0x1A, - 0x0A, - 0x00, - 0x00, - 0x00, - 0x0D, - 0x49, - 0x48, - 0x44, - 0x52, - 0x00, - 0x00, - 0x00, - 0x01, - 0x00, - 0x00, - 0x00, - 0x01, - 0x08, - 0x06, - 0x00, - 0x00, - 0x00, - 0x1F, - 0x15, - 0xC4, - 0x89, - 0x00, - 0x00, - 0x00, - 0x0A, - 0x49, - 0x44, - 0x41, - 0x54, - 0x78, - 0x9C, - 0x63, - 0x00, - 0x01, - 0x00, - 0x00, - 0x05, - 0x00, - 0x01, - 0x0D, - 0x0A, - 0x2D, - 0xB4, - 0x00, - 0x00, - 0x00, - 0x00, - 0x49, - 0x45, - 0x4E, - 0x44, - 0xAE, -]); - -class ResilientNetworkImage extends ImageProvider { - static final HttpClient _sharedHttpClient = - HttpClient()..autoUncompress = false; - - static HttpClient get _httpClient { - HttpClient client = _sharedHttpClient; - assert(() { - if (debugNetworkImageHttpClientProvider != null) { - client = debugNetworkImageHttpClientProvider!(); - } - return true; - }()); - return client; - } - - static final Map> _pending = {}; - - final Uri uri; - final double scale; - - int get hash => uri.hashCode; - - PlaceholderImage get placeholderImage => PlaceholderImage(uri, scale: scale); - - const ResilientNetworkImage(this.uri, {this.scale = 1.0}); - - @override - ImageStreamCompleter loadImage( - ResilientNetworkImage key, - ImageDecoderCallback decode, - ) { - final chunkEvents = StreamController(); - return MultiFrameImageStreamCompleter( - chunkEvents: chunkEvents.stream, - codec: - _pending[hash] ??= _loadAsync(key, chunkEvents, decode).then((x) { - _pending.remove(hash); - return x; - }), - scale: scale, - debugLabel: key.uri.toString(), - informationCollector: _imageStreamInformationCollector(key), - ); - } - - InformationCollector? _imageStreamInformationCollector( - ResilientNetworkImage key, - ) { - InformationCollector? collector; - assert(() { - collector = - () => [ - DiagnosticsProperty('Image provider', this), - DiagnosticsProperty('Image key', key), - ]; - return true; - }()); - return collector; - } - - static Future _loadAsync( - ResilientNetworkImage key, - StreamController chunkEvents, - ImageDecoderCallback decode, - ) async { - final temp = await getTemporaryDirectory(); - final cacheFile = File('${temp.path}/${key.hash}'); - final etagFile = File('${temp.path}/${key.hash}.etag'); - - String? etagValue; - - Codec? decodedCache; - - if (await cacheFile.exists() && await etagFile.exists()) { - try { - final bytes = await cacheFile.readAsBytes(); - if (bytes.isNotEmpty) { - decodedCache = await decode( - await ImmutableBuffer.fromUint8List(bytes), - ); - etagValue = await etagFile.readAsString(); - } - } catch (e) { - FirebaseCrashlytics.instance.recordError(e, StackTrace.current); - } - } - - var delay = const Duration(seconds: 1); - Object? lastError; - - for (var i = 0; i < 30; i++) { - try { - final request = await _httpClient.getUrl(key.uri); - - if (etagValue != null && decodedCache != null) { - request.headers.add(HttpHeaders.ifNoneMatchHeader, etagValue); - } - - final response = await request.close(); - - if (response.statusCode != HttpStatus.ok) { - await response.drain>([]); - - if (response.statusCode == HttpStatus.notModified && - etagValue != null && - decodedCache != null) { - final bytes = await cacheFile.readAsBytes(); - chunkEvents.add( - ImageChunkEvent( - cumulativeBytesLoaded: bytes.lengthInBytes, - expectedTotalBytes: bytes.lengthInBytes, - ), - ); - chunkEvents.close(); - await cacheFile.setLastModified(DateTime.now()); - return decodedCache; - } - if (response.statusCode >= 400 && response.statusCode < 500) { - throw NetworkImageLoadException( - statusCode: response.statusCode, - uri: key.uri, - ); - } else { - // assume this is a retriable error. - continue; - } - } - - final bytes = await consolidateHttpClientResponseBytes( - response, - onBytesReceived: (int cumulative, int? total) { - chunkEvents.add( - ImageChunkEvent( - cumulativeBytesLoaded: cumulative, - expectedTotalBytes: total, - ), - ); - }, - ); - - if (bytes.lengthInBytes == 0) { - throw Exception('ResilientNetworkImage is an empty file: ${key.uri}'); - } - - final etagHeader = response.headers[HttpHeaders.etagHeader]?.first; - if (etagHeader != null) { - await cacheFile.writeAsBytes(bytes); - await etagFile.writeAsString(etagHeader); - } - - chunkEvents.close(); - return await decode(await ImmutableBuffer.fromUint8List(bytes)); - } on NetworkImageLoadException { - chunkEvents.close(); - scheduleMicrotask(() { - PaintingBinding.instance.imageCache.evict(key); - }); - rethrow; - } catch (e) { - // retry this error - await Future.delayed(delay); - delay = Duration(milliseconds: (1.5 * delay.inMilliseconds).toInt()); - lastError = e; - } - } - chunkEvents.close(); - if (lastError != null) { - throw lastError; - } - throw Exception('Failed to fetch ResilientNetworkImage'); - } - - @override - Future obtainKey(ImageConfiguration configuration) { - return SynchronousFuture(this); - } - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is ResilientNetworkImage && - other.uri == uri && - other.scale == scale; - } - - @override - int get hashCode => Object.hash(uri, scale); - - @override - String toString() => - '${objectRuntimeType(this, 'ResilientNetworkImage')}("$uri", scale: $scale)'; -} diff --git a/lib/components/message_input.dart b/lib/components/message_input.dart index 7f730096f..83373e75b 100644 --- a/lib/components/message_input.dart +++ b/lib/components/message_input.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'dart:math'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:provider/provider.dart'; import 'package:rtchat/components/autocomplete.dart'; import 'package:rtchat/components/emote_picker.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/components/provider_icon.dart'; import 'package:rtchat/l10n/app_localizations.dart'; import 'package:rtchat/models/adapters/actions.dart'; @@ -65,7 +65,7 @@ class EmoteTextEditingController extends TextEditingController { preferBelow: false, child: Image( height: styleModel.fontSize, - image: ResilientNetworkImage(token.url), + image: CachedNetworkImageProvider(token.url.toString()), errorBuilder: (context, error, stackTrace) => Text(token.code), ), ), @@ -432,8 +432,8 @@ class _MessageInputWidgetState extends State { child: Image( width: 24, height: 24, - image: ResilientNetworkImage( - Uri.parse(_emotes[_emoteIndex]), + image: CachedNetworkImageProvider( + _emotes[_emoteIndex], ), ), ), diff --git a/lib/models/channels.dart b/lib/models/channels.dart index b9c821075..19a04baff 100644 --- a/lib/models/channels.dart +++ b/lib/models/channels.dart @@ -1,6 +1,6 @@ import 'dart:core'; -import 'package:rtchat/components/image/resilient_network_image.dart'; +import 'package:cached_network_image/cached_network_image.dart'; /// The streaming platforms a [Channel] can belong to. abstract final class Providers { @@ -49,8 +49,8 @@ class Channel { ); } - ResilientNetworkImage get profilePicture { - return ResilientNetworkImage(profilePictureUrl); + CachedNetworkImageProvider get profilePicture { + return CachedNetworkImageProvider(profilePictureUrl.toString()); } Map toJson() { diff --git a/lib/models/messages/twitch/emote.dart b/lib/models/messages/twitch/emote.dart index 2bedeca11..6768dec9d 100644 --- a/lib/models/messages/twitch/emote.dart +++ b/lib/models/messages/twitch/emote.dart @@ -2,12 +2,12 @@ import 'dart:convert'; import 'dart:core'; import 'dart:io'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:rtchat/api.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/models/channels.dart'; /// How long to wait before fetching the emotes again after a failure. @@ -149,7 +149,8 @@ class Emote { Uri get uri => Uri.parse(imageUrl.startsWith("//") ? "https:$imageUrl" : imageUrl); - ResilientNetworkImage get image => ResilientNetworkImage(uri); + CachedNetworkImageProvider get image => + CachedNetworkImageProvider(uri.toString()); static Emote fromJson(dynamic json) { return Emote( diff --git a/lib/screens/settings/twitch/badges.dart b/lib/screens/settings/twitch/badges.dart index d2b13e8cd..2c1533f10 100644 --- a/lib/screens/settings/twitch/badges.dart +++ b/lib/screens/settings/twitch/badges.dart @@ -1,7 +1,7 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:rtchat/components/image/cross_fade_image.dart'; -import 'package:rtchat/components/image/resilient_network_image.dart'; import 'package:rtchat/l10n/app_localizations.dart'; import 'package:rtchat/models/adapters/chat_state.dart'; import 'package:rtchat/models/messages/twitch/badge.dart'; @@ -47,13 +47,11 @@ class TwitchBadgesScreen extends StatelessWidget { itemBuilder: (context, index) { final badge = badges[index]; final lastVersion = badge.versions.last; - final image = ResilientNetworkImage( - Uri.parse(lastVersion.imageUrl4x), - ); + final image = CachedNetworkImageProvider(lastVersion.imageUrl4x); return CheckboxListTile.adaptive( secondary: CrossFadeImage( alignment: Alignment.center, - placeholder: image.placeholderImage, + placeholder: MemoryImage(kTransparentImage), image: image, width: 36, height: 36, diff --git a/pubspec.lock b/pubspec.lock index 535b167b5..466f15e94 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,31 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + path: cached_network_image + ref: "4c24ee00b4fe69144a37e2cd4067eb74eabf1348" + resolved-ref: "4c24ee00b4fe69144a37e2cd4067eb74eabf1348" + url: "https://github.com/kevmo314/flutter_cached_network_image.git" + source: git + version: "4.0.6" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "555e287d3882fbb36cde50d24214cb3f6a272d39c4386e8afe00cb188d35b285" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "38762ec3bd2295f4d79545add118f525bd5a105a53692c06208c4875f2abde62" + url: "https://pub.dev" + source: hosted + version: "2.0.3" characters: dependency: transitive description: @@ -217,6 +242,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: a47c4e7c66fd7444a063a602597fceb238f705c3f6b1322bc0053d0a2638ee6e + url: "https://pub.dev" + source: hosted + version: "1.1.4" dbus: dependency: transitive description: @@ -398,6 +431,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: "direct dev" + description: + name: flutter_cache_manager + sha256: f94fc6f5480977e3eb7b622ed488f671ba96c971bd0e274e6bd0f0ba9b044816 + url: "https://pub.dev" + source: hosted + version: "3.4.5" flutter_custom_tabs: dependency: "direct main" description: @@ -568,7 +609,7 @@ packages: source: hosted version: "0.15.7" http: - dependency: transitive + dependency: "direct dev" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -716,6 +757,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + material_ui: + dependency: transitive + description: + name: material_ui + sha256: e4031746f96c8316864d9b92bce375f1320a36ed56e166307e1e1bda9d90b4ab + url: "https://pub.dev" + source: hosted + version: "1.8.0" meta: dependency: transitive description: @@ -772,6 +821,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.6.2" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "49c4f93afc10f1ad4fdef9db88fed9c65a8eb5d9ad0a4fa9b128d7fedeb41418" + url: "https://pub.dev" + source: hosted + version: "2.1.2" on_device_tts: dependency: "direct main" description: @@ -1008,6 +1065,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "21a9750de2deb464946e9f41c7ddf176e25553618afdfc774868cd454990c06a" + url: "https://pub.dev" + source: hosted + version: "2.4.5" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "3986f4cbdde19ca538aae877981c5a53400dcc4601499df18e7ed9e848a0bdd4" + url: "https://pub.dev" + source: hosted + version: "2.4.5" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: b80e94ff0d551d5f1f8c7d14348d29b576cca3ae30f6e195a7c380552c32b1e0 + url: "https://pub.dev" + source: hosted + version: "2.5.14" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "8de68fc306040f992aaf378750c16f95e4298c4bb150dd45091d75e42b1f08ee" + url: "https://pub.dev" + source: hosted + version: "2.4.4+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "5b611dbcb9bab33e7073594ecac111bf0a86ffc342652c2081160d7038a45fb6" + url: "https://pub.dev" + source: hosted + version: "2.4.3" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8942884db..1843940ce 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,12 @@ dependencies: webview_flutter_wkwebview: ^3.27.2 uuid: ^4.6.0 flutter_markdown_plus: ^1.0.12 + cached_network_image: + git: + url: https://github.com/kevmo314/flutter_cached_network_image.git + # Queued-frame disposal fix: Baseflow/flutter_cached_network_image#1065. + ref: 4c24ee00b4fe69144a37e2cd4067eb74eabf1348 + path: cached_network_image dev_dependencies: audioplayers_platform_interface: ^7.2.0 @@ -54,6 +60,8 @@ dev_dependencies: integration_test: sdk: flutter flutter_lints: ^6.0.0 + flutter_cache_manager: ^3.4.5 + http: ^1.6.0 flutter: config: diff --git a/test/components/image/cached_network_image_test.dart b/test/components/image/cached_network_image_test.dart new file mode 100644 index 000000000..0c170bb94 --- /dev/null +++ b/test/components/image/cached_network_image_test.dart @@ -0,0 +1,245 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:rtchat/components/chat_history/tokens.dart'; +import 'package:rtchat/components/emote_picker.dart'; +import 'package:rtchat/components/image/cross_fade_image.dart'; +import 'package:rtchat/models/channels.dart'; +import 'package:rtchat/models/messages/tokens.dart'; +import 'package:rtchat/models/messages/twitch/emote.dart'; +import 'package:rtchat/models/style.dart'; + +import '../l10n.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory directory; + late _ImageCacheManager manager; + late BaseCacheManager previousManager; + late MockClient client; + late Future Function(http.Request) respond; + late List requests; + + _ImageCacheManager createManager() { + final manager = _ImageCacheManager( + Config( + 'image-test', + repo: JsonCacheInfoRepository.withFile( + File('${directory.path}/cache.json'), + ), + fileSystem: IOFileSystem('image-test'), + fileService: HttpFileService(httpClient: client), + ), + ); + // Run cleanup during pumps rather than leaving its 10s timer pending. + manager.store.cleanupRunMinInterval = Duration.zero; + return manager; + } + + setUp(() async { + directory = await Directory.systemTemp.createTemp('rtchat-images-'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (_) async => directory.path, + ); + requests = []; + respond = (_) async => throw const SocketException('offline'); + client = MockClient((request) { + requests.add(request); + return respond(request); + }); + previousManager = CachedNetworkImageProvider.defaultCacheManager; + manager = createManager(); + CachedNetworkImageProvider.defaultCacheManager = manager; + }); + + tearDown(() async { + PaintingBinding.instance.imageCache.clear(); + PaintingBinding.instance.imageCache.clearLiveImages(); + CachedNetworkImageProvider.defaultCacheManager = previousManager; + client.close(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + null, + ); + await directory.delete(recursive: true); + }); + + Future imageResponse(String fixture) async => + http.Response.bytes( + await File('test/fixtures/images/$fixture').readAsBytes(), + 200, + headers: { + 'content-type': 'image/${fixture.split('.').last}', + 'cache-control': 'max-age=86400', + // Deliberately no ETag: these responses must still be cached. + }, + ); + + Widget chatEmote(Uri url) => MaterialApp( + home: Material( + child: Builder( + builder: (context) { + final style = StyleModel.fromJson({}); + final spans = + renderToken( + context, + style, + EmoteToken(url: url, code: 'AnimatedEmote'), + ).toList(); + style.dispose(); + return Text.rich(TextSpan(children: spans)); + }, + ), + ), + ); + + for (final format in ['gif', 'webp']) { + final url = Uri.parse('https://example.com/animated.$format'); + + testWidgets( + '$format animates in chat after download and an offline restart', + (tester) async { + respond = (_) => imageResponse('animated.$format'); + await tester.pumpWidget(chatEmote(url)); + await _expectAnimation(tester); + expect(requests, hasLength(1)); + expect(tester.getSize(find.byType(Image)).height, 20); + + // Reopen persisted cache metadata and clear Flutter's decoded image cache, + // so this exercises disk bytes rather than an in-memory image or codec. + await tester.pumpWidget(const SizedBox()); + PaintingBinding.instance.imageCache.clear(); + PaintingBinding.instance.imageCache.clearLiveImages(); + await _waitFor(tester, () => manager.webHelper.concurrentCalls == 0); + await _dispose(tester, manager); + await tester.runAsync(() async { + manager = createManager(); + CachedNetworkImageProvider.defaultCacheManager = manager; + }); + respond = (_) async => throw const SocketException('offline'); + await tester.pumpWidget(chatEmote(url)); + await _expectAnimation(tester); + expect(requests, hasLength(1)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await _dispose(tester, manager); + }, + ); + + testWidgets('$format animates in the emote picker at its existing size', ( + tester, + ) async { + respond = (_) => imageResponse('animated.$format'); + await tester.pumpWidget( + MaterialApp( + home: TestLocalizations( + child: Material( + child: EmotesList( + emotes: [ + Emote( + provider: '7tv', + category: null, + id: 'animated', + code: 'AnimatedEmote', + imageUrl: url.toString(), + ), + ], + channel: Channel('twitch', '1', 'streamer'), + onEmoteSelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + await _expectAnimation(tester); + // The 36px button's vertical padding constrains its icon to 20px. + expect(tester.getSize(find.byType(CrossFadeImage)), const Size(36, 20)); + expect(requests, hasLength(1)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await _dispose(tester, manager); + }); + } + + testWidgets('a failed image displays the emote code fallback', ( + tester, + ) async { + respond = (_) async => http.Response('', 404); + await tester.pumpWidget( + chatEmote(Uri.parse('https://example.com/broken.gif')), + ); + await _waitFor( + tester, + () => find.text('AnimatedEmote').evaluate().isNotEmpty, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await _dispose(tester, manager); + }); +} + +// Fixtures are 4x4 two-frame GIF/WebP (red/blue, 100ms, infinite loop). +// Use the same image-cache mixin as DefaultCacheManager, backed by real disk +// files and a local JSON index so tests need no platform SQLite plugin. +class _ImageCacheManager extends CacheManager with ImageCacheManager { + _ImageCacheManager(super.config); +} + +Future _dispose(WidgetTester tester, CacheManager manager) async { + PaintingBinding.instance.imageCache.clear(); + PaintingBinding.instance.imageCache.clearLiveImages(); + // Pump fake microtasks while the disk-backed repository closes. + var disposed = false; + unawaited(manager.dispose().then((_) => disposed = true)); + await _waitFor(tester, () => disposed); +} + +Future _waitFor(WidgetTester tester, bool Function() ready) async { + for (var i = 0; i < 100 && !ready(); i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 5)), + ); + await tester.pump(const Duration(milliseconds: 20)); + } + expect(ready(), isTrue, reason: 'image did not reach the expected state'); +} + +Future _waitForPixel(WidgetTester tester, List pixel) async { + List? actual; + for (var i = 0; i < 100; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 5)), + ); + await tester.pump(const Duration(milliseconds: 20)); + for (final raw in tester.widgetList(find.byType(RawImage))) { + final image = raw.image; + if (image == null || image.width != 4) continue; + final data = await tester.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.rawRgba), + ); + actual = data!.buffer.asUint8List().sublist(0, 4); + if (actual.toString() == pixel.toString()) return; + } + } + expect(actual, pixel); +} + +Future _expectAnimation(WidgetTester tester) async { + // Check rendered frame pixels and a full loop, not just a successful decode. + await _waitForPixel(tester, [255, 0, 0, 255]); + await _waitForPixel(tester, [0, 0, 255, 255]); + await _waitForPixel(tester, [255, 0, 0, 255]); +} diff --git a/test/fixtures/images/animated.gif b/test/fixtures/images/animated.gif new file mode 100644 index 000000000..6c2fde47f Binary files /dev/null and b/test/fixtures/images/animated.gif differ diff --git a/test/fixtures/images/animated.webp b/test/fixtures/images/animated.webp new file mode 100644 index 000000000..03481f881 Binary files /dev/null and b/test/fixtures/images/animated.webp differ