diff --git a/CHANGELOG.md b/CHANGELOG.md index cdba33dc991..9fd3f7517be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,13 +39,16 @@ All user visible changes to this project will be documented in this file. This p - UI: - Chat page: - Replies having reversed order in messages. ([#193], [#192]) + - Images sometimes not loading. ([#164], [#126]) - Web: - Context menu not opening over video previews. ([#198], [#196]) [#63]: /../../issues/63 [#102]: /../../issues/102 +[#126]: /../../issues/126 [#134]: /../../issues/134 [#142]: /../../pull/142 +[#164]: /../../pull/164 [#172]: /../../pull/172 [#173]: /../../pull/173 [#188]: /../../pull/188 diff --git a/lib/config.dart b/lib/config.dart index d4c84ab2d6e..7399411c2f8 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -139,8 +139,8 @@ class Config { // configuration. if (confRemote) { try { - var response = - await Dio().fetch(RequestOptions(path: '$url:$port/conf.toml')); + final response = await PlatformUtils.dio + .fetch(RequestOptions(path: '$url:$port/conf.toml')); if (response.statusCode == 200) { Map remote = TomlDocument.parse(response.data.toString()).toMap(); diff --git a/lib/domain/model/precise_date_time/src/non_web.dart b/lib/domain/model/precise_date_time/src/non_web.dart index 4188bb45d0d..8d69a811424 100644 --- a/lib/domain/model/precise_date_time/src/non_web.dart +++ b/lib/domain/model/precise_date_time/src/non_web.dart @@ -19,10 +19,7 @@ import 'package:hive/hive.dart'; import '/domain/model_type_id.dart'; import '/util/new_type.dart'; -part 'non_web.g.dart'; - /// [DateTime] considering the microseconds on any platform, including Web. -@HiveType(typeId: ModelTypeId.preciseDateTime) class PreciseDateTime extends NewType implements Comparable { PreciseDateTime(DateTime val, {int microsecond = 0}) : super(val); @@ -188,3 +185,19 @@ class PreciseDateTime extends NewType static PreciseDateTime parse(String formattedString) => PreciseDateTime(DateTime.parse(formattedString)); } + +/// [Hive] adapter for a [PreciseDateTime]. +class PreciseDateTimeAdapter extends TypeAdapter { + @override + final typeId = ModelTypeId.preciseDateTime; + + @override + PreciseDateTime read(BinaryReader reader) => PreciseDateTime( + DateTime.fromMicrosecondsSinceEpoch(reader.readInt()), + ); + + @override + void write(BinaryWriter writer, PreciseDateTime obj) { + writer.writeInt(obj.microsecondsSinceEpoch); + } +} diff --git a/lib/domain/model/precise_date_time/src/web.dart b/lib/domain/model/precise_date_time/src/web.dart index 18997230017..cfe9fd5bbbf 100644 --- a/lib/domain/model/precise_date_time/src/web.dart +++ b/lib/domain/model/precise_date_time/src/web.dart @@ -19,15 +19,11 @@ import 'package:hive/hive.dart'; import '/domain/model_type_id.dart'; import '/util/new_type.dart'; -part 'web.g.dart'; - /// [DateTime] considering the microseconds on any platform, including Web. -@HiveType(typeId: ModelTypeId.preciseDateTimeWeb) class PreciseDateTime extends NewType implements Comparable { PreciseDateTime(DateTime val, {this.microsecond = 0}) : super(val); - @HiveField(1) final int microsecond; /// Returns the number of microseconds since the "Unix epoch" @@ -209,3 +205,19 @@ class PreciseDateTime extends NewType return formattedString; } } + +/// [Hive] adapter for a [PreciseDateTime]. +class PreciseDateTimeAdapter extends TypeAdapter { + @override + final typeId = ModelTypeId.preciseDateTime; + + @override + PreciseDateTime read(BinaryReader reader) => + PreciseDateTime(reader.read() as DateTime, microsecond: reader.readInt()); + + @override + void write(BinaryWriter writer, PreciseDateTime obj) { + writer.write(obj.val); + writer.writeInt(obj.microsecond); + } +} diff --git a/lib/domain/model_type_id.dart b/lib/domain/model_type_id.dart index 6ad5079eec9..d7871d0d03f 100644 --- a/lib/domain/model_type_id.dart +++ b/lib/domain/model_type_id.dart @@ -93,13 +93,12 @@ class ModelTypeId { static const mediaSettings = 71; static const chatCallDeviceId = 72; static const preciseDateTime = 73; - static const preciseDateTimeWeb = 74; - static const applicationSettings = 75; - static const sendingStatus = 76; - static const nativeFile = 77; - static const localAttachment = 78; - static const mediaType = 79; - static const hiveBackground = 80; - static const storageFile = 81; - static const chatCallCredentials = 82; + static const applicationSettings = 74; + static const sendingStatus = 75; + static const nativeFile = 76; + static const localAttachment = 77; + static const mediaType = 78; + static const hiveBackground = 79; + static const storageFile = 80; + static const chatCallCredentials = 81; } diff --git a/lib/provider/gql/base.dart b/lib/provider/gql/base.dart index 707c0ac4a74..f3f90bfd032 100644 --- a/lib/provider/gql/base.dart +++ b/lib/provider/gql/base.dart @@ -17,7 +17,7 @@ import 'dart:async'; import 'package:async/async.dart' show StreamGroup; -import 'package:dio/dio.dart' as dio show Dio, DioError, Options, Response; +import 'package:dio/dio.dart' as dio show DioError, Options, Response; import 'package:graphql_flutter/graphql_flutter.dart'; import 'package:mutex/mutex.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -25,6 +25,7 @@ import 'package:web_socket_channel/web_socket_channel.dart'; import '/config.dart'; import '/domain/model/session.dart'; import '/util/log.dart'; +import '/util/platform_utils.dart'; import 'exceptions.dart'; /// Base GraphQl provider. @@ -205,13 +206,12 @@ class GraphQlClient { void Function(int, int)? onSendProgress, }) => _middleware(() async { - var client = dio.Dio(); - var authorized = options ?? dio.Options(); + final dio.Options authorized = options ?? dio.Options(); authorized.headers = (authorized.headers ?? {}); authorized.headers!['Authorization'] = 'Bearer $token'; try { - return await client.post( + return await PlatformUtils.dio.post( '${Config.url}:${Config.port}${Config.graphql}', data: data, options: authorized, diff --git a/lib/ui/page/call/widget/call_cover.dart b/lib/ui/page/call/widget/call_cover.dart index f7bb8fd4880..9359bcb8da4 100644 --- a/lib/ui/page/call/widget/call_cover.dart +++ b/lib/ui/page/call/widget/call_cover.dart @@ -19,6 +19,7 @@ import 'package:flutter/material.dart'; import '/domain/model/user.dart'; import '/domain/model/user_call_cover.dart'; import '/ui/page/home/widget/avatar.dart'; +import '/ui/page/home/widget/retry_image.dart'; import '/ui/widget/svg/svg.dart'; /// Widget to build an [UserCallCover]. @@ -29,7 +30,7 @@ import '/ui/widget/svg/svg.dart'; class CallCoverWidget extends StatelessWidget { const CallCoverWidget(this.cover, {Key? key, this.user}) : super(key: key); - /// Call cover data object. + /// [UserCallCover] to display. final UserCallCover? cover; /// [User] this [UserCallCover] belongs to. @@ -48,7 +49,7 @@ class CallCoverWidget extends StatelessWidget { ) : Stack( children: [ - Image.network( + RetryImage( cover!.full.url, width: double.infinity, height: double.infinity, diff --git a/lib/ui/page/home/page/chat/forward/view.dart b/lib/ui/page/home/page/chat/forward/view.dart index 6a8b22f53f2..306d9529047 100644 --- a/lib/ui/page/home/page/chat/forward/view.dart +++ b/lib/ui/page/home/page/chat/forward/view.dart @@ -35,6 +35,7 @@ import '/ui/page/home/page/chat/widget/chat_item.dart'; import '/ui/page/home/page/chat/forward/controller.dart'; import '/ui/page/home/page/chat/widget/animated_fab.dart'; import '/ui/page/home/widget/avatar.dart'; +import '/ui/page/home/widget/retry_image.dart'; import '/ui/widget/animations.dart'; import '/ui/widget/modal_popup.dart'; import '/ui/widget/svg/svg.dart'; @@ -225,14 +226,16 @@ class ChatForwardView extends StatelessWidget { decoration: BoxDecoration( color: const Color(0XFFF0F2F6), borderRadius: BorderRadius.circular(4), - image: image == null - ? null - : DecorationImage(image: NetworkImage(image.original.url)), ), width: 50, height: 50, - child: - image == null ? const Icon(Icons.attach_file, size: 16) : null, + child: image == null + ? const Icon(Icons.attach_file, size: 16) + : RetryImage( + image.medium.url, + fit: BoxFit.cover, + borderRadius: BorderRadius.circular(4), + ), ); }).toList(); } @@ -439,17 +442,11 @@ class ChatForwardView extends StatelessWidget { width: 80, height: 80, ) - : Image.network( + : RetryImage( e.original.url, fit: BoxFit.cover, width: 80, height: 80, - errorBuilder: (_, __, ___) => const SizedBox( - width: 80, - height: 80, - child: - Center(child: Icon(Icons.error, color: Colors.red)), - ), ), ) else diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index f00d0832b04..1b75fe044ea 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -48,6 +48,7 @@ import '/ui/page/home/widget/app_bar.dart'; import '/ui/page/home/widget/avatar.dart'; import '/ui/page/home/widget/gallery_popup.dart'; import '/ui/page/home/widget/init_callback.dart'; +import '/ui/page/home/widget/retry_image.dart'; import '/ui/widget/animations.dart'; import '/ui/widget/menu_interceptor/menu_interceptor.dart'; import '/ui/widget/svg/svg.dart'; @@ -1383,7 +1384,7 @@ class _ChatViewState extends State } } } else { - child = Image.network( + child = RetryImage( e.original.url, fit: BoxFit.cover, width: size, @@ -1648,9 +1649,6 @@ class _ChatViewState extends State ? Colors.white.withOpacity(0.2) : Colors.black.withOpacity(0.03), borderRadius: BorderRadius.circular(4), - image: image == null - ? null - : DecorationImage(image: NetworkImage(image.small.url)), ), width: 30, height: 30, @@ -1660,7 +1658,11 @@ class _ChatViewState extends State color: fromMe ? Colors.white : const Color(0xFFDDDDDD), size: 16, ) - : null, + : RetryImage( + image.small.url, + fit: BoxFit.cover, + borderRadius: BorderRadius.circular(4), + ), ); }).toList(); } @@ -1884,9 +1886,6 @@ class _ChatViewState extends State ? Colors.white.withOpacity(0.2) : Colors.black.withOpacity(0.03), borderRadius: BorderRadius.circular(4), - image: image == null - ? null - : DecorationImage(image: NetworkImage(image.small.url)), ), width: 30, height: 30, @@ -1896,7 +1895,11 @@ class _ChatViewState extends State color: fromMe ? Colors.white : const Color(0xFFDDDDDD), size: 16, ) - : null, + : RetryImage( + image.small.url, + fit: BoxFit.cover, + borderRadius: BorderRadius.circular(4), + ), ); }).toList(); } diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index c5e319b38ad..5dbf19e6370 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -46,7 +46,7 @@ import '/ui/page/home/page/chat/forward/view.dart'; import '/ui/page/home/widget/avatar.dart'; import '/ui/page/home/widget/confirm_dialog.dart'; import '/ui/page/home/widget/gallery_popup.dart'; -import '/ui/page/home/widget/init_callback.dart'; +import '/ui/page/home/widget/retry_image.dart'; import '/ui/widget/animated_delayed_switcher.dart'; import '/ui/widget/animations.dart'; import '/ui/widget/context_menu/menu.dart'; @@ -200,20 +200,12 @@ class ChatItemWidget extends StatefulWidget { } else { attachment = KeyedSubtree( key: const Key('SentImage'), - child: Image.network( + child: RetryImage( (e as ImageAttachment).big.url, key: key, fit: BoxFit.cover, height: 300, - errorBuilder: (_, __, ___) { - return InitCallback( - callback: onError, - child: const SizedBox.square( - dimension: 300, - child: Center(child: CircularProgressIndicator()), - ), - ); - }, + onForbidden: onError, ), ); } @@ -1042,13 +1034,6 @@ class _ChatItemWidgetState extends State { ? Colors.white.withOpacity(0.25) : Colors.black.withOpacity(0.03), borderRadius: BorderRadius.circular(10), - image: image == null - ? null - : DecorationImage( - image: NetworkImage(image.medium.url), - onError: (_, __) => widget.onAttachmentError?.call(), - fit: BoxFit.cover, - ), ), width: 50, height: 50, @@ -1058,7 +1043,14 @@ class _ChatItemWidgetState extends State { color: fromMe ? Colors.white : const Color(0xFFDDDDDD), size: 28, ) - : null, + : RetryImage( + image.medium.url, + onForbidden: widget.onAttachmentError, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + borderRadius: BorderRadius.circular(10.0), + ), ); }) .take(3) diff --git a/lib/ui/page/home/tab/chats/widget/recent_chat.dart b/lib/ui/page/home/tab/chats/widget/recent_chat.dart index bbe26f90bed..206473e08c8 100644 --- a/lib/ui/page/home/tab/chats/widget/recent_chat.dart +++ b/lib/ui/page/home/tab/chats/widget/recent_chat.dart @@ -19,7 +19,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '/api/backend/schema.dart' show ChatMemberInfoAction; -import '/config.dart'; import '/domain/model/attachment.dart'; import '/domain/model/chat.dart'; import '/domain/model/chat_call.dart'; @@ -37,7 +36,7 @@ import '/ui/page/home/tab/chats/widget/periodic_builder.dart'; import '/ui/page/home/widget/animated_typing.dart'; import '/ui/page/home/widget/avatar.dart'; import '/ui/page/home/widget/chat_tile.dart'; -import '/ui/page/home/widget/init_callback.dart'; +import '/ui/page/home/widget/retry_image.dart'; import '/ui/widget/context_menu/menu.dart'; import '/ui/widget/svg/svg.dart'; import '/ui/widget/widget_button.dart'; @@ -553,21 +552,12 @@ class RecentChatTile extends StatelessWidget { } if (e is ImageAttachment) { - content = Image.network( - '${Config.files}${e.medium.relativeRef}', + content = RetryImage( + e.medium.url, fit: BoxFit.cover, - errorBuilder: (_, __, ___) { - return InitCallback( - callback: onError, - child: const Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 3), - ), - ), - ); - }, + width: double.infinity, + height: double.infinity, + onForbidden: onError, ); } diff --git a/lib/ui/page/home/widget/avatar.dart b/lib/ui/page/home/widget/avatar.dart index 21a78cd45e9..85264ec9ed3 100644 --- a/lib/ui/page/home/widget/avatar.dart +++ b/lib/ui/page/home/widget/avatar.dart @@ -31,6 +31,7 @@ import '/domain/repository/chat.dart'; import '/domain/repository/contact.dart'; import '/domain/repository/user.dart'; import '/ui/page/home/page/chat/controller.dart'; +import '/ui/page/home/widget/retry_image.dart'; /// Widget to build an [Avatar]. /// @@ -393,13 +394,6 @@ class AvatarWidget extends StatelessWidget { end: Alignment.bottomCenter, colors: [gradient.lighten(), gradient], ), - image: avatar == null - ? null - : DecorationImage( - image: NetworkImage(avatar!.original.url), - fit: BoxFit.cover, - isAntiAlias: true, - ), shape: BoxShape.circle, ), child: avatar == null @@ -416,7 +410,14 @@ class AvatarWidget extends StatelessWidget { textScaleFactor: 1, ), ) - : null, + : ClipOval( + child: RetryImage( + avatar!.original.url, + fit: BoxFit.cover, + height: double.infinity, + width: double.infinity, + ), + ), ), ); }); diff --git a/lib/ui/page/home/widget/gallery.dart b/lib/ui/page/home/widget/gallery.dart index f9e427bf6de..dde6ae731f5 100644 --- a/lib/ui/page/home/widget/gallery.dart +++ b/lib/ui/page/home/widget/gallery.dart @@ -21,6 +21,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '/domain/model/image_gallery_item.dart'; +import '/ui/page/home/widget/retry_image.dart'; import '/ui/widget/svg/svg.dart'; import 'gallery_popup.dart'; @@ -108,20 +109,16 @@ class _CarouselGalleryState extends State { alignment: Alignment.center, children: [ widget.items?.isNotEmpty == true - ? ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - child: Container( - key: ValueKey(widget.index), - decoration: BoxDecoration( - image: DecorationImage( - image: NetworkImage( - widget.items![widget.index].original.url, - ), - fit: BoxFit.cover, - ), - ), + ? AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + child: Container( + key: ValueKey(widget.index), + child: RetryImage( + widget.items![widget.index].original.url, + fit: BoxFit.cover, + filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), + width: double.infinity, + height: double.infinity, ), ), ) @@ -147,8 +144,9 @@ class _CarouselGalleryState extends State { ] : widget.items! .map( - (e) => Image.network( + (e) => RetryImage( e.original.url, + height: double.infinity, fit: BoxFit.fitHeight, ), ) diff --git a/lib/ui/page/home/widget/gallery_popup.dart b/lib/ui/page/home/widget/gallery_popup.dart index 22a6bcdf68c..2fa5a9be446 100644 --- a/lib/ui/page/home/widget/gallery_popup.dart +++ b/lib/ui/page/home/widget/gallery_popup.dart @@ -32,6 +32,7 @@ import '/ui/page/call/widget/round_button.dart'; import '/ui/page/home/page/chat/widget/video.dart'; import '/ui/page/home/page/chat/widget/web_image/web_image.dart'; import '/ui/page/home/widget/init_callback.dart'; +import '/ui/page/home/widget/retry_image.dart'; import '/ui/widget/context_menu/menu.dart'; import '/ui/widget/context_menu/region.dart'; import '/ui/widget/widget_button.dart'; @@ -471,7 +472,15 @@ class _GalleryPopupState extends State } }, ) - : Image.network(e.link), + : RetryImage( + e.link, + onForbidden: () async { + await e.onError?.call(); + if (mounted) { + setState(() {}); + } + }, + ), ), minScale: PhotoViewComputedScale.contained, maxScale: PhotoViewComputedScale.contained * 3, @@ -565,23 +574,13 @@ class _GalleryPopupState extends State }, child: PlatformUtils.isWeb ? IgnorePointer(child: WebImage(e.link)) - : Image.network( + : RetryImage( e.link, - errorBuilder: (_, __, ___) { - return InitCallback( - callback: () async { - await e.onError?.call(); - if (mounted) { - setState(() {}); - } - }, - child: const SizedBox( - height: 300, - child: Center( - child: CircularProgressIndicator(), - ), - ), - ); + onForbidden: () async { + await e.onError?.call(); + if (mounted) { + setState(() {}); + } }, ), ), diff --git a/lib/ui/page/home/widget/retry_image.dart b/lib/ui/page/home/widget/retry_image.dart new file mode 100644 index 00000000000..7cf3240b892 --- /dev/null +++ b/lib/ui/page/home/widget/retry_image.dart @@ -0,0 +1,263 @@ +// Copyright © 2022 IT ENGINEERING MANAGEMENT INC, +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + +import 'dart:async'; +import 'dart:ui'; +import 'dart:collection'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '/util/platform_utils.dart'; + +/// [Image.memory] displaying an image fetched from the provided [url]. +/// +/// Uses exponential backoff algorithm to re-fetch the [url] in case an error +/// occurs. +/// +/// Invokes the provided [onForbidden] callback on the `403 Forbidden` HTTP +/// errors. +class RetryImage extends StatefulWidget { + const RetryImage( + this.url, { + Key? key, + this.fit, + this.height, + this.width, + this.borderRadius, + this.onForbidden, + this.filter, + }) : super(key: key); + + /// URL of an image to display. + final String url; + + /// Callback, called when loading an image from the provided [url] fails with + /// a forbidden network error. + final Future Function()? onForbidden; + + /// [BoxFit] to apply to this [RetryImage]. + final BoxFit? fit; + + /// Height of this [RetryImage]. + final double? height; + + /// Width of this [RetryImage]. + final double? width; + + /// [ImageFilter] to apply to this [RetryImage]. + final ImageFilter? filter; + + /// [BorderRadius] to apply to this [RetryImage]. + final BorderRadius? borderRadius; + + @override + State createState() => _RetryImageState(); +} + +/// [State] of [RetryImage] maintaining image data loading with the exponential +/// backoff algorithm. +class _RetryImageState extends State { + /// Naive [_FIFOCache] caching the images. + static final _FIFOCache _cache = _FIFOCache(); + + /// [Timer] retrying the image fetching. + Timer? _timer; + + /// Byte data of the fetched image. + Uint8List? _image; + + /// Image fetching progress. + double _progress = 0; + + /// Starting period of exponential backoff image fetching. + static const Duration _minBackoffPeriod = Duration(microseconds: 250); + + /// Maximum possible period of exponential backoff image fetching. + static const Duration _maxBackoffPeriod = Duration(seconds: 32); + + /// Current period of exponential backoff image fetching. + Duration _backoffPeriod = _minBackoffPeriod; + + @override + void initState() { + _loadImage(); + super.initState(); + } + + @override + void didUpdateWidget(covariant RetryImage oldWidget) { + if (oldWidget.url != widget.url) { + _loadImage(); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Widget child; + + if (_image != null) { + Widget image = Image.memory( + _image!, + key: const Key('Loaded'), + height: widget.height, + width: widget.width, + fit: widget.fit, + ); + + if (widget.filter != null) { + image = ImageFiltered(imageFilter: widget.filter!, child: image); + } + + if (widget.borderRadius != null) { + image = ClipRRect(borderRadius: widget.borderRadius!, child: image); + } + + child = image; + } else { + child = Container( + key: const Key('Loading'), + height: widget.height, + width: 200, + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.all(5), + constraints: const BoxConstraints( + maxHeight: 40, + maxWidth: 40, + minWidth: 10, + minHeight: 10, + ), + child: AspectRatio( + aspectRatio: 1, + child: CircularProgressIndicator( + value: _progress == 0 ? null : _progress, + ), + ), + ), + ); + } + + return AnimatedSwitcher( + key: Key('Image_${widget.url}'), + duration: const Duration(milliseconds: 150), + child: child, + ); + } + + /// Loads the [_image] from the provided URL. + /// + /// Retries itself using exponential backoff algorithm on a failure. + Future _loadImage() async { + _timer?.cancel(); + + Uint8List? cached = _cache[widget.url]; + if (cached != null) { + _image = cached; + _backoffPeriod = _minBackoffPeriod; + if (mounted) { + setState(() {}); + } + } else { + Response? data; + + try { + data = await PlatformUtils.dio.get( + widget.url, + onReceiveProgress: (received, total) { + if (total != -1) { + _progress = received / total; + if (mounted) { + setState(() {}); + } + } + }, + options: Options(responseType: ResponseType.bytes), + ); + } on DioError catch (e) { + if (e.response?.statusCode == 403) { + await widget.onForbidden?.call(); + } + } + + if (data?.data != null && data!.statusCode == 200) { + _cache[widget.url] = data.data; + _image = data.data; + _backoffPeriod = _minBackoffPeriod; + if (mounted) { + setState(() {}); + } + } else { + _timer = Timer( + _backoffPeriod, + () { + if (_backoffPeriod < _maxBackoffPeriod) { + _backoffPeriod *= 2; + } + + _loadImage(); + }, + ); + } + } + } +} + +/// Naive [LinkedHashMap]-based cache of [Uint8List]s. +/// +/// FIFO policy is used, meaning if [_cache] exceeds its [_maxSize] or +/// [_maxLength], then the first inserted element is removed. +class _FIFOCache { + /// Maximum allowed length of [_cache]. + static const int _maxLength = 1000; + + /// Maximum allowed size in bytes of [_cache]. + static const int _maxSize = 100 << 20; // 100 MiB + + /// [LinkedHashMap] maintaining [Uint8List]s itself. + final LinkedHashMap _cache = + LinkedHashMap(); + + /// Returns the total size [_cache] occupies. + int get size => + _cache.values.map((e) => e.lengthInBytes).fold(0, (p, e) => p + e); + + /// Puts the provided [bytes] to the cache. + void operator []=(String key, Uint8List bytes) { + if (!_cache.containsKey(key)) { + while (size >= _maxSize) { + _cache.remove(_cache.keys.first); + } + + if (_cache.length >= _maxLength) { + _cache.remove(_cache.keys.first); + } + + _cache[key] = bytes; + } + } + + /// Returns the [Uint8List] of the provided [key], if any is cached. + Uint8List? operator [](String key) => _cache[key]; +} diff --git a/lib/util/platform_utils.dart b/lib/util/platform_utils.dart index a74283670af..6e748c04402 100644 --- a/lib/util/platform_utils.dart +++ b/lib/util/platform_utils.dart @@ -43,6 +43,11 @@ class PlatformUtilsImpl { /// Path to the downloads directory. String? _downloadDirectory; + /// [Dio] client to use in queries. + /// + /// May be overridden to be mocked in tests. + Dio dio = Dio(); + /// Indicates whether application is running in a web browser. bool get isWeb => GetPlatform.isWeb; @@ -199,7 +204,7 @@ class PlatformUtilsImpl { }) async { if ((size != null || url != null) && !PlatformUtils.isWeb) { size = size ?? - int.parse(((await Dio().head(url!)).headers['content-length'] + int.parse(((await dio.head(url!)).headers['content-length'] as List)[0]); String downloads = await PlatformUtils.downloadsDirectory; @@ -286,7 +291,7 @@ class PlatformUtilsImpl { // Retry the downloading unless any other that `404` error is // thrown. await withBackoff( - () => Dio().download( + () => dio.download( url, file!.path, onReceiveProgress: onReceiveProgress, @@ -312,7 +317,7 @@ class PlatformUtilsImpl { if (isMobile && !isWeb) { final Directory temp = await getTemporaryDirectory(); final String path = '${temp.path}/$name'; - await Dio().download(url, path); + await dio.download(url, path); await ImageGallerySaver.saveFile(path, name: name); File(path).delete(); } @@ -322,7 +327,7 @@ class PlatformUtilsImpl { Future share(String url, String name) async { final Directory temp = await getTemporaryDirectory(); final String path = '${temp.path}/$name'; - await Dio().download(url, path); + await dio.download(url, path); await Share.shareFiles([path]); File(path).delete(); } diff --git a/lib/util/web/web.dart b/lib/util/web/web.dart index ded0240fc11..a5a46ae7c0e 100644 --- a/lib/util/web/web.dart +++ b/lib/util/web/web.dart @@ -486,7 +486,7 @@ class WebUtils { /// Downloads a file from the provided [url]. static Future downloadFile(String url, String name) async { - Response response = await Dio().head(url); + final Response response = await PlatformUtils.dio.head(url); if (response.statusCode != 200) { throw Exception('Cannot download file'); } diff --git a/pubspec.lock b/pubspec.lock index 732639626ac..7022a485735 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -962,13 +962,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" - network_image_mock: - dependency: "direct dev" - description: - name: network_image_mock - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" nm: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 51fac11db3c..b7ad4718a24 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -109,7 +109,6 @@ dev_dependencies: hive_generator: ^1.1.3 json_serializable: ^6.3.1 mockito: 5.2.0 - network_image_mock: ^2.1.1 sentry_dart_plugin: ^1.0.0-beta.2 yaml: ^3.1.0 diff --git a/test/e2e/configuration.dart b/test/e2e/configuration.dart index df541a6b974..453aa669c7c 100644 --- a/test/e2e/configuration.dart +++ b/test/e2e/configuration.dart @@ -32,6 +32,7 @@ import 'mock/graphql.dart'; import 'mock/platform_utils.dart'; import 'parameters/attachment.dart'; import 'parameters/download_status.dart'; +import 'parameters/fetch_status.dart'; import 'parameters/keys.dart'; import 'parameters/muted_status.dart'; import 'parameters/online_status.dart'; @@ -64,6 +65,7 @@ import 'steps/text_field.dart'; import 'steps/updates_bio.dart'; import 'steps/users.dart'; import 'steps/wait_until_attachment.dart'; +import 'steps/wait_until_attachment_fetched.dart'; import 'steps/wait_until_attachment_status.dart'; import 'steps/wait_until_file_status.dart'; import 'steps/wait_until_message_status.dart'; @@ -113,6 +115,7 @@ final FlutterTestConfiguration gherkinTestConfiguration = tapWidget, twoUsers, untilAttachmentExists, + untilAttachmentFetched, untilTextExists, untilTextExistsWithin, updateBio, @@ -141,6 +144,7 @@ final FlutterTestConfiguration gherkinTestConfiguration = ..customStepParameterDefinitions = [ AttachmentTypeParameter(), DownloadStatusParameter(), + ImageFetchStatusParameter(), MutedStatusParameter(), OnlineStatusParameter(), SendingStatusParameter(), diff --git a/test/e2e/features/chat/attachments_fetching/.feature b/test/e2e/features/chat/attachments_fetching/.feature new file mode 100644 index 00000000000..b87e66dc884 --- /dev/null +++ b/test/e2e/features/chat/attachments_fetching/.feature @@ -0,0 +1,12 @@ +Feature: Attachments refetching + + Scenario: User sees image refetched in chat + Given I am Alice + And user Bob + And Bob has dialog with me + And Bob sends "test.jpg" attachment to me + And I have Internet with delay of 4 seconds + + When I am in chat with Bob + Then I wait until "test.jpg" attachment is fetching + And I wait until "test.jpg" attachment is fetched diff --git a/test/e2e/hook/reset_app.dart b/test/e2e/hook/reset_app.dart index 218fda3039b..f77ef6c5b0a 100644 --- a/test/e2e/hook/reset_app.dart +++ b/test/e2e/hook/reset_app.dart @@ -21,6 +21,9 @@ import 'package:get/get.dart'; import 'package:gherkin/gherkin.dart'; import 'package:hive/hive.dart'; import 'package:messenger/main.dart'; +import 'package:messenger/util/platform_utils.dart'; + +import '../steps/internet.dart'; /// [Hook] resetting the [Hive] and [Get] states after a test. class ResetAppHook extends Hook { @@ -38,6 +41,8 @@ class ResetAppHook extends Hook { await Get.deleteAll(force: true); Get.reset(); + PlatformUtils.dio.interceptors.removeWhere((e) => e is DelayedInterceptor); + await Future.delayed(Duration.zero); await Hive.close(); await Hive.clean('hive'); diff --git a/test/e2e/parameters/fetch_status.dart b/test/e2e/parameters/fetch_status.dart new file mode 100644 index 00000000000..68e9edb905e --- /dev/null +++ b/test/e2e/parameters/fetch_status.dart @@ -0,0 +1,33 @@ +// Copyright © 2022 IT ENGINEERING MANAGEMENT INC, +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + +import 'package:gherkin/gherkin.dart'; + +/// Image fetching statuses available in an [ImageFetchStatusParameter]. +enum ImageFetchStatus { fetching, fetched } + +/// [CustomParameter] representing an [ImageFetchStatus]. +class ImageFetchStatusParameter extends CustomParameter { + ImageFetchStatusParameter() + : super( + 'fetch_status', + RegExp( + '(${ImageFetchStatus.values.map((e) => e.name).join('|')})', + caseSensitive: false, + ), + (c) => ImageFetchStatus.values.firstWhere((e) => e.name == c), + ); +} diff --git a/test/e2e/steps/in_chat_with.dart b/test/e2e/steps/in_chat_with.dart index 2de966c5325..918ef93f57e 100644 --- a/test/e2e/steps/in_chat_with.dart +++ b/test/e2e/steps/in_chat_with.dart @@ -32,7 +32,6 @@ final StepDefinitionGeneric iAmInChatWith = given1( await context.world.appDriver.waitUntil( () async { - await context.world.appDriver.waitForAppToSettle(); return context.world.appDriver.isPresent( context.world.appDriver.findBy('ChatView', FindType.key), ); diff --git a/test/e2e/steps/internet.dart b/test/e2e/steps/internet.dart index f57cd212d8e..74acb3e3b8b 100644 --- a/test/e2e/steps/internet.dart +++ b/test/e2e/steps/internet.dart @@ -14,9 +14,13 @@ // along with this program. If not, see // . +import 'dart:async'; + +import 'package:dio/dio.dart'; import 'package:get/get.dart'; import 'package:gherkin/gherkin.dart'; import 'package:messenger/provider/gql/graphql.dart'; +import 'package:messenger/util/platform_utils.dart'; import '../mock/graphql.dart'; import '../world/custom_world.dart'; @@ -34,6 +38,10 @@ final StepDefinitionGeneric haveInternetWithDelay = given1( provider.client.delay = delay.seconds; provider.client.throwException = false; } + PlatformUtils.dio.interceptors.removeWhere((e) => e is DelayedInterceptor); + PlatformUtils.dio.interceptors.add( + DelayedInterceptor(Duration(seconds: delay)), + ); }), ); @@ -49,6 +57,7 @@ final StepDefinitionGeneric haveInternetWithoutDelay = given( provider.client.delay = null; provider.client.throwException = false; } + PlatformUtils.dio.interceptors.removeWhere((e) => e is DelayedInterceptor); }), ); @@ -66,3 +75,20 @@ final StepDefinitionGeneric noInternetConnection = given( } }), ); + +/// [Interceptor] for [Dio] requests adding the provided [delay]. +class DelayedInterceptor extends Interceptor { + DelayedInterceptor(this.delay); + + /// [Duration] to delay the requests for. + final Duration delay; + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + await Future.delayed(delay); + handler.next(options); + } +} diff --git a/test/e2e/steps/sends_attachment.dart b/test/e2e/steps/sends_attachment.dart index 88cdd18977b..c45914ac0af 100644 --- a/test/e2e/steps/sends_attachment.dart +++ b/test/e2e/steps/sends_attachment.dart @@ -14,6 +14,7 @@ // along with this program. If not, see // . +import 'dart:convert'; import 'dart:typed_data'; import 'package:dio/dio.dart' as dio; @@ -31,6 +32,7 @@ import '../world/custom_world.dart'; /// /// Examples: /// - Then Bob sends "test.txt" attachment to me +/// - Then Bob sends "test.jpg" attachment to me final StepDefinitionGeneric sendsAttachmentToMe = and2( '{user} sends {string} attachment to me', @@ -38,10 +40,16 @@ final StepDefinitionGeneric sendsAttachmentToMe = final provider = GraphQlProvider(); provider.token = context.world.sessions[user.name]?.session.token; - String? type = MimeResolver.lookup(filename); - var response = await provider.uploadAttachment( + final String? type = MimeResolver.lookup(filename); + final MediaType? mime = type != null ? MediaType.parse(type) : null; + + final response = await provider.uploadAttachment( dio.MultipartFile.fromBytes( - Uint8List.fromList([1, 1]), + mime?.type == 'image' + ? base64Decode( + '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AVN//2Q==', + ) + : Uint8List.fromList([1, 1]), filename: filename, contentType: type != null ? MediaType.parse(type) : null, ), diff --git a/test/e2e/steps/wait_until_attachment_fetched.dart b/test/e2e/steps/wait_until_attachment_fetched.dart new file mode 100644 index 00000000000..388b5ff23dc --- /dev/null +++ b/test/e2e/steps/wait_until_attachment_fetched.dart @@ -0,0 +1,75 @@ +// Copyright © 2022 IT ENGINEERING MANAGEMENT INC, +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + +import 'package:collection/collection.dart'; +import 'package:flutter_gherkin/flutter_gherkin.dart'; +import 'package:get/get.dart'; +import 'package:gherkin/gherkin.dart' hide Attachment; +import 'package:messenger/domain/model/attachment.dart'; +import 'package:messenger/domain/model/chat.dart'; +import 'package:messenger/domain/model/chat_item.dart'; +import 'package:messenger/domain/repository/chat.dart'; +import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/routes.dart'; + +import '../configuration.dart'; +import '../parameters/fetch_status.dart'; + +/// Waits until the specified image attachment is fetched or is being fetched. +/// +/// Examples: +/// - Then I wait until "test.jpg" attachment is fetching +/// - Then I wait until "test.jpg" attachment is fetched +final StepDefinitionGeneric untilAttachmentFetched = + then2( + 'I wait until {string} attachment is {fetch_status}', + (filename, status, context) async { + final RxChat? chat = + Get.find().chats[ChatId(router.route.split('/').last)]; + + await context.world.appDriver.waitUntil( + () async { + final Attachment? attachment; + + attachment = chat!.messages + .map((e) => e.value) + .whereType() + .expand((e) => e.attachments) + .firstWhereOrNull((a) => a.filename == filename); + + if (attachment == null) { + return false; + } + + return context.world.appDriver.isPresent( + context.world.appDriver.findByDescendant( + context.world.appDriver.findByKeySkipOffstage( + 'Image_${(attachment as ImageAttachment).big.url}', + ), + context.world.appDriver.findByKeySkipOffstage( + status == ImageFetchStatus.fetching ? 'Loading' : 'Loaded', + ), + firstMatchOnly: true, + ), + ); + }, + pollInterval: const Duration(milliseconds: 1), + timeout: const Duration(seconds: 60), + ); + }, + configuration: StepDefinitionConfiguration() + ..timeout = const Duration(seconds: 60), +); diff --git a/test/widget/my_profile_gallery_test.dart b/test/widget/my_profile_gallery_test.dart index ad4efdef333..44f4b409c54 100644 --- a/test/widget/my_profile_gallery_test.dart +++ b/test/widget/my_profile_gallery_test.dart @@ -45,7 +45,6 @@ import 'package:messenger/store/user.dart'; import 'package:messenger/ui/page/home/page/my_profile/controller.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:network_image_mock/network_image_mock.dart'; import 'my_profile_gallery_test.mocks.dart'; @@ -375,7 +374,6 @@ void main() async { await tester .pumpWidget(createWidgetForTesting(child: const MyProfileView())); - await tester.pumpAndSettle(const Duration(seconds: 2)); expect(find.byKey(const Key('AddGallery')), findsOneWidget); Get.find().uploadGalleryItem( NativeFile( @@ -384,15 +382,15 @@ void main() async { bytes: Uint8List.fromList([1, 1]), ), ); - await mockNetworkImagesFor( - () async => await tester.pumpAndSettle(const Duration(seconds: 2))); + await tester.pump(const Duration(seconds: 2)); + expect(find.byKey(const Key('DeleteGallery')), findsOneWidget); expect(find.byKey(const Key('AvatarStatus')), findsOneWidget); expect(myUserService.myUser.value?.gallery, isNotEmpty); await tester.tap(find.byKey(const Key('AvatarStatus'))); - await mockNetworkImagesFor( - () async => await tester.pumpAndSettle(const Duration(seconds: 2))); + await tester.pump(const Duration(seconds: 2)); + expect( myUserService.myUser.value?.avatar?.galleryItem?.id, const GalleryItemId('testId'), @@ -403,14 +401,14 @@ void main() async { ); await tester.tap(find.byKey(const Key('AvatarStatus'))); - await tester.pumpAndSettle(const Duration(seconds: 2)); + await tester.pump(const Duration(seconds: 2)); expect(myUserService.myUser.value?.avatar?.galleryItem?.id, isNull); expect(myUserService.myUser.value?.callCover?.galleryItem?.id, isNull); await tester.tap(find.byKey(const Key('DeleteGallery'))); - await tester.pumpAndSettle(const Duration(seconds: 2)); + await tester.pump(const Duration(seconds: 2)); await tester.tap(find.byKey(const Key('AlertYesButton'))); - await tester.pumpAndSettle(const Duration(seconds: 2)); + await tester.pump(const Duration(seconds: 2)); expect(find.byKey(const Key('DeleteGallery')), findsNothing); expect(myUserService.myUser.value?.gallery, isEmpty);