mirror of
https://github.com/immich-app/immich.git
synced 2026-09-30 13:23:21 +08:00
fix(mobile): face thumbnail stuck blank after renaming a person (#30769)
* fix(mobile): face thumbnail stuck blank after renaming a person * flatten the codec error guard and rework the tests around framework driven reuse * trim the image provider tests and note the per load state rule * keep two small image provider tests: the renamed face and a late failure of a cancelled load * clearer image provider tests: return the created completer, comment the steps, drop the unused listener, rename to thumbWithChecksum * use the provider constructor with a lint ignore in the cache key test
This commit is contained in:
@@ -12,20 +12,21 @@ 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');
|
||||
// The provider is also the cache key and can be resolved more than once.
|
||||
// Keep mutable state per load so cancelling one cannot affect the next load.
|
||||
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) {
|
||||
@@ -73,7 +74,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);
|
||||
@@ -102,7 +103,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
}
|
||||
if (isFinal) {
|
||||
isFinished = true;
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
rethrow;
|
||||
}
|
||||
return null;
|
||||
@@ -133,7 +134,6 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void cancel() {
|
||||
isCancelled = true;
|
||||
final hasActiveWork = !isFinished;
|
||||
@@ -151,7 +151,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
}
|
||||
|
||||
if (hasActiveWork) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}');
|
||||
|
||||
@@ -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}');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -101,14 +101,16 @@ 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));
|
||||
});
|
||||
|
||||
test('shares the cache key when no decode size is set', () {
|
||||
// ignore: prefer_const_constructors
|
||||
final first = RemoteImageProvider(url: 'https://example.test/thumbnail');
|
||||
// ignore: prefer_const_constructors
|
||||
final second = RemoteImageProvider(url: 'https://example.test/thumbnail');
|
||||
|
||||
expect(first, second);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
|
||||
void main() {
|
||||
const api = 'dev.flutter.pigeon.immich_mobile.RemoteImageApi';
|
||||
const requestImage = BasicMessageChannel<Object?>('$api.requestImage', RemoteImageApi.pigeonChannelCodec);
|
||||
const cancelRequest = BasicMessageChannel<Object?>('$api.cancelRequest', RemoteImageApi.pigeonChannelCodec);
|
||||
|
||||
testWidgets('a face url that changes while the avatar animates still loads', (tester) async {
|
||||
final inFlight = <Object?, Object?>{};
|
||||
tester.binding.defaultBinaryMessenger
|
||||
..setMockDecodedMessageHandler<Object?>(requestImage, (message) {
|
||||
final [url, id, ...] = message! as List<Object?>;
|
||||
inFlight[id] = url;
|
||||
return Completer<Object?>().future;
|
||||
})
|
||||
..setMockDecodedMessageHandler<Object?>(cancelRequest, (message) async {
|
||||
inFlight.remove((message! as List<Object?>).first);
|
||||
return const <Object?>[null];
|
||||
});
|
||||
Widget avatar(String url) => MaterialApp(
|
||||
home: CircleAvatar(backgroundImage: RemoteImageProvider(url: url)),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(avatar('https://example.test/before'));
|
||||
await tester.pumpWidget(avatar('https://example.test/after'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(inFlight.values, ['https://example.test/after']);
|
||||
});
|
||||
|
||||
testWidgets('a cancelled load that fails late keeps the next load of the same image', (tester) async {
|
||||
final replies = <Completer<Object?>>[];
|
||||
tester.binding.defaultBinaryMessenger
|
||||
..setMockDecodedMessageHandler<Object?>(requestImage, (_) {
|
||||
final reply = Completer<Object?>();
|
||||
replies.add(reply);
|
||||
return reply.future;
|
||||
})
|
||||
..setMockDecodedMessageHandler<Object?>(cancelRequest, (_) async => const <Object?>[null]);
|
||||
const provider = RemoteImageProvider(url: 'https://example.test/face');
|
||||
final listener = ImageStreamListener((_, _) {});
|
||||
|
||||
// Show the image and hide it again before it loads, which cancels the first load
|
||||
final first = provider.resolve(ImageConfiguration.empty)..addListener(listener);
|
||||
await tester.pump();
|
||||
first.removeListener(listener);
|
||||
// Showing the same image again has to start a second load
|
||||
provider.resolve(ImageConfiguration.empty);
|
||||
await tester.pump();
|
||||
expect(replies, hasLength(2));
|
||||
// The cancelled request only fails now, and that must not throw away the second load
|
||||
replies.first.complete(const <Object?>['error', 'late failure', null]);
|
||||
await tester.pump();
|
||||
|
||||
expect(PaintingBinding.instance.imageCache.statusForKey(provider).pending, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -71,16 +71,19 @@ void main() {
|
||||
});
|
||||
|
||||
group('LocalThumbProvider caching', () {
|
||||
LocalThumbProvider thumbWithChecksum(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(thumbWithChecksum('same'), load);
|
||||
cache.putIfAbsent(thumbWithChecksum('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(thumbWithChecksum(null), load);
|
||||
cache.putIfAbsent(thumbWithChecksum(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',
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user