diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index bdedd29d45..7d31226b52 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -12,20 +12,19 @@ import 'package:immich_mobile/presentation/widgets/images/remote_image_provider. import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:logging/logging.dart'; -abstract class CancellableImageProvider extends ImageProvider { - void cancel(); -} - -mixin CancellableImageProviderMixin on CancellableImageProvider { - static final _log = Logger('CancellableImageProviderMixin'); +class ImageLoader { + static final _log = Logger('ImageLoader'); + final ImageProvider key; bool isCancelled = false; bool isFinished = false; ImageRequest? request; CancelableOperation? cachedOperation; - ImageInfo? getInitialImage(CancellableImageProvider provider) { - final completer = CancelableCompleter(onCancel: provider.cancel); + ImageLoader(this.key); + + ImageInfo? getInitialImage(ImageProvider provider) { + final completer = CancelableCompleter(); final cachedStream = provider.resolve(ImageConfiguration.empty); ImageInfo? cachedImage; final listener = ImageStreamListener((image, synchronousCall) { @@ -74,7 +73,7 @@ mixin CancellableImageProviderMixin on CancellableImageProvide } if (isFinal) { isFinished = true; - PaintingBinding.instance.imageCache.evict(this); + PaintingBinding.instance.imageCache.evict(key); rethrow; } _log.warning('Non-fatal image load error', e, stack); @@ -98,9 +97,12 @@ mixin CancellableImageProviderMixin on CancellableImageProvide isFinished = isFinal; return codec; } catch (e) { + if (isCancelled) { + return null; + } if (isFinal) { isFinished = true; - PaintingBinding.instance.imageCache.evict(this); + PaintingBinding.instance.imageCache.evict(key); rethrow; } return null; @@ -127,7 +129,6 @@ mixin CancellableImageProviderMixin on CancellableImageProvide } } - @override void cancel() { isCancelled = true; final hasActiveWork = !isFinished; @@ -145,7 +146,7 @@ mixin CancellableImageProviderMixin on CancellableImageProvide } if (hasActiveWork) { - PaintingBinding.instance.imageCache.evict(this); + PaintingBinding.instance.imageCache.evict(key); } } } diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 46e8eda85b..d5535ee74b 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -13,8 +13,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; // iOS GPU textures max out at 16384px; larger images squish. const _kMaxPixelSize = 16384; -class LocalThumbProvider extends CancellableImageProvider - with CancellableImageProviderMixin { +class LocalThumbProvider extends ImageProvider { final String id; final Size size; final AssetType assetType; @@ -22,7 +21,12 @@ class LocalThumbProvider extends CancellableImageProvider // an edit on the device keeps the id and changes the bytes, so the checksum is what separates two renders final String? checksum; - LocalThumbProvider({required this.id, required this.assetType, this.checksum, this.size = kThumbnailResolution}); + const LocalThumbProvider({ + required this.id, + required this.assetType, + this.checksum, + this.size = kThumbnailResolution, + }); @override Future obtainKey(ImageConfiguration configuration) { @@ -31,19 +35,20 @@ class LocalThumbProvider extends CancellableImageProvider @override ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) { + final loader = ImageLoader(key); return OneFramePlaceholderImageStreamCompleter( - _codec(key, decode), + _codec(loader, key, decode), informationCollector: () => [ DiagnosticsProperty('Id', key.id), DiagnosticsProperty('Size', key.size), ], - onLastListenerRemoved: cancel, + onLastListenerRemoved: loader.cancel, ); } - Stream _codec(LocalThumbProvider key, ImageDecoderCallback decode) { - final request = this.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType); - return loadRequest(request, decode, isFinal: true); + Stream _codec(ImageLoader loader, LocalThumbProvider key, ImageDecoderCallback decode) { + final request = loader.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType); + return loader.loadRequest(request, decode, isFinal: true); } @override @@ -61,8 +66,7 @@ class LocalThumbProvider extends CancellableImageProvider int get hashCode => Object.hash(id, checksum); } -class LocalFullImageProvider extends CancellableImageProvider - with CancellableImageProviderMixin { +class LocalFullImageProvider extends ImageProvider { final String id; final Size size; final AssetType assetType; @@ -71,7 +75,7 @@ class LocalFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Id', key.id), DiagnosticsProperty('Size', key.size), DiagnosticsProperty('isAnimated', key.isAnimated), ], - onLastListenerRemoved: cancel, + onLastListenerRemoved: loader.cancel, ); } return OneFramePlaceholderImageStreamCompleter( - _codec(key, decode), - initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)), + _codec(loader, key, decode), + initialImage: loader.getInitialImage( + LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum), + ), informationCollector: () => [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Id', key.id), DiagnosticsProperty('Size', key.size), DiagnosticsProperty('isAnimated', key.isAnimated), ], - onLastListenerRemoved: cancel, + onLastListenerRemoved: loader.cancel, ); } - Stream _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* { - yield* initialImageStream(); + Stream _codec(ImageLoader loader, LocalFullImageProvider key, ImageDecoderCallback decode) async* { + yield* loader.initialImageStream(); - if (isCancelled) { + if (loader.isCancelled) { return; } final loadOriginal = SettingsRepository.instance.appConfig.image.loadOriginal; final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; - var request = this.request = LocalImageRequest( + var request = loader.request = LocalImageRequest( localId: key.id, size: _previewTarget(devicePixelRatio, !loadOriginal), assetType: key.assetType, ); - yield* loadRequest(request, decode, isFinal: !loadOriginal); + yield* loader.loadRequest(request, decode, isFinal: !loadOriginal); if (!loadOriginal) { return; } - if (isCancelled) { + if (loader.isCancelled) { return; } - request = this.request = LocalImageRequest(localId: key.id, assetType: key.assetType, size: Size.zero); + request = loader.request = LocalImageRequest(localId: key.id, assetType: key.assetType, size: Size.zero); - yield* loadRequest(request, decode, isFinal: true); + yield* loader.loadRequest(request, decode, isFinal: true); } - Stream _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* { - yield* initialImageStream(); + Stream _animatedCodec(ImageLoader loader, LocalFullImageProvider key, ImageDecoderCallback decode) async* { + yield* loader.initialImageStream(); - if (isCancelled) { + if (loader.isCancelled) { return; } final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; - final previewRequest = request = LocalImageRequest( + final previewRequest = loader.request = LocalImageRequest( localId: key.id, size: _previewTarget(devicePixelRatio, false), assetType: key.assetType, ); - yield* loadRequest(previewRequest, decode, isFinal: false); + yield* loader.loadRequest(previewRequest, decode, isFinal: false); - if (isCancelled) { + if (loader.isCancelled) { return; } // always try original for animated, since previews don't support animation - final originalRequest = request = LocalImageRequest(localId: key.id, size: Size.zero, assetType: key.assetType); - final codec = await loadCodecRequest(originalRequest, isFinal: true); + final originalRequest = loader.request = LocalImageRequest( + localId: key.id, + size: Size.zero, + assetType: key.assetType, + ); + final codec = await loader.loadCodecRequest(originalRequest, isFinal: true); if (codec == null) { - if (isCancelled) { + if (loader.isCancelled) { return; } throw StateError('Failed to load animated codec for local asset ${key.id}'); diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index b533fd8cf5..36cb4fe6e5 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -9,15 +9,14 @@ import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_ import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:openapi/api.dart'; -class RemoteImageProvider extends CancellableImageProvider - with CancellableImageProviderMixin { +class RemoteImageProvider extends ImageProvider { final String url; final bool edited; /// Physical size to decode, or null for the source size. final Size? decodeSize; - RemoteImageProvider({required this.url, this.edited = true, this.decodeSize}); + const RemoteImageProvider({required this.url, this.edited = true, this.decodeSize}); RemoteImageProvider.thumbnail({ required String assetId, @@ -33,19 +32,20 @@ class RemoteImageProvider extends CancellableImageProvider @override ImageStreamCompleter loadImage(RemoteImageProvider key, ImageDecoderCallback decode) { + final loader = ImageLoader(key); return OneFramePlaceholderImageStreamCompleter( - _codec(key, decode), + _codec(loader, key, decode), informationCollector: () => [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('URL', key.url), ], - onLastListenerRemoved: cancel, + onLastListenerRemoved: loader.cancel, ); } - Stream _codec(RemoteImageProvider key, ImageDecoderCallback decode) { - final request = this.request = RemoteImageRequest(uri: key.url, decodeSize: key.decodeSize); - return loadRequest(request, decode, isFinal: true); + Stream _codec(ImageLoader loader, RemoteImageProvider key, ImageDecoderCallback decode) { + final request = loader.request = RemoteImageRequest(uri: key.url, decodeSize: key.decodeSize); + return loader.loadRequest(request, decode, isFinal: true); } @override @@ -63,8 +63,7 @@ class RemoteImageProvider extends CancellableImageProvider int get hashCode => url.hashCode ^ edited.hashCode ^ decodeSize.hashCode; } -class RemoteFullImageProvider extends CancellableImageProvider - with CancellableImageProviderMixin { +class RemoteFullImageProvider extends ImageProvider { final String assetId; final String thumbhash; final AssetType assetType; @@ -74,7 +73,7 @@ class RemoteFullImageProvider extends CancellableImageProvider [ @@ -102,13 +102,13 @@ class RemoteFullImageProvider extends CancellableImageProvider('Asset Id', key.assetId), DiagnosticsProperty('isAnimated', key.isAnimated), ], - onLastListenerRemoved: cancel, + onLastListenerRemoved: loader.cancel, ); } return OneFramePlaceholderImageStreamCompleter( - _codec(key, decode), - initialImage: getInitialImage( + _codec(loader, key, decode), + initialImage: loader.getInitialImage( RemoteImageProvider.thumbnail( assetId: key.assetId, thumbhash: key.thumbhash, @@ -121,18 +121,18 @@ class RemoteFullImageProvider extends CancellableImageProvider('Asset Id', key.assetId), DiagnosticsProperty('isAnimated', key.isAnimated), ], - onLastListenerRemoved: cancel, + onLastListenerRemoved: loader.cancel, ); } - Stream _codec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* { - yield* initialImageStream(); + Stream _codec(ImageLoader loader, RemoteFullImageProvider key, ImageDecoderCallback decode) async* { + yield* loader.initialImageStream(); - if (isCancelled) { + if (loader.isCancelled) { return; } - final previewRequest = request = RemoteImageRequest( + final previewRequest = loader.request = RemoteImageRequest( uri: getThumbnailUrlForRemoteId( key.assetId, type: AssetMediaSize.preview, @@ -141,30 +141,30 @@ class RemoteFullImageProvider extends CancellableImageProvider _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* { - yield* initialImageStream(); + Stream _animatedCodec(ImageLoader loader, RemoteFullImageProvider key, ImageDecoderCallback decode) async* { + yield* loader.initialImageStream(); - if (isCancelled) { + if (loader.isCancelled) { return; } - final previewRequest = request = RemoteImageRequest( + final previewRequest = loader.request = RemoteImageRequest( uri: getThumbnailUrlForRemoteId( key.assetId, type: AssetMediaSize.preview, @@ -172,19 +172,19 @@ class RemoteFullImageProvider extends CancellableImageProvider - with CancellableImageProviderMixin { +class ThumbHashProvider extends ImageProvider { final String thumbHash; - ThumbHashProvider({required this.thumbHash}); + const ThumbHashProvider({required this.thumbHash}); @override Future obtainKey(ImageConfiguration configuration) { @@ -17,12 +16,16 @@ class ThumbHashProvider extends CancellableImageProvider @override ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) { - return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onLastListenerRemoved: cancel); + final loader = ImageLoader(key); + return OneFramePlaceholderImageStreamCompleter( + _loadCodec(loader, key, decode), + onLastListenerRemoved: loader.cancel, + ); } - Stream _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) { - final request = this.request = ThumbhashImageRequest(thumbhash: key.thumbHash); - return loadRequest(request, decode, isFinal: true); + Stream _loadCodec(ImageLoader loader, ThumbHashProvider key, ImageDecoderCallback decode) { + final request = loader.request = ThumbhashImageRequest(thumbhash: key.thumbHash); + return loader.loadRequest(request, decode, isFinal: true); } @override diff --git a/mobile/test/infrastructure/loaders/remote_image_request_test.dart b/mobile/test/infrastructure/loaders/remote_image_request_test.dart index 97bdf3e2bf..6120347fcc 100644 --- a/mobile/test/infrastructure/loaders/remote_image_request_test.dart +++ b/mobile/test/infrastructure/loaders/remote_image_request_test.dart @@ -101,15 +101,17 @@ void main() { }); test('uses the decode size in the provider cache key', () { - final small = RemoteImageProvider(url: 'https://example.test/thumbnail', decodeSize: const ui.Size.square(160)); - final large = RemoteImageProvider(url: 'https://example.test/thumbnail', decodeSize: const ui.Size.square(320)); + const small = RemoteImageProvider(url: 'https://example.test/thumbnail', decodeSize: ui.Size.square(160)); + const large = RemoteImageProvider(url: 'https://example.test/thumbnail', decodeSize: ui.Size.square(320)); expect(small, isNot(large)); }); + RemoteImageProvider providerFor(String url) => RemoteImageProvider(url: url); + test('shares the cache key when no decode size is set', () { - final first = RemoteImageProvider(url: 'https://example.test/thumbnail'); - final second = RemoteImageProvider(url: 'https://example.test/thumbnail'); + final first = providerFor('https://example.test/thumbnail'); + final second = providerFor('https://example.test/thumbnail'); expect(first, second); expect(first.hashCode, second.hashCode); diff --git a/mobile/test/presentation/widgets/images/image_provider_test.dart b/mobile/test/presentation/widgets/images/image_provider_test.dart new file mode 100644 index 0000000000..db20da53bc --- /dev/null +++ b/mobile/test/presentation/widgets/images/image_provider_test.dart @@ -0,0 +1,271 @@ +import 'dart:async'; +import 'dart:ffi' hide Size; + +import 'package:ffi/ffi.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/platform/local_image_api.g.dart'; +import 'package:immich_mobile/platform/remote_image_api.g.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; + +// Hand-driven pigeon host: requests stay pending until the test completes them, +// so cancellation ordering can be controlled exactly. +class _FakeImageHost { + _FakeImageHost(this.api, this.codec); + + final String api; + final MessageCodec codec; + final started = []; + final cancelled = []; + final _pending = >>{}; + + Iterable get unsettled => _pending.keys; + + void install(WidgetTester tester) { + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockDecodedMessageHandler(_channel('requestImage'), (message) async { + final requestId = (message! as List)[1]! as int; + started.add(requestId); + final completer = Completer>(); + _pending[requestId] = completer; + return completer.future; + }); + messenger.setMockDecodedMessageHandler(_channel('cancelRequest'), (message) async { + cancelled.add((message! as List)[0]! as int); + return const [null]; + }); + } + + void uninstall() { + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockDecodedMessageHandler(_channel('requestImage'), null); + messenger.setMockDecodedMessageHandler(_channel('cancelRequest'), null); + } + + BasicMessageChannel _channel(String method) => + BasicMessageChannel('dev.flutter.pigeon.immich_mobile.$api.$method', codec); + + void complete(int requestId, Map? reply) => _pending.remove(requestId)!.complete([reply]); + + void fail(int requestId) => _pending.remove(requestId)!.complete(const ['failed', 'request failed', null]); + + // Cancelled requests may stay pending mid-test; settle them before the test + // ends so no platform message is left hanging. + Future settle() async { + for (final requestId in _pending.keys.toList()) { + expect(cancelled, contains(requestId), reason: 'request $requestId was neither completed nor cancelled'); + _pending.remove(requestId)!.complete(const [null]); + } + await Future.delayed(const Duration(milliseconds: 1)); + } +} + +// A decoded 1x1 RGBA frame; the request frees the pointer after upload. +Map _rgbaReply() { + final pointer = malloc.allocate(4); + pointer.asTypedList(4).setAll(0, const [0, 255, 0, 255]); + return {'pointer': pointer.address, 'width': 1, 'height': 1, 'rowBytes': 4}; +} + +Future _until(bool Function() condition, String reason) async { + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + fail('timed out waiting for $reason'); + } + await Future.delayed(const Duration(milliseconds: 1)); + } +} + +ImageStreamListener _frameTo(Completer completer) => ImageStreamListener( + (image, _) { + if (!completer.isCompleted) { + completer.complete(image); + } + }, + onError: (e, s) { + if (!completer.isCompleted) { + completer.completeError(e, s); + } + }, +); + +void main() { + late ImageCache cache; + late _FakeImageHost host; + + setUp(() { + cache = PaintingBinding.instance.imageCache + ..clear() + ..clearLiveImages(); + }); + + tearDown(() { + host.uninstall(); + expect(host.unsettled, isEmpty); + }); + + testWidgets('re-resolving after a cancelled load still produces a frame', (tester) async { + await tester.runAsync(() async { + host = _FakeImageHost('RemoteImageApi', RemoteImageApi.pigeonChannelCodec)..install(tester); + const provider = RemoteImageProvider(url: 'https://example.com/1.jpg'); + + final gone = ImageStreamListener((_, __) {}); + final stream1 = provider.resolve(ImageConfiguration.empty)..addListener(gone); + await _until(() => host.started.length == 1, 'the first request'); + stream1.removeListener(gone); + await _until(() => host.cancelled.contains(host.started.first), 'the first request to be cancelled'); + + final frame = Completer(); + final listener = _frameTo(frame); + final stream2 = provider.resolve(ImageConfiguration.empty)..addListener(listener); + await _until(() => host.started.length == 2, 'a second request after the cancel'); + host.complete(host.started[1], _rgbaReply()); + + final image = await frame.future; + expect(image.image.width, 1); + expect(cache.statusForKey(provider).pending, isFalse); + stream2.removeListener(listener); + image.dispose(); + await host.settle(); + }); + }); + + testWidgets('a finished image stays cached after the last listener leaves', (tester) async { + await tester.runAsync(() async { + host = _FakeImageHost('RemoteImageApi', RemoteImageApi.pigeonChannelCodec)..install(tester); + const provider = RemoteImageProvider(url: 'https://example.com/3.jpg'); + + final frame = Completer(); + final listener = _frameTo(frame); + final stream = provider.resolve(ImageConfiguration.empty)..addListener(listener); + await _until(() => host.started.length == 1, 'the request'); + host.complete(host.started.first, _rgbaReply()); + (await frame.future).dispose(); + + stream.removeListener(listener); + expect(cache.containsKey(provider), isTrue); + + final replay = Completer(); + final replayListener = _frameTo(replay); + final replayStream = provider.resolve(ImageConfiguration.empty)..addListener(replayListener); + final replayed = await replay.future; + expect(replayed.image.width, 1); + expect(host.started, hasLength(1)); + replayStream.removeListener(replayListener); + replayed.dispose(); + await host.settle(); + }); + }); + + testWidgets('cancelling the full image keeps a shared thumbnail loading', (tester) async { + await tester.runAsync(() async { + host = _FakeImageHost('LocalImageApi', LocalImageApi.pigeonChannelCodec)..install(tester); + const full = LocalFullImageProvider( + id: 'asset-1', + assetType: AssetType.image, + size: Size(100, 100), + isAnimated: false, + checksum: 'c1', + ); + + final fullListener = ImageStreamListener((_, __) {}); + final fullStream = full.resolve(ImageConfiguration.empty)..addListener(fullListener); + await _until(() => host.started.length == 1, 'the thumbnail request'); + final thumbRequest = host.started.first; + + // A second widget shares the same thumbnail entry. + const thumb = LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'c1'); + final thumbFrame = Completer(); + final thumbListener = _frameTo(thumbFrame); + final thumbStream = thumb.resolve(ImageConfiguration.empty)..addListener(thumbListener); + + fullStream.removeListener(fullListener); + await Future.delayed(const Duration(milliseconds: 50)); + expect(host.cancelled, isNot(contains(thumbRequest))); + expect(host.started, hasLength(1)); + + host.complete(thumbRequest, _rgbaReply()); + final thumbImage = await thumbFrame.future; + expect(thumbImage.image.width, 1); + thumbStream.removeListener(thumbListener); + thumbImage.dispose(); + await host.settle(); + }); + }); + + testWidgets('a stale load finishing late cannot break the next load\'s cancel', (tester) async { + await tester.runAsync(() async { + host = _FakeImageHost('RemoteImageApi', RemoteImageApi.pigeonChannelCodec)..install(tester); + const provider = RemoteImageProvider(url: 'https://example.com/5.jpg'); + + final gone = ImageStreamListener((_, __) {}); + final stream1 = provider.resolve(ImageConfiguration.empty)..addListener(gone); + await _until(() => host.started.length == 1, 'the first request'); + final first = host.started.first; + stream1.removeListener(gone); + await _until(() => host.cancelled.contains(first), 'the first request to be cancelled'); + + final second = ImageStreamListener((_, __) {}); + final stream2 = provider.resolve(ImageConfiguration.empty)..addListener(second); + await _until(() => host.started.length == 2, 'a second request after the cancel'); + + host.complete(first, null); + await Future.delayed(const Duration(milliseconds: 50)); + + stream2.removeListener(second); + await _until(() => host.cancelled.contains(host.started[1]), 'the second request to be cancelled'); + expect(cache.statusForKey(provider).pending, isFalse); + await host.settle(); + }); + }); + + testWidgets('an abandoned animated load failing late cannot evict the next load', (tester) async { + await tester.runAsync(() async { + host = _FakeImageHost('LocalImageApi', LocalImageApi.pigeonChannelCodec)..install(tester); + const provider = LocalFullImageProvider( + id: 'asset-2', + assetType: AssetType.image, + size: Size(100, 100), + isAnimated: true, + checksum: 'c2', + ); + + final gone = ImageStreamListener((image, _) => image.dispose()); + final stream1 = provider.resolve(ImageConfiguration.empty)..addListener(gone); + await _until(() => host.started.length == 1, 'the thumbnail request'); + host.complete(host.started.first, _rgbaReply()); + await _until(() => host.started.length == 2, 'the preview request'); + host.complete(host.started[1], _rgbaReply()); + await _until(() => host.started.length == 3, 'the encoded original request'); + final original = host.started[2]; + + stream1.removeListener(gone); + await _until(() => host.cancelled.contains(original), 'the original request to be cancelled'); + + // Drop the cached thumbnail so the next load starts pending instead of complete. + cache.evict(const LocalThumbProvider(id: 'asset-2', assetType: AssetType.image, checksum: 'c2')); + + final second = ImageStreamListener((_, __) {}); + final stream2 = provider.resolve(ImageConfiguration.empty)..addListener(second); + await _until(() => host.started.length == 4, 'the next load to start'); + + host.fail(original); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(cache.statusForKey(provider).pending, isTrue); + expect(host.cancelled, isNot(contains(host.started[3]))); + // Abandoned animated loads close without a codec. + expect(tester.takeException(), isA()); + + stream2.removeListener(second); + await _until(() => host.cancelled.contains(host.started[3]), 'the next load to wind down'); + await Future.delayed(const Duration(milliseconds: 50)); + expect(tester.takeException(), isA()); + await host.settle(); + }); + }); +} diff --git a/mobile/test/presentation/widgets/images/local_image_provider_test.dart b/mobile/test/presentation/widgets/images/local_image_provider_test.dart index 5b556f8a41..223655518c 100644 --- a/mobile/test/presentation/widgets/images/local_image_provider_test.dart +++ b/mobile/test/presentation/widgets/images/local_image_provider_test.dart @@ -71,16 +71,19 @@ void main() { }); group('LocalThumbProvider caching', () { + LocalThumbProvider thumbFor(String? checksum) => + LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: checksum); + test('editing on device re-renders the thumbnail', () { - cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load); - cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'after'), load); + cache.putIfAbsent(const LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load); + cache.putIfAbsent(const LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'after'), load); expect(loads, 2); }); test('an unchanged thumbnail still comes from the cache', () { - cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'same'), load); - cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'same'), load); + cache.putIfAbsent(thumbFor('same'), load); + cache.putIfAbsent(thumbFor('same'), load); expect(loads, 1); }); @@ -88,15 +91,15 @@ void main() { // The rehash clears the checksum before writing the new one, so the tile has to // follow that step too or it waits for the hash to land before showing the edit. test('re-renders while the checksum is still being recomputed', () { - cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load); - cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load); + cache.putIfAbsent(const LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load); + cache.putIfAbsent(const LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load); expect(loads, 2); }); test('stays cached while the checksum is missing', () { - cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load); - cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load); + cache.putIfAbsent(thumbFor(null), load); + cache.putIfAbsent(thumbFor(null), load); expect(loads, 1); }); @@ -115,20 +118,20 @@ void main() { group('LocalFullImageProvider caching', () { test('editing on device re-renders the full image', () { cache.putIfAbsent( - LocalFullImageProvider( + const LocalFullImageProvider( id: 'asset-1', assetType: AssetType.image, - size: const Size(100, 100), + size: Size(100, 100), isAnimated: false, checksum: 'before', ), load, ); cache.putIfAbsent( - LocalFullImageProvider( + const LocalFullImageProvider( id: 'asset-1', assetType: AssetType.image, - size: const Size(100, 100), + size: Size(100, 100), isAnimated: false, checksum: 'after', ),