mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
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:
parent
cbd2d8a6bd
commit
d8e1ead6f4
3 changed files with 119 additions and 10 deletions
|
|
@ -162,7 +162,13 @@ ImageProvider getFullImageProvider(
|
|||
provider = FileImage(File(localFilePath));
|
||||
} else if (_shouldUseLocalAsset(asset)) {
|
||||
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 {
|
||||
final String assetId;
|
||||
final String thumbhash;
|
||||
|
|
@ -190,7 +196,7 @@ ImageProvider getFullImageProvider(
|
|||
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
|
||||
if (_shouldUseLocalAsset(asset)) {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
|||
final Size size;
|
||||
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
|
||||
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||
|
|
@ -44,13 +47,13 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
|||
return true;
|
||||
}
|
||||
if (other is LocalThumbProvider) {
|
||||
return id == other.id;
|
||||
return id == other.id && checksum == other.checksum;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
int get hashCode => Object.hash(id, checksum);
|
||||
}
|
||||
|
||||
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider>
|
||||
|
|
@ -59,8 +62,15 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||
final Size size;
|
||||
final AssetType assetType;
|
||||
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
|
||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
|
|
@ -73,7 +83,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||
return AnimatedImageStreamCompleter(
|
||||
stream: _animatedCodec(key, decode),
|
||||
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>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
|
|
@ -86,7 +96,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_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>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
|
|
@ -163,11 +173,11 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||
return true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
|
||||
int get hashCode => Object.hash(id, size, isAnimated, checksum);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue