fix(mobile): face thumbnail stuck blank after renaming a person

This commit is contained in:
Santo Shakil 2026-08-14 23:34:13 +06:00
parent af33a78d18
commit 827e790cbd
7 changed files with 396 additions and 103 deletions

View file

@ -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<T extends Object> extends ImageProvider<T> {
void cancel();
}
mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvider<T> {
static final _log = Logger('CancellableImageProviderMixin');
class ImageLoader {
static final _log = Logger('ImageLoader');
final ImageProvider key;
bool isCancelled = false;
bool isFinished = false;
ImageRequest? request;
CancelableOperation<ImageInfo?>? cachedOperation;
ImageInfo? getInitialImage(CancellableImageProvider provider) {
final completer = CancelableCompleter<ImageInfo?>(onCancel: provider.cancel);
ImageLoader(this.key);
ImageInfo? getInitialImage(ImageProvider provider) {
final completer = CancelableCompleter<ImageInfo?>();
final cachedStream = provider.resolve(ImageConfiguration.empty);
ImageInfo? cachedImage;
final listener = ImageStreamListener((image, synchronousCall) {
@ -74,7 +73,7 @@ mixin CancellableImageProviderMixin<T extends Object> 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<T extends Object> 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<T extends Object> on CancellableImageProvide
}
}
@override
void cancel() {
isCancelled = true;
final hasActiveWork = !isFinished;
@ -145,7 +146,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
if (hasActiveWork) {
PaintingBinding.instance.imageCache.evict(this);
PaintingBinding.instance.imageCache.evict(key);
}
}
}

View file

@ -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<LocalThumbProvider>
with CancellableImageProviderMixin<LocalThumbProvider> {
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
final String id;
final Size size;
final AssetType assetType;
@ -22,7 +21,12 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
// 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<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@ -31,19 +35,20 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
@override
ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) {
final loader = ImageLoader(key);
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
_codec(loader, key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
],
onLastListenerRemoved: cancel,
onLastListenerRemoved: loader.cancel,
);
}
Stream<ImageInfo> _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<ImageInfo> _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<LocalThumbProvider>
int get hashCode => Object.hash(id, checksum);
}
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider>
with CancellableImageProviderMixin<LocalFullImageProvider> {
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final String id;
final Size size;
final AssetType assetType;
@ -71,7 +75,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final int? height;
final String? checksum;
LocalFullImageProvider({
const LocalFullImageProvider({
required this.id,
required this.assetType,
required this.size,
@ -107,87 +111,96 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
@override
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
final loader = ImageLoader(key);
if (key.isAnimated) {
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
stream: _animatedCodec(loader, key, decode),
scale: 1.0,
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
initialImage: loader.getInitialImage(
LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum),
),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
DiagnosticsProperty<bool>('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: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
onLastListenerRemoved: loader.cancel,
);
}
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
Stream<ImageInfo> _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<Object> _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
Stream<Object> _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}');

View file

@ -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<RemoteImageProvider>
with CancellableImageProviderMixin<RemoteImageProvider> {
class RemoteImageProvider extends ImageProvider<RemoteImageProvider> {
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<RemoteImageProvider>
@override
ImageStreamCompleter loadImage(RemoteImageProvider key, ImageDecoderCallback decode) {
final loader = ImageLoader(key);
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
_codec(loader, key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('URL', key.url),
],
onLastListenerRemoved: cancel,
onLastListenerRemoved: loader.cancel,
);
}
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(uri: key.url, decodeSize: key.decodeSize);
return loadRequest(request, decode, isFinal: true);
Stream<ImageInfo> _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<RemoteImageProvider>
int get hashCode => url.hashCode ^ edited.hashCode ^ decodeSize.hashCode;
}
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
with CancellableImageProviderMixin<RemoteFullImageProvider> {
class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
final String assetId;
final String thumbhash;
final AssetType assetType;
@ -74,7 +73,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
/// Physical size of the thumbnail shown before the preview.
final Size? thumbnailSize;
RemoteFullImageProvider({
const RemoteFullImageProvider({
required this.assetId,
required this.thumbhash,
required this.assetType,
@ -90,11 +89,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
@override
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
final loader = ImageLoader(key);
if (key.isAnimated) {
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
stream: _animatedCodec(loader, key, decode),
scale: 1.0,
initialImage: getInitialImage(
initialImage: loader.getInitialImage(
RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash, decodeSize: key.thumbnailSize),
),
informationCollector: () => <DiagnosticsNode>[
@ -102,13 +102,13 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
DiagnosticsProperty<String>('Asset Id', key.assetId),
DiagnosticsProperty<bool>('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<RemoteFullImagePr
DiagnosticsProperty<String>('Asset Id', key.assetId),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
onLastListenerRemoved: loader.cancel,
);
}
Stream<ImageInfo> _codec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
Stream<ImageInfo> _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<RemoteFullImagePr
),
);
final loadOriginal = assetType == AssetType.image && SettingsRepository.instance.appConfig.image.loadOriginal;
yield* loadRequest(previewRequest, decode, isFinal: !loadOriginal);
yield* loader.loadRequest(previewRequest, decode, isFinal: !loadOriginal);
if (!loadOriginal) {
return;
}
if (isCancelled) {
if (loader.isCancelled) {
return;
}
final originalRequest = request = RemoteImageRequest(
final originalRequest = loader.request = RemoteImageRequest(
uri: getOriginalUrlForRemoteId(key.assetId, edited: key.edited),
);
yield* loadRequest(originalRequest, decode, isFinal: true);
yield* loader.loadRequest(originalRequest, decode, isFinal: true);
}
Stream<Object> _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
Stream<Object> _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<RemoteFullImagePr
edited: key.edited,
),
);
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 = RemoteImageRequest(
final originalRequest = loader.request = RemoteImageRequest(
uri: getOriginalUrlForRemoteId(key.assetId, edited: key.edited),
);
final codec = await loadCodecRequest(originalRequest, isFinal: true);
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 asset ${key.assetId}');

View file

@ -4,11 +4,10 @@ import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
class ThumbHashProvider extends CancellableImageProvider<ThumbHashProvider>
with CancellableImageProviderMixin<ThumbHashProvider> {
class ThumbHashProvider extends ImageProvider<ThumbHashProvider> {
final String thumbHash;
ThumbHashProvider({required this.thumbHash});
const ThumbHashProvider({required this.thumbHash});
@override
Future<ThumbHashProvider> obtainKey(ImageConfiguration configuration) {
@ -17,12 +16,16 @@ class ThumbHashProvider extends CancellableImageProvider<ThumbHashProvider>
@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<ImageInfo> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) {
final request = this.request = ThumbhashImageRequest(thumbhash: key.thumbHash);
return loadRequest(request, decode, isFinal: true);
Stream<ImageInfo> _loadCodec(ImageLoader loader, ThumbHashProvider key, ImageDecoderCallback decode) {
final request = loader.request = ThumbhashImageRequest(thumbhash: key.thumbHash);
return loader.loadRequest(request, decode, isFinal: true);
}
@override

View file

@ -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);

View file

@ -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<Object?> codec;
final started = <int>[];
final cancelled = <int>[];
final _pending = <int, Completer<List<Object?>>>{};
Iterable<int> get unsettled => _pending.keys;
void install(WidgetTester tester) {
final messenger = tester.binding.defaultBinaryMessenger;
messenger.setMockDecodedMessageHandler<Object?>(_channel('requestImage'), (message) async {
final requestId = (message! as List<Object?>)[1]! as int;
started.add(requestId);
final completer = Completer<List<Object?>>();
_pending[requestId] = completer;
return completer.future;
});
messenger.setMockDecodedMessageHandler<Object?>(_channel('cancelRequest'), (message) async {
cancelled.add((message! as List<Object?>)[0]! as int);
return const <Object?>[null];
});
}
void uninstall() {
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockDecodedMessageHandler<Object?>(_channel('requestImage'), null);
messenger.setMockDecodedMessageHandler<Object?>(_channel('cancelRequest'), null);
}
BasicMessageChannel<Object?> _channel(String method) =>
BasicMessageChannel<Object?>('dev.flutter.pigeon.immich_mobile.$api.$method', codec);
void complete(int requestId, Map<String, int>? reply) => _pending.remove(requestId)!.complete(<Object?>[reply]);
void fail(int requestId) => _pending.remove(requestId)!.complete(const <Object?>['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<void> 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 <Object?>[null]);
}
await Future<void>.delayed(const Duration(milliseconds: 1));
}
}
// A decoded 1x1 RGBA frame; the request frees the pointer after upload.
Map<String, int> _rgbaReply() {
final pointer = malloc.allocate<Uint8>(4);
pointer.asTypedList(4).setAll(0, const [0, 255, 0, 255]);
return {'pointer': pointer.address, 'width': 1, 'height': 1, 'rowBytes': 4};
}
Future<void> _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<void>.delayed(const Duration(milliseconds: 1));
}
}
ImageStreamListener _frameTo(Completer<ImageInfo> 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<ImageInfo>();
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<ImageInfo>();
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<ImageInfo>();
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<ImageInfo>();
final thumbListener = _frameTo(thumbFrame);
final thumbStream = thumb.resolve(ImageConfiguration.empty)..addListener(thumbListener);
fullStream.removeListener(fullListener);
await Future<void>.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<void>.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<void>.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<StateError>());
stream2.removeListener(second);
await _until(() => host.cancelled.contains(host.started[3]), 'the next load to wind down');
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(tester.takeException(), isA<StateError>());
await host.settle();
});
});
}

View file

@ -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',
),