Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,21 @@ import 'dart:ui' as ui;

import 'package:flutter/foundation.dart' show InformationCollector;
import 'package:flutter/painting.dart';
import 'package:immich_mobile/presentation/widgets/images/cache_aware_listener_tracker.mixin.dart';

/// A [MultiFrameImageStreamCompleter] with support for listener tracking
/// which makes resource cleanup possible when no longer needed.
/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
// True once any image or the codec has been provided.
// Until then the image cache holds one listener, so "last real listener gone"
// is _listenerCount == 1, not 0.
bool didProvideImage = false;

class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter with CacheAwareListenerTrackerMixin {
AnimatedImageStreamCompleter._({
required super.codec,
required super.scale,
required bool hadInitialImage,
super.informationCollector,
void Function()? onLastListenerRemoved,
}) : _onLastListenerRemoved = onLastListenerRemoved;
}) {
setupListenerTracking(hadInitialImage: hadInitialImage, onLastListenerRemoved: onLastListenerRemoved);
}

factory AnimatedImageStreamCompleter({
required Stream<Object> stream,
Expand All @@ -33,23 +30,21 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
final self = AnimatedImageStreamCompleter._(
codec: codecCompleter.future,
scale: scale,
hadInitialImage: initialImage != null,
informationCollector: informationCollector,
onLastListenerRemoved: onLastListenerRemoved,
);

if (initialImage != null) {
self.didProvideImage = true;
self.setImage(initialImage);
}

stream.listen(
(item) {
if (item is ImageInfo) {
self.didProvideImage = true;
self.setImage(item);
} else if (item is ui.Codec) {
if (!codecCompleter.isCompleted) {
self.didProvideImage = true;
codecCompleter.complete(item);
}
}
Expand All @@ -70,27 +65,4 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {

return self;
}

@override
void addListener(ImageStreamListener listener) {
super.addListener(listener);
_listenerCount++;
}

@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
_listenerCount--;

final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage;

if (onlyCacheListenerLeft || noListenersAfterCodec) {
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import 'package:flutter/painting.dart';

/// Tracks listeners on an [ImageStreamCompleter] to safely cancel in-flight
/// network requests without interfering with [ImageCache] internals.
///
/// ### Problem
/// Cancelling fetches when the listener count drops to 1 (cache only) or 0
/// is unsafe due to three framework behaviours:
///
/// 1. **Memory-pressure eviction** — `ImageCache.clear()` removes the cache
/// listener while UI widgets still need the image. A count-based check
/// would cancel the active fetch, leaving the UI with no image.
/// 2. **Synchronous detach during `putIfAbsent`** — When an `initialImage`
/// is provided, the cache attaches, receives the frame, and detaches
/// synchronously *before* the UI widget can attach. Count reaches 0 and
/// would trigger a false cancel.
/// 3. **Listener misidentification** — After the cache detaches (via 1 or 2),
/// the next UI listener could be mistaken for the cache listener, causing
/// incorrect cancellations when that widget is disposed.
///
/// ### Solution: First-Listener Heuristic
/// The cache is always the first listener attached (via `putIfAbsent`). This
/// mixin records that identity once and uses it for all subsequent decisions:
///
/// * **Identity locking** — The first listener is assumed to be the cache.
/// Once identified, `_hasIdentifiedCacheListener` prevents reassignment.
Comment thread
mertalev marked this conversation as resolved.
/// * **Targeted cancellation** — Cancel only when the identified cache
/// listener is the sole remaining listener and no image has been delivered.
/// * **Sync-removal bypass** — When `hadInitialImage` is set, the first
/// synchronous removal of the cache listener is ignored so the fetch
/// survives until the UI attaches.
mixin CacheAwareListenerTrackerMixin on ImageStreamCompleter {
Comment thread
mertalev marked this conversation as resolved.
void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
bool _hadInitialImage = false;
bool _hasIgnoredFirstSyncRemoval = false;
ImageStreamListener? _cacheListener;
bool _hasIdentifiedCacheListener = false;

/// Initializes the tracking state. Must be called in the subclass constructor.
void setupListenerTracking({required bool hadInitialImage, void Function()? onLastListenerRemoved}) {
_hadInitialImage = hadInitialImage;
_onLastListenerRemoved = onLastListenerRemoved;
}

@override
void addListener(ImageStreamListener listener) {
if (!_hasIdentifiedCacheListener) {
_hasIdentifiedCacheListener = true;
_cacheListener = listener;
}

_listenerCount++;
super.addListener(listener);
}
Comment thread
LeLunZ marked this conversation as resolved.

@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
_listenerCount--;

final bool isCacheListener = listener == _cacheListener;
if (isCacheListener) {
_cacheListener = null;
}

if (_hadInitialImage && !_hasIgnoredFirstSyncRemoval && isCacheListener) {
_hasIgnoredFirstSyncRemoval = true;
return;
}

final bool onlyCacheListenerLeft = _listenerCount == 1 && _cacheListener != null;

final bool completelyAbandoned = _listenerCount == 0;

Comment thread
LeLunZ marked this conversation as resolved.
if (onlyCacheListenerLeft || completelyAbandoned) {
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,10 @@ import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/presentation/widgets/images/cache_aware_listener_tracker.mixin.dart';

/// An ImageStreamCompleter with support for loading multiple images.
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
// True once setImage() has been called at least once.
bool didProvideImage = false;

class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter with CacheAwareListenerTrackerMixin {
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images]
/// should be the primary images to display (typically asynchronously as they load).
/// The [initialImage] is an optional image that will be emitted synchronously
Expand All @@ -24,14 +20,14 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
InformationCollector? informationCollector,
void Function()? onLastListenerRemoved,
}) {
setupListenerTracking(hadInitialImage: initialImage != null, onLastListenerRemoved: onLastListenerRemoved);

if (initialImage != null) {
didProvideImage = true;
setImage(initialImage);
}
_onLastListenerRemoved = onLastListenerRemoved;

images.listen(
(image) {
didProvideImage = true;
setImage(image);
},
onError: (Object error, StackTrace stack) {
Expand All @@ -45,26 +41,4 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
},
);
}

@override
void addListener(ImageStreamListener listener) {
super.addListener(listener);
_listenerCount = _listenerCount + 1;
}

@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
_listenerCount = _listenerCount - 1;

final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
final bool noListenersAfterImage = _listenerCount == 0 && didProvideImage;

final onLastListenerRemoved = _onLastListenerRemoved;

if (onLastListenerRemoved != null && (noListenersAfterImage || onlyCacheListenerLeft)) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
}
}
}
Loading
Loading