fix(mobile): stop sync albums crashing on the main isolate (#29133)

the album sync provider read cancellationProvider, which only exists in the background isolate and throws on the main one. moved the cancel signal onto the isolate call path.

fixes #29125
fixes #29119
This commit is contained in:
Santo Shakil 2026-06-16 20:14:33 +06:00 committed by GitHub
parent 010220d588
commit 9ee412110f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 52 additions and 9 deletions

View file

@ -7,7 +7,6 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@ -19,7 +18,6 @@ final syncLinkedAlbumServiceProvider = Provider(
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(storeServiceProvider),
cancellation: ref.watch(cancellationProvider),
),
);
@ -28,19 +26,17 @@ class SyncLinkedAlbumService {
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
final StoreService _storeService;
final Completer<void>? _cancellation;
SyncLinkedAlbumService(
this._localAlbumRepository,
this._remoteAlbumRepository,
this._albumApiRepository,
this._storeService, {
this._cancellation,
});
this._storeService,
);
final _log = Logger("SyncLinkedAlbumService");
Future<void> syncLinkedAlbums(String userId) async {
Future<void> syncLinkedAlbums(String userId, {Completer<void>? cancellation}) async {
final selectedAlbums = await _localAlbumRepository.getBackupAlbums();
await Future.wait(
@ -64,7 +60,7 @@ class SyncLinkedAlbumService {
final album = await _albumApiRepository.addAssets(
remoteAlbum.id,
assetIds,
abortTrigger: _cancellation?.future,
abortTrigger: cancellation?.future,
);
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
}

View file

@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:logging/logging.dart';
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
@ -10,5 +11,7 @@ Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
Logger("SyncLinkedAlbum").warning("No user logged in, skipping linked album sync");
return Future.value();
}
return ref.read(syncLinkedAlbumServiceProvider).syncLinkedAlbums(user.id);
return ref
.read(syncLinkedAlbumServiceProvider)
.syncLinkedAlbums(user.id, cancellation: ref.read(cancellationProvider));
}

View file

@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import '../../infrastructure/repository.mock.dart';
import '../service.mock.dart';
void main() {
// A container with the service's deps overridden but cancellationProvider left
// alone, i.e. the root (main) isolate, where cancellationProvider has no
// override and throws if read. The UI reads this provider here.
ProviderContainer rootContainer() {
final container = ProviderContainer(
overrides: [
localAlbumRepository.overrideWithValue(MockLocalAlbumRepository()),
remoteAlbumRepository.overrideWithValue(MockRemoteAlbumRepository()),
driftAlbumApiRepositoryProvider.overrideWithValue(MockDriftAlbumApiRepository()),
storeServiceProvider.overrideWithValue(MockStoreService()),
],
);
addTearDown(container.dispose);
return container;
}
// Regression for #29125 (Sync Albums toggle) and #29119 (can't leave the album
// selection screen): #28694 made the provider watch cancellationProvider, so
// reading it off the isolate threw. The cancellation now lives on the isolate
// call path, not the provider, so the UI can build it.
test('builds on the root isolate without a cancellationProvider override', () {
final container = rootContainer();
expect(() => container.read(syncLinkedAlbumServiceProvider), returnsNormally);
expect(container.read(syncLinkedAlbumServiceProvider), isA<SyncLinkedAlbumService>());
});
test('manageLinkedAlbums runs from the UI without a cancellation signal', () {
final service = rootContainer().read(syncLinkedAlbumServiceProvider);
expect(service.manageLinkedAlbums(const [], 'user-1'), completes);
});
}