mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
fix(mobile): face thumbnail stuck blank after renaming a person
This commit is contained in:
parent
af33a78d18
commit
827e790cbd
7 changed files with 396 additions and 103 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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);
|
||||
|
|
|
|||
271
mobile/test/presentation/widgets/images/image_provider_test.dart
Normal file
271
mobile/test/presentation/widgets/images/image_provider_test.dart
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue