Compare commits

..

1 Commits

Author SHA1 Message Date
Ben Beckford 1cc0ca9935 fix(mobile): fallback to remote thumbnail when local fails 2026-05-08 13:19:45 -07:00
4 changed files with 44 additions and 33 deletions
@@ -128,31 +128,17 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
final sw = Stopwatch()..start();
try {
final hashTimeout = isRefresh
? Duration(seconds: (maxSeconds ?? 20) - 1)
: Duration(minutes: _isBackupEnabled ? 3 : 6);
final budget = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null;
final sync = _ref?.read(backgroundSyncProvider);
if (sync == null) {
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
if (!await _syncAssets(hashTimeout: timeout)) {
_logger.warning("Remote sync did not complete successfully, skipping backup");
return;
}
// Run sync local, sync remote, hash and backup concurrently so the bg
// refresh task (20s budget) can make progress on all four instead of
// racing them sequentially. Phases are independent at the data layer:
// hash and handle_backup read drift state and tolerate stale reads
// (server-side dedup catches the rare race).
final localFuture = sync.syncLocal();
final remoteFuture = sync.syncRemote();
final hashFuture = sync.hashAssets().timeout(hashTimeout, onTimeout: () {});
final backupFuture = _handleBackup();
final all = Future.wait<dynamic>([localFuture, remoteFuture, hashFuture, backupFuture]);
if (budget != null) {
await all.timeout(budget, onTimeout: () => <dynamic>[]);
if (maxSeconds != null) {
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
} else {
await all;
await backupFuture;
}
} catch (error, stack) {
_logger.severe("Failed to complete iOS background upload", error, stack);
@@ -241,7 +241,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAssetEntity.checksum.isNull())
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]);
..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]);
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
}
@@ -177,8 +177,13 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
return provider;
}
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
if (_shouldUseLocalAsset(asset)) {
ImageProvider? getThumbnailImageProvider(
BaseAsset asset, {
Size size = kThumbnailResolution,
bool edited = true,
bool localFailed = false,
}) {
if (_shouldUseLocalAsset(asset) && !localFailed) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
}
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
@@ -18,24 +19,34 @@ class Thumbnail extends StatefulWidget {
final ImageProvider? imageProvider;
final ImageProvider? thumbhashProvider;
final BoxFit fit;
final Size size;
final BaseAsset? asset;
const Thumbnail({this.imageProvider, this.fit = BoxFit.cover, this.thumbhashProvider, super.key});
const Thumbnail({
this.imageProvider,
this.fit = BoxFit.cover,
this.thumbhashProvider,
this.size = kThumbnailResolution,
this.asset,
super.key,
});
Thumbnail.remote({
required String remoteId,
required String thumbhash,
this.fit = BoxFit.cover,
Size size = kThumbnailResolution,
this.size = kThumbnailResolution,
super.key,
}) : imageProvider = RemoteImageProvider.thumbnail(assetId: remoteId, thumbhash: thumbhash),
thumbhashProvider = null;
thumbhashProvider = null,
asset = null;
Thumbnail.fromAsset({
required BaseAsset? asset,
required this.asset,
this.fit = BoxFit.cover,
/// The logical UI size of the thumbnail. This is only used to determine the ideal image resolution and does not affect the widget size.
Size size = kThumbnailResolution,
this.size = kThumbnailResolution,
super.key,
}) : thumbhashProvider = switch (asset) {
RemoteAsset() when asset.thumbHash != null && asset.localId == null => ThumbHashProvider(
@@ -105,9 +116,8 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
thumbhashStream.addListener(thumbhashStreamListener);
}
void _loadFromImageProvider() {
void _loadFromImageProvider(ImageProvider? imageProvider) {
_stopListeningToImageStream();
final imageProvider = widget.imageProvider;
if (imageProvider == null) return;
final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty);
@@ -142,8 +152,18 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
});
},
onError: (exception, stackTrace) {
log.severe('Error loading image: $exception', exception, stackTrace);
_stopListeningToImageStream();
if (imageProvider is LocalThumbProvider && widget.asset != null) {
ImageProvider? nextProvider = getThumbnailImageProvider(widget.asset!, size: widget.size, localFailed: true);
if (nextProvider != null && nextProvider != imageProvider) {
log.fine('Error loading image, retrying with other provider: $exception', exception, stackTrace);
_loadFromImageProvider(nextProvider);
return;
}
}
log.severe('Error loading image: $exception', exception, stackTrace);
},
);
imageStream.addListener(imageStreamListener);
@@ -180,7 +200,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
_previousImage?.dispose();
_previousImage = null;
}
_loadFromImageProvider();
_loadFromImageProvider(widget.imageProvider);
}
if (_providerImage == null && oldWidget.thumbhashProvider != widget.thumbhashProvider) {
@@ -195,7 +215,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
}
void _loadImage() {
_loadFromImageProvider();
_loadFromImageProvider(widget.imageProvider);
_loadFromThumbhashProvider();
}