fix(mobile): stale local renders after editing a photo on device (#30415)

* fix(mobile): key local image caches by content so on-device edits re-render

* use Object.hash and the local asset factory
This commit is contained in:
Santo Shakil 2026-08-05 00:37:49 +06:00 committed by GitHub
parent cbd2d8a6bd
commit d8e1ead6f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 119 additions and 10 deletions

View file

@ -162,7 +162,13 @@ ImageProvider getFullImageProvider(
provider = FileImage(File(localFilePath)); provider = FileImage(File(localFilePath));
} else if (_shouldUseLocalAsset(asset)) { } else if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage); provider = LocalFullImageProvider(
id: id,
size: size,
assetType: asset.type,
isAnimated: asset.isAnimatedImage,
checksum: asset.checksum,
);
} else { } else {
final String assetId; final String assetId;
final String thumbhash; final String thumbhash;
@ -190,7 +196,7 @@ ImageProvider getFullImageProvider(
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) { ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
if (_shouldUseLocalAsset(asset)) { if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size, assetType: asset.type); return LocalThumbProvider(id: id, size: size, assetType: asset.type, checksum: asset.checksum);
} }
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId; final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;

View file

@ -14,7 +14,10 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
final Size size; final Size size;
final AssetType assetType; final AssetType assetType;
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution}); // 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});
@override @override
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) { Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@ -44,13 +47,13 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
return true; return true;
} }
if (other is LocalThumbProvider) { if (other is LocalThumbProvider) {
return id == other.id; return id == other.id && checksum == other.checksum;
} }
return false; return false;
} }
@override @override
int get hashCode => id.hashCode; int get hashCode => Object.hash(id, checksum);
} }
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider> class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider>
@ -59,8 +62,15 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final Size size; final Size size;
final AssetType assetType; final AssetType assetType;
final bool isAnimated; final bool isAnimated;
final String? checksum;
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated}); LocalFullImageProvider({
required this.id,
required this.assetType,
required this.size,
required this.isAnimated,
this.checksum,
});
@override @override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@ -73,7 +83,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return AnimatedImageStreamCompleter( return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode), stream: _animatedCodec(key, decode),
scale: 1.0, scale: 1.0,
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)), initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),
@ -86,7 +96,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return OneFramePlaceholderImageStreamCompleter( return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)), initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),
@ -163,11 +173,11 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return true; return true;
} }
if (other is LocalFullImageProvider) { if (other is LocalFullImageProvider) {
return id == other.id && size == other.size && isAnimated == other.isAnimated; return id == other.id && size == other.size && isAnimated == other.isAnimated && checksum == other.checksum;
} }
return false; return false;
} }
@override @override
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode; int get hashCode => Object.hash(id, size, isAnimated, checksum);
} }

View file

@ -0,0 +1,93 @@
import 'package:flutter/painting.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import '../../../unit/factories/local_asset_factory.dart';
class _StubCompleter extends ImageStreamCompleter {}
void main() {
late ImageCache cache;
late int loads;
ImageStreamCompleter load() {
loads++;
return _StubCompleter();
}
setUp(() {
cache = ImageCache();
loads = 0;
});
group('LocalThumbProvider caching', () {
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);
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);
expect(loads, 1);
});
// 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);
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);
expect(loads, 1);
});
});
group('factories', () {
test('thumbnails are keyed by the asset checksum', () {
final asset = LocalAssetFactory.create().copyWith(checksum: 'abc');
final provider = getThumbnailImageProvider(asset) as LocalThumbProvider;
expect(provider.checksum, 'abc');
});
});
group('LocalFullImageProvider caching', () {
test('editing on device re-renders the full image', () {
cache.putIfAbsent(
LocalFullImageProvider(
id: 'asset-1',
assetType: AssetType.image,
size: const Size(100, 100),
isAnimated: false,
checksum: 'before',
),
load,
);
cache.putIfAbsent(
LocalFullImageProvider(
id: 'asset-1',
assetType: AssetType.image,
size: const Size(100, 100),
isAnimated: false,
checksum: 'after',
),
load,
);
expect(loads, 2);
});
});
}