refactor: yeet old timeline (#27666)

* refactor: yank old timeline

# Conflicts:
#	mobile/lib/presentation/pages/editing/drift_edit.page.dart
#	mobile/lib/providers/websocket.provider.dart
#	mobile/lib/routing/router.dart

* more cleanup

* remove native code

* chore: bump sqlite-data version

* remove old background tasks from BGTaskSchedulerPermittedIdentifiers

* rebase

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong
2026-04-15 23:00:27 +05:30
committed by GitHub
parent 6dd6053222
commit 79fccdbee0
367 changed files with 332 additions and 50870 deletions
@@ -1,151 +0,0 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/services/album.service.dart';
final isRefreshingRemoteAlbumProvider = StateProvider<bool>((ref) => false);
class AlbumNotifier extends StateNotifier<List<Album>> {
AlbumNotifier(this.albumService, this.ref) : super([]) {
albumService.getAllRemoteAlbums().then((value) {
if (mounted) {
state = value;
}
});
_streamSub = albumService.watchRemoteAlbums().listen((data) => state = data);
}
final AlbumService albumService;
final Ref ref;
late final StreamSubscription<List<Album>> _streamSub;
Future<void> refreshRemoteAlbums() async {
ref.read(isRefreshingRemoteAlbumProvider.notifier).state = true;
await albumService.refreshRemoteAlbums();
ref.read(isRefreshingRemoteAlbumProvider.notifier).state = false;
}
Future<void> refreshDeviceAlbums() => albumService.refreshDeviceAlbums();
Future<bool> deleteAlbum(Album album) => albumService.deleteAlbum(album);
Future<Album?> createAlbum(String albumTitle, Set<Asset> assets) => albumService.createAlbum(albumTitle, assets, []);
Future<Album?> getAlbumByName(String albumName, {bool? remote, bool? shared, bool? owner}) =>
albumService.getAlbumByName(albumName, remote: remote, shared: shared, owner: owner);
/// Create an album on the server with the same name as the selected album for backup
/// First this will check if the album already exists on the server with name
/// If it does not exist, it will create the album on the server
Future<void> createSyncAlbum(String albumName) async {
final album = await getAlbumByName(albumName, remote: true, owner: true);
if (album != null) {
return;
}
await createAlbum(albumName, {});
}
Future<bool> leaveAlbum(Album album) async {
var res = await albumService.leaveAlbum(album);
if (res) {
await deleteAlbum(album);
return true;
} else {
return false;
}
}
void searchAlbums(String searchTerm, QuickFilterMode filterMode) async {
state = await albumService.search(searchTerm, filterMode);
}
Future<void> addUsers(Album album, List<String> userIds) async {
await albumService.addUsers(album, userIds);
}
Future<bool> removeUser(Album album, UserDto user) async {
final isRemoved = await albumService.removeUser(album, user);
if (isRemoved && album.sharedUsers.isEmpty) {
state = state.where((element) => element.id != album.id).toList();
}
return isRemoved;
}
Future<void> addAssets(Album album, Iterable<Asset> assets) async {
await albumService.addAssets(album, assets);
}
Future<bool> removeAsset(Album album, Iterable<Asset> assets) async {
return await albumService.removeAsset(album, assets);
}
Future<bool> setActivitystatus(Album album, bool enabled) {
return albumService.setActivityStatus(album, enabled);
}
Future<Album?> toggleSortOrder(Album album) {
final order = album.sortOrder == SortOrder.asc ? SortOrder.desc : SortOrder.asc;
return albumService.updateSortOrder(album, order);
}
@override
void dispose() {
_streamSub.cancel();
super.dispose();
}
}
final albumProvider = StateNotifierProvider.autoDispose<AlbumNotifier, List<Album>>((ref) {
return AlbumNotifier(ref.watch(albumServiceProvider), ref);
});
final albumWatcher = StreamProvider.autoDispose.family<Album, int>((ref, id) async* {
final albumService = ref.watch(albumServiceProvider);
final album = await albumService.getAlbumById(id);
if (album != null) {
yield album;
}
await for (final album in albumService.watchAlbum(id)) {
if (album != null) {
yield album;
}
}
});
class LocalAlbumsNotifier extends StateNotifier<List<Album>> {
LocalAlbumsNotifier(this.albumService) : super([]) {
albumService.getAllLocalAlbums().then((value) {
if (mounted) {
state = value;
}
});
_streamSub = albumService.watchLocalAlbums().listen((data) => state = data);
}
final AlbumService albumService;
late final StreamSubscription<List<Album>> _streamSub;
@override
void dispose() {
_streamSub.cancel();
super.dispose();
}
}
final localAlbumsProvider = StateNotifierProvider.autoDispose<LocalAlbumsNotifier, List<Album>>((ref) {
return LocalAlbumsNotifier(ref.watch(albumServiceProvider));
});
@@ -1,119 +1,19 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'album_sort_by_options.provider.g.dart';
typedef AlbumSortFn = List<Album> Function(List<Album> albums, bool isReverse);
class _AlbumSortHandlers {
const _AlbumSortHandlers._();
static const AlbumSortFn created = _sortByCreated;
static List<Album> _sortByCreated(List<Album> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.createdAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn title = _sortByTitle;
static List<Album> _sortByTitle(List<Album> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.name);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn lastModified = _sortByLastModified;
static List<Album> _sortByLastModified(List<Album> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.modifiedAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn assetCount = _sortByAssetCount;
static List<Album> _sortByAssetCount(List<Album> albums, bool isReverse) {
final sorted = albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount));
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn mostRecent = _sortByMostRecent;
static List<Album> _sortByMostRecent(List<Album> albums, bool isReverse) {
final sorted = albums.sorted((a, b) {
if (a.endDate == null && b.endDate == null) {
return 0;
}
if (a.endDate == null) {
// Put nulls at the end for recent sorting
return 1;
}
if (b.endDate == null) {
return -1;
}
// Sort by descending recent date
return b.endDate!.compareTo(a.endDate!);
});
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn mostOldest = _sortByMostOldest;
static List<Album> _sortByMostOldest(List<Album> albums, bool isReverse) {
final sorted = albums.sorted((a, b) {
if (a.startDate != null && b.startDate != null) {
return a.startDate!.compareTo(b.startDate!);
}
if (a.startDate == null) return 1;
if (b.startDate == null) return -1;
return 0;
});
return (isReverse ? sorted.reversed : sorted).toList();
}
}
// Store index allows us to re-arrange the values without affecting the saved prefs
enum AlbumSortMode {
title(1, "library_page_sort_title", _AlbumSortHandlers.title, SortOrder.asc),
assetCount(4, "library_page_sort_asset_count", _AlbumSortHandlers.assetCount, SortOrder.desc),
lastModified(3, "library_page_sort_last_modified", _AlbumSortHandlers.lastModified, SortOrder.desc),
created(0, "library_page_sort_created", _AlbumSortHandlers.created, SortOrder.desc),
mostRecent(2, "sort_recent", _AlbumSortHandlers.mostRecent, SortOrder.desc),
mostOldest(5, "sort_oldest", _AlbumSortHandlers.mostOldest, SortOrder.asc);
title(1, "library_page_sort_title", SortOrder.asc),
assetCount(4, "library_page_sort_asset_count", SortOrder.desc),
lastModified(3, "library_page_sort_last_modified", SortOrder.desc),
created(0, "library_page_sort_created", SortOrder.desc),
mostRecent(2, "sort_recent", SortOrder.desc),
mostOldest(5, "sort_oldest", SortOrder.asc);
final int storeIndex;
final String label;
final AlbumSortFn sortFn;
final SortOrder defaultOrder;
const AlbumSortMode(this.storeIndex, this.label, this.sortFn, this.defaultOrder);
const AlbumSortMode(this.storeIndex, this.label, this.defaultOrder);
SortOrder effectiveOrder(bool isReverse) => isReverse ? defaultOrder.reverse() : defaultOrder;
}
@riverpod
class AlbumSortByOptions extends _$AlbumSortByOptions {
@override
AlbumSortMode build() {
final sortOpt = ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.selectedAlbumSortOrder);
return AlbumSortMode.values.firstWhere((e) => e.storeIndex == sortOpt, orElse: () => AlbumSortMode.title);
}
void changeSortMode(AlbumSortMode sortOption) {
state = sortOption;
ref.watch(appSettingsServiceProvider).setSetting(AppSettingsEnum.selectedAlbumSortOrder, sortOption.storeIndex);
}
}
@riverpod
class AlbumSortOrder extends _$AlbumSortOrder {
@override
bool build() {
return ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.selectedAlbumSortReverse);
}
void changeSortDirection(bool isReverse) {
state = isReverse;
ref.watch(appSettingsServiceProvider).setSetting(AppSettingsEnum.selectedAlbumSortReverse, isReverse);
}
}
@@ -1,43 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'album_sort_by_options.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$albumSortByOptionsHash() =>
r'dd8da5e730af555de1b86c3b157b6c93183523ac';
/// See also [AlbumSortByOptions].
@ProviderFor(AlbumSortByOptions)
final albumSortByOptionsProvider =
AutoDisposeNotifierProvider<AlbumSortByOptions, AlbumSortMode>.internal(
AlbumSortByOptions.new,
name: r'albumSortByOptionsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$albumSortByOptionsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AlbumSortByOptions = AutoDisposeNotifier<AlbumSortMode>;
String _$albumSortOrderHash() => r'573dea45b4519e69386fc7104c72522e35713440';
/// See also [AlbumSortOrder].
@ProviderFor(AlbumSortOrder)
final albumSortOrderProvider =
AutoDisposeNotifierProvider<AlbumSortOrder, bool>.internal(
AlbumSortOrder.new,
name: r'albumSortOrderProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$albumSortOrderHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AlbumSortOrder = AutoDisposeNotifier<bool>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -1,74 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/models/albums/album_viewer_page_state.model.dart';
import 'package:immich_mobile/services/album.service.dart';
class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
AlbumViewerNotifier(this.ref)
: super(const AlbumViewerPageState(editTitleText: "", isEditAlbum: false, editDescriptionText: ""));
final Ref ref;
void enableEditAlbum() {
state = state.copyWith(isEditAlbum: true);
}
void disableEditAlbum() {
state = state.copyWith(isEditAlbum: false);
}
void setEditTitleText(String newTitle) {
state = state.copyWith(editTitleText: newTitle);
}
void setEditDescriptionText(String newDescription) {
state = state.copyWith(editDescriptionText: newDescription);
}
void remoteEditTitleText() {
state = state.copyWith(editTitleText: "");
}
void remoteEditDescriptionText() {
state = state.copyWith(editDescriptionText: "");
}
void resetState() {
state = state.copyWith(editTitleText: "", isEditAlbum: false, editDescriptionText: "");
}
Future<bool> changeAlbumTitle(Album album, String newAlbumTitle) async {
AlbumService service = ref.watch(albumServiceProvider);
bool isSuccess = await service.changeTitleAlbum(album, newAlbumTitle);
if (isSuccess) {
state = state.copyWith(editTitleText: "", isEditAlbum: false);
return true;
}
state = state.copyWith(editTitleText: "", isEditAlbum: false);
return false;
}
Future<bool> changeAlbumDescription(Album album, String newAlbumDescription) async {
AlbumService service = ref.watch(albumServiceProvider);
bool isSuccess = await service.changeDescriptionAlbum(album, newAlbumDescription);
if (isSuccess) {
state = state.copyWith(editDescriptionText: "", isEditAlbum: false);
return true;
}
state = state.copyWith(editDescriptionText: "", isEditAlbum: false);
return false;
}
}
final albumViewerProvider = StateNotifierProvider<AlbumViewerNotifier, AlbumViewerPageState>((ref) {
return AlbumViewerNotifier(ref);
});
@@ -1,15 +0,0 @@
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'current_album.provider.g.dart';
@riverpod
class CurrentAlbum extends _$CurrentAlbum {
@override
Album? build() => null;
void set(Album? a) => state = a;
}
/// Mock class for testing
abstract class CurrentAlbumInternal extends _$CurrentAlbum {}
@@ -1,14 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
final otherUsersProvider = FutureProvider.autoDispose<List<UserDto>>((ref) async {
UserService userService = ref.watch(userServiceProvider);
final currentUser = ref.watch(currentUserProvider);
final allUsers = await userService.getAll();
allUsers.removeWhere((u) => currentUser?.id == u.id);
return allUsers;
});
@@ -5,28 +5,17 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
enum AppLifeCycleEnum { active, inactive, paused, resumed, detached, hidden }
@@ -87,43 +76,15 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
final endpoint = await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint();
_log.info("Using server URL: $endpoint");
if (!Store.isBetaTimelineEnabled) {
final permission = _ref.watch(galleryPermissionNotifier);
if (permission.isGranted || permission.isLimited) {
await _ref.read(backupProvider.notifier).resumeBackup();
await _ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
}
}
await _ref.read(serverInfoProvider.notifier).getServerVersion();
}
if (!Store.isBetaTimelineEnabled) {
switch (_ref.read(tabProvider)) {
case TabEnum.home:
await _ref.read(assetProvider.notifier).getAllAsset();
case TabEnum.albums:
await _ref.read(albumProvider.notifier).refreshRemoteAlbums();
case TabEnum.library:
case TabEnum.search:
break;
}
} else {
_ref.read(websocketProvider.notifier).connect();
await _handleBetaTimelineResume();
}
_ref.read(websocketProvider.notifier).connect();
await _handleBetaTimelineResume();
await _ref.read(notificationPermissionProvider.notifier).getNotificationPermission();
await _ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus();
if (!Store.isBetaTimelineEnabled) {
await _ref.read(iOSBackgroundSettingsProvider.notifier).refresh();
_ref.invalidate(memoryFutureProvider);
}
}
Future<void> _safeRun(Future<void> action, String debugName) async {
@@ -139,7 +100,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
}
Future<void> _handleBetaTimelineResume() async {
_ref.read(backupProvider.notifier).cancelBackup();
unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock());
// Give isolates time to complete any ongoing database transactions
@@ -218,9 +178,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_pauseOperation = Completer<void>();
try {
if (Store.isBetaTimelineEnabled) {
unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock());
}
unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock());
await _performPause();
} catch (e, stackTrace) {
_log.severe("Error during app pause", e, stackTrace);
@@ -234,14 +192,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
Future<void> _performPause() {
if (_ref.read(authProvider).isAuthenticated) {
if (!Store.isBetaTimelineEnabled) {
// Do not cancel backup if manual upload is in progress
if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) {
_ref.read(backupProvider.notifier).cancelBackup();
}
} else {
_ref.read(driftBackupProvider.notifier).stopForegroundBackup();
}
_ref.read(driftBackupProvider.notifier).stopForegroundBackup();
_ref.read(websocketProvider.notifier).disconnect();
}
@@ -252,31 +203,12 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
Future<void> handleAppDetached() async {
state = AppLifeCycleEnum.detached;
if (Store.isBetaTimelineEnabled) {
unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock());
}
unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock());
// Flush logs before closing database
try {
await LogService.I.flush();
} catch (_) {}
// Close Isar database safely
try {
final isar = Isar.getInstance();
if (isar != null && isar.isOpen) {
await isar.close();
}
} catch (_) {}
if (Store.isBetaTimelineEnabled) {
return;
}
// no guarantee this is called at all
try {
_ref.read(manualUploadProvider.notifier).cancelBackup();
} catch (_) {}
}
void handleAppHidden() {
-182
View File
@@ -1,182 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/services/etag.service.dart';
import 'package:immich_mobile/services/exif.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final assetProvider = StateNotifierProvider<AssetNotifier, bool>((ref) {
return AssetNotifier(
ref.watch(assetServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(userServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(etagServiceProvider),
ref.watch(exifServiceProvider),
ref,
);
});
class AssetNotifier extends StateNotifier<bool> {
final AssetService _assetService;
final AlbumService _albumService;
final UserService _userService;
final SyncService _syncService;
final ETagService _etagService;
final ExifService _exifService;
final Ref _ref;
final log = Logger('AssetNotifier');
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;
AssetNotifier(
this._assetService,
this._albumService,
this._userService,
this._syncService,
this._etagService,
this._exifService,
this._ref,
) : super(false);
Future<void> getAllAsset({bool clear = false}) async {
if (_getAllAssetInProgress || _deleteInProgress) {
// guard against multiple calls to this method while it's still working
return;
}
final stopwatch = Stopwatch()..start();
try {
_getAllAssetInProgress = true;
state = true;
if (clear) {
await clearAllAssets();
log.info("Manual refresh requested, cleared assets and albums from db");
}
final users = await _syncService.getUsersFromServer();
bool changedUsers = false;
if (users != null) {
changedUsers = await _syncService.syncUsersFromServer(users);
}
final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums();
dPrint(() => "changedUsers: $changedUsers, newRemote: $newRemote, newLocal: $newLocal");
if (newRemote) {
_ref.invalidate(memoryFutureProvider);
}
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
} catch (error) {
// If there is error in getting the remote assets, still showing the new local assets
await _albumService.refreshDeviceAlbums();
} finally {
_getAllAssetInProgress = false;
if (mounted) {
state = false;
}
}
}
Future<void> clearAllAssets() async {
await Store.delete(StoreKey.assetETag);
await Future.wait([
_assetService.clearTable(),
_exifService.clearTable(),
_albumService.clearTable(),
_userService.deleteAll(),
_etagService.clearTable(),
]);
}
Future<void> onNewAssetUploaded(Asset newAsset) async {
// eTag on device is not valid after partially modifying the assets
await Store.delete(StoreKey.assetETag);
await _syncService.syncNewAssetToDb(newAsset);
}
Future<bool> deleteLocalAssets(List<Asset> assets) async {
_deleteInProgress = true;
state = true;
try {
await _assetService.deleteLocalAssets(assets);
return true;
} catch (error) {
log.severe("Failed to delete local assets", error);
return false;
} finally {
_deleteInProgress = false;
state = false;
}
}
/// Delete remote asset only
///
/// Default behavior is trashing the asset
Future<bool> deleteRemoteAssets(Iterable<Asset> deleteAssets, {bool shouldDeletePermanently = false}) async {
_deleteInProgress = true;
state = true;
try {
await _assetService.deleteRemoteAssets(deleteAssets, shouldDeletePermanently: shouldDeletePermanently);
return true;
} catch (error) {
log.severe("Failed to delete remote assets", error);
return false;
} finally {
_deleteInProgress = false;
state = false;
}
}
Future<bool> deleteAssets(Iterable<Asset> deleteAssets, {bool force = false}) async {
_deleteInProgress = true;
state = true;
try {
await _assetService.deleteAssets(deleteAssets, shouldDeletePermanently: force);
return true;
} catch (error) {
log.severe("Failed to delete assets", error);
return false;
} finally {
_deleteInProgress = false;
state = false;
}
}
Future<void> toggleFavorite(List<Asset> assets, [bool? status]) {
status ??= !assets.every((a) => a.isFavorite);
return _assetService.changeFavoriteStatus(assets, status);
}
Future<void> toggleArchive(List<Asset> assets, [bool? status]) {
status ??= !assets.every((a) => a.isArchived);
return _assetService.changeArchiveStatus(assets, status);
}
Future<void> setLockedView(List<Asset> selection, AssetVisibilityEnum visibility) {
return _assetService.setVisibility(selection, visibility);
}
}
final assetDetailProvider = StreamProvider.autoDispose.family<Asset, Asset>((ref, asset) async* {
final assetService = ref.watch(assetServiceProvider);
yield await assetService.loadExif(asset);
await for (final asset in assetService.watchAsset(asset.id)) {
if (asset != null) {
yield await ref.watch(assetServiceProvider).loadExif(asset);
}
}
});
final assetWatcher = StreamProvider.autoDispose.family<Asset?, Asset>((ref, asset) {
final assetService = ref.watch(assetServiceProvider);
return assetService.watchAsset(asset.id, fireImmediately: true);
});
@@ -1,49 +0,0 @@
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'asset_people.provider.g.dart';
/// Maintains the list of people for an asset.
@riverpod
class AssetPeopleNotifier extends _$AssetPeopleNotifier {
final log = Logger('AssetPeopleNotifier');
@override
Future<List<PersonWithFacesResponseDto>> build(Asset asset) async {
if (!asset.isRemote) {
return [];
}
final list = await ref.watch(assetServiceProvider).getRemotePeopleOfAsset(asset.remoteId!);
if (list == null) {
return [];
}
// explicitly a sorted slice to make it deterministic
// named people will be at the beginning, and names are sorted
// ascendingly
list.sort((a, b) {
final aNotEmpty = a.name.isNotEmpty;
final bNotEmpty = b.name.isNotEmpty;
if (aNotEmpty && !bNotEmpty) {
return -1;
} else if (!aNotEmpty && bNotEmpty) {
return 1;
} else if (!aNotEmpty && !bNotEmpty) {
return 0;
}
return a.name.compareTo(b.name);
});
return list;
}
Future<void> refresh() async {
// invalidate the state this way we don't have to
// duplicate the code from build.
ref.invalidateSelf();
}
}
@@ -1,192 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'asset_people.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$assetPeopleNotifierHash() =>
r'9835b180984a750c91e923e7b64dbda94f6d7574';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$AssetPeopleNotifier
extends
BuildlessAutoDisposeAsyncNotifier<List<PersonWithFacesResponseDto>> {
late final Asset asset;
FutureOr<List<PersonWithFacesResponseDto>> build(Asset asset);
}
/// Maintains the list of people for an asset.
///
/// Copied from [AssetPeopleNotifier].
@ProviderFor(AssetPeopleNotifier)
const assetPeopleNotifierProvider = AssetPeopleNotifierFamily();
/// Maintains the list of people for an asset.
///
/// Copied from [AssetPeopleNotifier].
class AssetPeopleNotifierFamily
extends Family<AsyncValue<List<PersonWithFacesResponseDto>>> {
/// Maintains the list of people for an asset.
///
/// Copied from [AssetPeopleNotifier].
const AssetPeopleNotifierFamily();
/// Maintains the list of people for an asset.
///
/// Copied from [AssetPeopleNotifier].
AssetPeopleNotifierProvider call(Asset asset) {
return AssetPeopleNotifierProvider(asset);
}
@override
AssetPeopleNotifierProvider getProviderOverride(
covariant AssetPeopleNotifierProvider provider,
) {
return call(provider.asset);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'assetPeopleNotifierProvider';
}
/// Maintains the list of people for an asset.
///
/// Copied from [AssetPeopleNotifier].
class AssetPeopleNotifierProvider
extends
AutoDisposeAsyncNotifierProviderImpl<
AssetPeopleNotifier,
List<PersonWithFacesResponseDto>
> {
/// Maintains the list of people for an asset.
///
/// Copied from [AssetPeopleNotifier].
AssetPeopleNotifierProvider(Asset asset)
: this._internal(
() => AssetPeopleNotifier()..asset = asset,
from: assetPeopleNotifierProvider,
name: r'assetPeopleNotifierProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$assetPeopleNotifierHash,
dependencies: AssetPeopleNotifierFamily._dependencies,
allTransitiveDependencies:
AssetPeopleNotifierFamily._allTransitiveDependencies,
asset: asset,
);
AssetPeopleNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.asset,
}) : super.internal();
final Asset asset;
@override
FutureOr<List<PersonWithFacesResponseDto>> runNotifierBuild(
covariant AssetPeopleNotifier notifier,
) {
return notifier.build(asset);
}
@override
Override overrideWith(AssetPeopleNotifier Function() create) {
return ProviderOverride(
origin: this,
override: AssetPeopleNotifierProvider._internal(
() => create()..asset = asset,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
asset: asset,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<
AssetPeopleNotifier,
List<PersonWithFacesResponseDto>
>
createElement() {
return _AssetPeopleNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is AssetPeopleNotifierProvider && other.asset == asset;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, asset.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin AssetPeopleNotifierRef
on AutoDisposeAsyncNotifierProviderRef<List<PersonWithFacesResponseDto>> {
/// The parameter `asset` of this provider.
Asset get asset;
}
class _AssetPeopleNotifierProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<
AssetPeopleNotifier,
List<PersonWithFacesResponseDto>
>
with AssetPeopleNotifierRef {
_AssetPeopleNotifierProviderElement(super.provider);
@override
Asset get asset => (origin as AssetPeopleNotifierProvider).asset;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -1,42 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'asset_stack.provider.g.dart';
class AssetStackNotifier extends StateNotifier<List<Asset>> {
final AssetService assetService;
final String _stackId;
AssetStackNotifier(this.assetService, this._stackId) : super([]) {
_fetchStack(_stackId);
}
void _fetchStack(String stackId) async {
if (!mounted) {
return;
}
final stack = await assetService.getStackAssets(stackId);
if (stack.isNotEmpty) {
state = stack;
}
}
void removeChild(int index) {
if (index < state.length) {
state.removeAt(index);
state = List<Asset>.from(state);
}
}
}
final assetStackStateProvider = StateNotifierProvider.autoDispose.family<AssetStackNotifier, List<Asset>, String>(
(ref, stackId) => AssetStackNotifier(ref.watch(assetServiceProvider), stackId),
);
@riverpod
int assetStackIndex(Ref _) {
return -1;
}
@@ -1,27 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'asset_stack.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$assetStackIndexHash() => r'086ddb782e3eb38b80d755666fe35be8fe7322d7';
/// See also [assetStackIndex].
@ProviderFor(assetStackIndex)
final assetStackIndexProvider = AutoDisposeProvider<int>.internal(
assetStackIndex,
name: r'assetStackIndexProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$assetStackIndexHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AssetStackIndexRef = AutoDisposeProviderRef<int>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -1,15 +0,0 @@
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'current_asset.provider.g.dart';
@riverpod
class CurrentAsset extends _$CurrentAsset {
@override
Asset? build() => null;
void set(Asset? a) => state = a;
}
/// Mock class for testing
abstract class CurrentAssetInternal extends _$CurrentAsset {}
@@ -1,26 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'current_asset.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$currentAssetHash() => r'2def10ea594152c984ae2974d687ab6856d7bdd0';
/// See also [CurrentAsset].
@ProviderFor(CurrentAsset)
final currentAssetProvider =
AutoDisposeNotifierProvider<CurrentAsset, Asset?>.internal(
CurrentAsset.new,
name: r'currentAssetProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$currentAssetHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$CurrentAsset = AutoDisposeNotifier<Asset?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -1,26 +1,15 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/download/download_state.model.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/share.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/share_dialog.dart';
class DownloadStateNotifier extends StateNotifier<DownloadState> {
final DownloadService _downloadService;
final ShareService _shareService;
final AlbumService _albumService;
DownloadStateNotifier(this._downloadService, this._shareService, this._albumService)
DownloadStateNotifier(this._downloadService)
: super(
const DownloadState(
downloadStatus: TaskStatus.complete,
@@ -132,18 +121,9 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
if (state.taskProgress.isEmpty) {
state = state.copyWith(showProgress: false);
}
_albumService.refreshDeviceAlbums();
});
}
Future<List<bool>> downloadAllAsset(List<Asset> assets) async {
return await _downloadService.downloadAll(assets);
}
void downloadAsset(Asset asset) async {
await _downloadService.download(asset);
}
void cancelDownload(String id) async {
final isCanceled = await _downloadService.cancelDownload(id);
@@ -159,36 +139,8 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
state = state.copyWith(showProgress: false);
}
}
void shareAsset(Asset asset, BuildContext context) async {
unawaited(
showDialog(
context: context,
builder: (BuildContext buildContext) {
_shareService.shareAsset(asset, context).then((bool status) {
if (!status) {
ImmichToast.show(
context: context,
msg: 'image_viewer_page_state_provider_share_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
buildContext.pop();
});
return const ShareDialog();
},
barrierDismissible: false,
useRootNavigator: false,
),
);
}
}
final downloadStateProvider = StateNotifierProvider<DownloadStateNotifier, DownloadState>(
((ref) => DownloadStateNotifier(
ref.watch(downloadServiceProvider),
ref.watch(shareServiceProvider),
ref.watch(albumServiceProvider),
)),
((ref) => DownloadStateNotifier(ref.watch(downloadServiceProvider))),
);
@@ -1,19 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
enum RenderListStatusEnum { complete, empty, error, loading }
final renderListStatusProvider = StateNotifierProvider<RenderListStatus, RenderListStatusEnum>((ref) {
return RenderListStatus(ref);
});
class RenderListStatus extends StateNotifier<RenderListStatusEnum> {
RenderListStatus(this.ref) : super(RenderListStatusEnum.complete);
final Ref ref;
RenderListStatusEnum get status => state;
set status(RenderListStatusEnum value) {
state = value;
}
}
@@ -1,672 +1,23 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/backup_album.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
import 'package:immich_mobile/utils/debug_print.dart';
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(
ref.watch(backupServiceProvider),
ref.watch(serverInfoServiceProvider),
ref.watch(authProvider),
ref.watch(backgroundServiceProvider),
ref.watch(galleryPermissionNotifier.notifier),
ref.watch(albumMediaRepositoryProvider),
ref.watch(fileMediaRepositoryProvider),
ref.watch(backupAlbumServiceProvider),
ref,
);
final backupProvider = StateNotifierProvider<BackupNotifier, ServerDiskInfo>((ref) {
return BackupNotifier(ref.watch(serverInfoServiceProvider));
});
class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier(
this._backupService,
this._serverInfoService,
this._authState,
this._backgroundService,
this._galleryPermissionNotifier,
this._albumMediaRepository,
this._fileMediaRepository,
this._backupAlbumService,
this.ref,
) : super(
BackUpState(
backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: const [],
progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
autoBackup: Store.get(StoreKey.autoBackup, false),
backgroundBackup: Store.get(StoreKey.backgroundBackup, false),
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
backupRequireCharging: Store.get(StoreKey.backupRequireCharging, false),
backupTriggerDelay: Store.get(StoreKey.backupTriggerDelay, 5000),
serverInfo: const ServerDiskInfo(diskAvailable: "0", diskSize: "0", diskUse: "0", diskUsagePercentage: 0),
availableAlbums: const [],
selectedBackupAlbums: const {},
excludedBackupAlbums: const {},
allUniqueAssets: const {},
selectedAlbumsBackupAssetsIds: const {},
currentUploadAsset: CurrentUploadAsset(
id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'),
fileName: '...',
fileType: '...',
fileSize: 0,
iCloudAsset: false,
),
iCloudDownloadProgress: 0.0,
),
);
class BackupNotifier extends StateNotifier<ServerDiskInfo> {
BackupNotifier(this._serverInfoService)
: super(const ServerDiskInfo(diskAvailable: "0", diskSize: "0", diskUse: "0", diskUsagePercentage: 0));
final log = Logger('BackupNotifier');
final BackupService _backupService;
final ServerInfoService _serverInfoService;
final AuthState _authState;
final BackgroundService _backgroundService;
final GalleryPermissionNotifier _galleryPermissionNotifier;
final AlbumMediaRepository _albumMediaRepository;
final FileMediaRepository _fileMediaRepository;
final BackupAlbumService _backupAlbumService;
final Ref ref;
Completer<void>? _cancelToken;
///
/// UI INTERACTION
///
/// Album selection
/// Due to the overlapping assets across multiple albums on the device
/// We have method to include and exclude albums
/// The total unique assets will be used for backing mechanism
///
void addAlbumForBackup(AvailableAlbum album) {
if (state.excludedBackupAlbums.contains(album)) {
removeExcludedAlbumForBackup(album);
}
state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
}
void addExcludedAlbumForBackup(AvailableAlbum album) {
if (state.selectedBackupAlbums.contains(album)) {
removeAlbumForBackup(album);
}
state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
}
void removeAlbumForBackup(AvailableAlbum album) {
Set<AvailableAlbum> currentSelectedAlbums = state.selectedBackupAlbums;
currentSelectedAlbums.removeWhere((a) => a == album);
state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums);
}
void removeExcludedAlbumForBackup(AvailableAlbum album) {
Set<AvailableAlbum> currentExcludedAlbums = state.excludedBackupAlbums;
currentExcludedAlbums.removeWhere((a) => a == album);
state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums);
}
Future<void> backupAlbumSelectionDone() {
if (state.selectedBackupAlbums.isEmpty) {
// disable any backup
cancelBackup();
setAutoBackup(false);
configureBackgroundBackup(enabled: false, onError: (msg) {}, onBatteryInfo: () {});
}
return _updateBackupAssetCount();
}
void setAutoBackup(bool enabled) {
Store.put(StoreKey.autoBackup, enabled);
state = state.copyWith(autoBackup: enabled);
}
void configureBackgroundBackup({
bool? enabled,
bool? requireWifi,
bool? requireCharging,
int? triggerDelay,
required void Function(String msg) onError,
required void Function() onBatteryInfo,
}) async {
assert(enabled != null || requireWifi != null || requireCharging != null || triggerDelay != null);
final bool wasEnabled = state.backgroundBackup;
final bool wasWifi = state.backupRequireWifi;
final bool wasCharging = state.backupRequireCharging;
final int oldTriggerDelay = state.backupTriggerDelay;
state = state.copyWith(
backgroundBackup: enabled,
backupRequireWifi: requireWifi,
backupRequireCharging: requireCharging,
backupTriggerDelay: triggerDelay,
);
if (state.backgroundBackup) {
bool success = true;
if (!wasEnabled) {
if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
onBatteryInfo();
}
success &= await _backgroundService.enableService(immediate: true);
}
success &=
success &&
await _backgroundService.configureService(
requireUnmetered: state.backupRequireWifi,
requireCharging: state.backupRequireCharging,
triggerUpdateDelay: state.backupTriggerDelay,
triggerMaxDelay: state.backupTriggerDelay * 10,
);
if (success) {
await Store.put(StoreKey.backupRequireWifi, state.backupRequireWifi);
await Store.put(StoreKey.backupRequireCharging, state.backupRequireCharging);
await Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay);
await Store.put(StoreKey.backgroundBackup, state.backgroundBackup);
} else {
state = state.copyWith(
backgroundBackup: wasEnabled,
backupRequireWifi: wasWifi,
backupRequireCharging: wasCharging,
backupTriggerDelay: oldTriggerDelay,
);
onError("backup_controller_page_background_configure_error");
}
} else {
final bool success = await _backgroundService.disableService();
if (!success) {
state = state.copyWith(backgroundBackup: wasEnabled);
onError("backup_controller_page_background_configure_error");
}
}
}
///
/// Get all album on the device
/// Get all selected and excluded album from the user's persistent storage
/// If this is the first time performing backup - set the default selected album to be
/// the one that has all assets (`Recent` on Android, `Recents` on iOS)
///
Future<void> _getBackupAlbumsInfo() async {
Stopwatch stopwatch = Stopwatch()..start();
// Get all albums on the device
List<AvailableAlbum> availableAlbums = [];
List<Album> albums = await _albumMediaRepository.getAll();
// Map of id -> album for quick album lookup later on.
Map<String, Album> albumMap = {};
log.info('Found ${albums.length} local albums');
for (Album album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(
album: album,
assetCount: await ref.read(albumMediaRepositoryProvider).getAssetCount(album.localId!),
);
availableAlbums.add(availableAlbum);
albumMap[album.localId!] = album;
}
state = state.copyWith(availableAlbums: availableAlbums);
final List<BackupAlbum> excludedBackupAlbums = await _backupAlbumService.getAllBySelection(BackupSelection.exclude);
final List<BackupAlbum> selectedBackupAlbums = await _backupAlbumService.getAllBySelection(BackupSelection.select);
final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
selectedAlbums.add(
AvailableAlbum(
album: albumAsset,
assetCount: await _albumMediaRepository.getAssetCount(albumAsset.localId!),
lastBackup: ba.lastBackup,
),
);
} else {
log.severe('Selected album not found');
}
}
final Set<AvailableAlbum> excludedAlbums = {};
for (final BackupAlbum ba in excludedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
excludedAlbums.add(
AvailableAlbum(
album: albumAsset,
assetCount: await ref.read(albumMediaRepositoryProvider).getAssetCount(albumAsset.localId!),
lastBackup: ba.lastBackup,
),
);
} else {
log.severe('Excluded album not found');
}
}
state = state.copyWith(selectedBackupAlbums: selectedAlbums, excludedBackupAlbums: excludedAlbums);
log.info("_getBackupAlbumsInfo: Found ${availableAlbums.length} available albums");
dPrint(() => "_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
}
///
/// From all the selected and albums assets
/// Find the assets that are not overlapping between the two sets
/// Those assets are unique and are used as the total assets
///
Future<void> _updateBackupAssetCount() async {
// Save to persistent storage
await _updatePersistentAlbumsSelection();
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
final Set<BackupCandidate> assetsFromSelectedAlbums = {};
final Set<BackupCandidate> assetsFromExcludedAlbums = {};
for (final album in state.selectedBackupAlbums) {
final assetCount = await ref.read(albumMediaRepositoryProvider).getAssetCount(album.album.localId!);
if (assetCount == 0) {
continue;
}
final assets = await ref.read(albumMediaRepositoryProvider).getAssets(album.album.localId!);
// Add album's name to the asset info
for (final asset in assets) {
List<String> albumNames = [album.name];
final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull((a) => a.asset.localId == asset.localId);
if (existingAsset != null) {
albumNames.addAll(existingAsset.albumNames);
assetsFromSelectedAlbums.remove(existingAsset);
}
assetsFromSelectedAlbums.add(BackupCandidate(asset: asset, albumNames: albumNames));
}
}
for (final album in state.excludedBackupAlbums) {
final assetCount = await ref.read(albumMediaRepositoryProvider).getAssetCount(album.album.localId!);
if (assetCount == 0) {
continue;
}
final assets = await ref.read(albumMediaRepositoryProvider).getAssets(album.album.localId!);
for (final asset in assets) {
assetsFromExcludedAlbums.add(BackupCandidate(asset: asset, albumNames: [album.name]));
}
}
final Set<BackupCandidate> allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
if (allAssetsInDatabase == null) {
return;
}
// Find asset that were backup from selected albums
final Set<String> selectedAlbumsBackupAssets = Set.from(allUniqueAssets.map((e) => e.asset.localId));
selectedAlbumsBackupAssets.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere((candidate) => duplicatedAssetIds.contains(candidate.asset.localId));
if (allUniqueAssets.isEmpty) {
log.info("No assets are selected for back up");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: {},
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
);
} else {
state = state.copyWith(
allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: allUniqueAssets,
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
);
}
}
/// Get all necessary information for calculating the available albums,
/// which albums are selected or excluded
/// and then update the UI according to those information
Future<void> getBackupInfo() async {
final isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled);
if (isEnabled != Store.get(StoreKey.backgroundBackup, !isEnabled)) {
await Store.put(StoreKey.backgroundBackup, isEnabled);
}
if (state.backupProgress != BackUpProgressEnum.inBackground) {
await _getBackupAlbumsInfo();
await updateDiskInfo();
await _updateBackupAssetCount();
} else {
log.warning("cannot get backup info - background backup is in progress!");
}
}
/// Save user selection of selected albums and excluded albums to database
Future<void> _updatePersistentAlbumsSelection() async {
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
final selected = state.selectedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select),
);
final excluded = state.excludedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude),
);
final candidates = selected.followedBy(excluded).toList();
candidates.sortBy((e) => e.id);
final savedBackupAlbums = await _backupAlbumService.getAll(sort: BackupAlbumSort.id);
final List<int> toDelete = [];
final List<BackupAlbum> toUpsert = [];
diffSortedListsSync(
savedBackupAlbums,
candidates,
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
both: (BackupAlbum a, BackupAlbum b) {
b.lastBackup = a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup;
toUpsert.add(b);
return true;
},
onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId),
onlySecond: (BackupAlbum b) => toUpsert.add(b),
);
await _backupAlbumService.deleteAll(toDelete);
await _backupAlbumService.updateAll(toUpsert);
}
/// Invoke backup process
Future<void> startBackupProcess() async {
dPrint(() => "Start backup process");
assert(state.backupProgress == BackUpProgressEnum.idle);
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
await getBackupInfo();
final hasPermission = _galleryPermissionNotifier.hasPermission;
if (hasPermission) {
await _fileMediaRepository.clearFileCache();
if (state.allUniqueAssets.isEmpty) {
log.info("No Asset On Device - Abort Backup Process");
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
return;
}
Set<BackupCandidate> assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up
for (final assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.asset.localId == assetId);
}
if (assetsWillBeBackup.isEmpty) {
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
}
// Perform Backup
_cancelToken?.complete();
_cancelToken = Completer<void>();
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
pmProgressHandler?.stream.listen((event) {
final double progress = event.progress;
state = state.copyWith(iCloudDownloadProgress: progress);
});
await _backupService.backupAsset(
assetsWillBeBackup,
_cancelToken!,
pmProgressHandler: pmProgressHandler,
onSuccess: _onAssetUploaded,
onProgress: _onUploadProgress,
onCurrentAsset: _onSetCurrentBackupAsset,
onError: _onBackupError,
);
await notifyBackgroundServiceCanRun();
} else {
await openAppSettings();
}
}
void setAvailableAlbums(availableAlbums) {
state = state.copyWith(availableAlbums: availableAlbums);
}
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
}
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
state = state.copyWith(currentUploadAsset: currentUploadAsset);
}
void cancelBackup() {
if (state.backupProgress != BackUpProgressEnum.inProgress) {
notifyBackgroundServiceCanRun();
}
_cancelToken?.complete();
_cancelToken = null;
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
progressInPercentage: 0.0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
);
}
void _onAssetUploaded(SuccessUploadAsset result) async {
if (result.isDuplicate) {
state = state.copyWith(
allUniqueAssets: state.allUniqueAssets
.where((candidate) => candidate.asset.localId != result.candidate.asset.localId)
.toSet(),
);
} else {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {...state.selectedAlbumsBackupAssetsIds, result.candidate.asset.localId!},
allAssetsInDatabase: [...state.allAssetsInDatabase, result.candidate.asset.localId!],
);
}
if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) {
final latestAssetBackup = state.allUniqueAssets
.map((candidate) => candidate.asset.fileModifiedAt)
.reduce((v, e) => e.isAfter(v) ? e : v);
state = state.copyWith(
selectedBackupAlbums: state.selectedBackupAlbums.map((e) => e.copyWith(lastBackup: latestAssetBackup)).toSet(),
excludedBackupAlbums: state.excludedBackupAlbums.map((e) => e.copyWith(lastBackup: latestAssetBackup)).toSet(),
backupProgress: BackUpProgressEnum.done,
progressInPercentage: 0.0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
);
await _updatePersistentAlbumsSelection();
}
await updateDiskInfo();
}
void _onUploadProgress(int sent, int total) {
double lastUploadSpeed = state.progressInFileSpeed;
List<double> lastUploadSpeeds = state.progressInFileSpeeds.toList();
DateTime lastUpdateTime = state.progressInFileSpeedUpdateTime;
int lastSentBytes = state.progressInFileSpeedUpdateSentBytes;
final now = DateTime.now();
final duration = now.difference(lastUpdateTime);
// Keep the upload speed average span limited, to keep it somewhat relevant
if (lastUploadSpeeds.length > 10) {
lastUploadSpeeds.removeAt(0);
}
if (duration.inSeconds > 0) {
lastUploadSpeeds.add(((sent - lastSentBytes) / duration.inSeconds).abs().roundToDouble());
lastUploadSpeed = lastUploadSpeeds.average.abs().roundToDouble();
lastUpdateTime = now;
lastSentBytes = sent;
}
state = state.copyWith(
progressInPercentage: (sent.toDouble() / total.toDouble() * 100),
progressInFileSize: humanReadableFileBytesProgress(sent, total),
progressInFileSpeed: lastUploadSpeed,
progressInFileSpeeds: lastUploadSpeeds,
progressInFileSpeedUpdateTime: lastUpdateTime,
progressInFileSpeedUpdateSentBytes: lastSentBytes,
);
}
Future<void> updateDiskInfo() async {
final diskInfo = await _serverInfoService.getDiskInfo();
// Update server info
if (diskInfo != null) {
state = state.copyWith(serverInfo: diskInfo);
state = diskInfo;
}
}
Future<void> _resumeBackup() async {
// Check if user is login
final accessKey = Store.tryGet(StoreKey.accessToken);
// User has been logged out return
if (accessKey == null || !_authState.isAuthenticated) {
log.info("[_resumeBackup] not authenticated - abort");
return;
}
// Check if this device is enable backup by the user
if (state.autoBackup) {
// check if backup is already in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
log.info("[_resumeBackup] Auto Backup is already in progress - abort");
return;
}
if (state.backupProgress == BackUpProgressEnum.inBackground) {
log.info("[_resumeBackup] Background backup is running - abort");
return;
}
if (state.backupProgress == BackUpProgressEnum.manualInProgress) {
log.info("[_resumeBackup] Manual upload is running - abort");
return;
}
// Run backup
log.info("[_resumeBackup] Start back up");
await startBackupProcess();
}
return;
}
Future<void> resumeBackup() async {
final List<BackupAlbum> selectedBackupAlbums = await _backupAlbumService.getAllBySelection(BackupSelection.select);
final List<BackupAlbum> excludedBackupAlbums = await _backupAlbumService.getAllBySelection(BackupSelection.exclude);
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
if (selectedAlbums.isNotEmpty) {
selectedAlbums = _updateAlbumsBackupTime(selectedAlbums, selectedBackupAlbums);
}
if (excludedAlbums.isNotEmpty) {
excludedAlbums = _updateAlbumsBackupTime(excludedAlbums, excludedBackupAlbums);
}
final BackUpProgressEnum previous = state.backupProgress;
state = state.copyWith(
backupProgress: BackUpProgressEnum.inBackground,
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
// assumes the background service is currently running
// if true, waits until it has stopped to start the backup
final bool hasLock = await _backgroundService.acquireLock();
if (hasLock) {
state = state.copyWith(backupProgress: previous);
}
return _resumeBackup();
}
Set<AvailableAlbum> _updateAlbumsBackupTime(Set<AvailableAlbum> albums, List<BackupAlbum> backupAlbums) {
Set<AvailableAlbum> result = {};
for (BackupAlbum ba in backupAlbums) {
try {
AvailableAlbum a = albums.firstWhere((e) => e.id == ba.id);
result.add(a.copyWith(lastBackup: ba.lastBackup));
} on StateError {
log.severe("[_updateAlbumBackupTime] failed to find album in state", "State Error", StackTrace.current);
}
}
return result;
}
Future<void> notifyBackgroundServiceCanRun() async {
const allowedStates = [AppLifeCycleEnum.inactive, AppLifeCycleEnum.paused, AppLifeCycleEnum.detached];
if (allowedStates.contains(ref.read(appStateProvider.notifier).state)) {
_backgroundService.releaseLock();
}
}
BackUpProgressEnum get backupProgress => state.backupProgress;
void updateBackupProgress(BackUpProgressEnum backupProgress) {
state = state.copyWith(backupProgress: backupProgress);
}
}
@@ -1,101 +0,0 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/services/backup_verification.service.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
part 'backup_verification.provider.g.dart';
@riverpod
class BackupVerification extends _$BackupVerification {
@override
bool build() => false;
void performBackupCheck(BuildContext context) async {
try {
state = true;
final backupState = ref.read(backupProvider);
if (backupState.allUniqueAssets.length > backupState.selectedAlbumsBackupAssetsIds.length) {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "Backup all assets before starting this check!",
toastType: ToastType.error,
);
}
return;
}
final connection = await Connectivity().checkConnectivity();
if (!connection.contains(ConnectivityResult.wifi)) {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "Make sure to be connected to unmetered Wi-Fi",
toastType: ToastType.error,
);
}
return;
}
unawaited(WakelockPlus.enable());
const limit = 100;
final toDelete = await ref.read(backupVerificationServiceProvider).findWronglyBackedUpAssets(limit: limit);
if (toDelete.isEmpty) {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "Did not find any corrupt asset backups!",
toastType: ToastType.success,
);
}
} else {
if (context.mounted) {
await showDialog(
context: context,
builder: (ctx) => ConfirmDialog(
onOk: () => _performDeletion(context, toDelete),
title: "Corrupt backups!",
ok: "Delete",
content:
"Found ${toDelete.length} (max $limit at once) corrupt asset backups. "
"Run the check again to find more.\n"
"Do you want to delete the corrupt asset backups now?",
),
);
}
}
} finally {
unawaited(WakelockPlus.disable());
state = false;
}
}
Future<void> _performDeletion(BuildContext context, List<Asset> assets) async {
try {
state = true;
if (context.mounted) {
ImmichToast.show(context: context, msg: "Deleting ${assets.length} assets on the server...");
}
await ref.read(assetProvider.notifier).deleteAssets(assets, force: true);
if (context.mounted) {
ImmichToast.show(
context: context,
msg:
"Deleted ${assets.length} assets on the server. "
"You can now start a manual backup",
toastType: ToastType.success,
);
}
} finally {
state = false;
}
}
}
@@ -1,27 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'backup_verification.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$backupVerificationHash() =>
r'b4b34909ed1af3f28877ea457d53a4a18b6417f8';
/// See also [BackupVerification].
@ProviderFor(BackupVerification)
final backupVerificationProvider =
AutoDisposeNotifierProvider<BackupVerification, bool>.internal(
BackupVerification.new,
name: r'backupVerificationProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$backupVerificationHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$BackupVerification = AutoDisposeNotifier<bool>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -1,22 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
class ErrorBackupListNotifier extends StateNotifier<Set<ErrorUploadAsset>> {
ErrorBackupListNotifier() : super({});
add(ErrorUploadAsset errorAsset) {
state = state.union({errorAsset});
}
remove(ErrorUploadAsset errorAsset) {
state = state.difference({errorAsset});
}
empty() {
state = {};
}
}
final errorBackupListProvider = StateNotifierProvider<ErrorBackupListNotifier, Set<ErrorUploadAsset>>(
(ref) => ErrorBackupListNotifier(),
);
@@ -1,54 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/background.service.dart';
class IOSBackgroundSettings {
final bool appRefreshEnabled;
final int numberOfBackgroundTasksQueued;
final DateTime? timeOfLastFetch;
final DateTime? timeOfLastProcessing;
const IOSBackgroundSettings({
required this.appRefreshEnabled,
required this.numberOfBackgroundTasksQueued,
this.timeOfLastFetch,
this.timeOfLastProcessing,
});
}
class IOSBackgroundSettingsNotifier extends StateNotifier<IOSBackgroundSettings?> {
final BackgroundService _service;
IOSBackgroundSettingsNotifier(this._service) : super(null);
IOSBackgroundSettings? get settings => state;
Future<IOSBackgroundSettings> refresh() async {
final lastFetchTime = await _service.getIOSBackupLastRun(IosBackgroundTask.fetch);
final lastProcessingTime = await _service.getIOSBackupLastRun(IosBackgroundTask.processing);
int numberOfProcesses = await _service.getIOSBackupNumberOfProcesses();
final appRefreshEnabled = await _service.getIOSBackgroundAppRefreshEnabled();
// If this is enabled and there are no background processes,
// the user just enabled app refresh in Settings.
// But we don't have any background services running, since it was disabled
// before.
if (await _service.isBackgroundBackupEnabled() && numberOfProcesses == 0) {
// We need to restart the background service
await _service.enableService();
numberOfProcesses = await _service.getIOSBackupNumberOfProcesses();
}
final settings = IOSBackgroundSettings(
appRefreshEnabled: appRefreshEnabled,
numberOfBackgroundTasksQueued: numberOfProcesses,
timeOfLastFetch: lastFetchTime,
timeOfLastProcessing: lastProcessingTime,
);
state = settings;
return settings;
}
}
final iOSBackgroundSettingsProvider = StateNotifierProvider<IOSBackgroundSettingsNotifier, IOSBackgroundSettings?>(
(ref) => IOSBackgroundSettingsNotifier(ref.watch(backgroundServiceProvider)),
);
@@ -1,391 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/manual_upload_state.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/backup_album.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final manualUploadProvider = StateNotifierProvider<ManualUploadNotifier, ManualUploadState>((ref) {
return ManualUploadNotifier(
ref.watch(localNotificationService),
ref.watch(backupProvider.notifier),
ref.watch(backupServiceProvider),
ref.watch(backupAlbumServiceProvider),
ref,
);
});
class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
final Logger _log = Logger("ManualUploadNotifier");
final LocalNotificationService _localNotificationService;
final BackupNotifier _backupProvider;
final BackupService _backupService;
final BackupAlbumService _backupAlbumService;
final Ref ref;
Completer<void>? _cancelToken;
ManualUploadNotifier(
this._localNotificationService,
this._backupProvider,
this._backupService,
this._backupAlbumService,
this.ref,
) : super(
ManualUploadState(
progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
currentUploadAsset: CurrentUploadAsset(
id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'),
fileName: '...',
fileType: '...',
),
totalAssetsToUpload: 0,
successfulUploads: 0,
currentAssetIndex: 0,
showDetailedNotification: false,
),
);
String _lastPrintedDetailContent = '';
String? _lastPrintedDetailTitle;
static const notifyInterval = Duration(milliseconds: 500);
late final ThrottleProgressUpdate _throttledNotifiy = ThrottleProgressUpdate(_updateProgress, notifyInterval);
late final ThrottleProgressUpdate _throttledDetailNotify = ThrottleProgressUpdate(
_updateDetailProgress,
notifyInterval,
);
void _updateProgress(String? title, int progress, int total) {
// Guard against throttling calling this method after the upload is done
if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
_localNotificationService.showOrUpdateManualUploadStatus(
"backup_background_service_in_progress_notification".tr(),
formatAssetBackupProgress(state.currentAssetIndex, state.totalAssetsToUpload),
maxProgress: state.totalAssetsToUpload,
progress: state.currentAssetIndex,
showActions: true,
);
}
}
void _updateDetailProgress(String? title, int progress, int total) {
// Guard against throttling calling this method after the upload is done
if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
final String msg = total > 0 ? humanReadableBytesProgress(progress, total) : "";
// only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
if (msg != _lastPrintedDetailContent || title != _lastPrintedDetailTitle) {
_lastPrintedDetailContent = msg;
_lastPrintedDetailTitle = title;
_localNotificationService.showOrUpdateManualUploadStatus(
title ?? 'Uploading',
msg,
progress: total > 0 ? (progress * 1000) ~/ total : 0,
maxProgress: 1000,
isDetailed: true,
// Detailed noitifcation is displayed for Single asset uploads. Show actions for such case
showActions: state.totalAssetsToUpload == 1,
);
}
}
}
void _onAssetUploaded(SuccessUploadAsset result) {
state = state.copyWith(successfulUploads: state.successfulUploads + 1);
_backupProvider.updateDiskInfo();
}
void _onAssetUploadError(ErrorUploadAsset errorAssetInfo) {
ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
}
void _onProgress(int sent, int total) {
double lastUploadSpeed = state.progressInFileSpeed;
List<double> lastUploadSpeeds = state.progressInFileSpeeds.toList();
DateTime lastUpdateTime = state.progressInFileSpeedUpdateTime;
int lastSentBytes = state.progressInFileSpeedUpdateSentBytes;
final now = DateTime.now();
final duration = now.difference(lastUpdateTime);
// Keep the upload speed average span limited, to keep it somewhat relevant
if (lastUploadSpeeds.length > 10) {
lastUploadSpeeds.removeAt(0);
}
if (duration.inSeconds > 0) {
lastUploadSpeeds.add(((sent - lastSentBytes) / duration.inSeconds).abs().roundToDouble());
lastUploadSpeed = lastUploadSpeeds.average.abs().roundToDouble();
lastUpdateTime = now;
lastSentBytes = sent;
}
state = state.copyWith(
progressInPercentage: (sent.toDouble() / total.toDouble() * 100),
progressInFileSize: humanReadableFileBytesProgress(sent, total),
progressInFileSpeed: lastUploadSpeed,
progressInFileSpeeds: lastUploadSpeeds,
progressInFileSpeedUpdateTime: lastUpdateTime,
progressInFileSpeedUpdateSentBytes: lastSentBytes,
);
if (state.showDetailedNotification) {
final title = "backup_background_service_current_upload_notification".tr(
namedArgs: {'filename': state.currentUploadAsset.fileName},
);
_throttledDetailNotify(title: title, progress: sent, total: total);
}
}
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
state = state.copyWith(currentUploadAsset: currentUploadAsset, currentAssetIndex: state.currentAssetIndex + 1);
if (state.totalAssetsToUpload > 1) {
_throttledNotifiy();
}
if (state.showDetailedNotification) {
_throttledDetailNotify.title = "backup_background_service_current_upload_notification".tr(
namedArgs: {'filename': currentUploadAsset.fileName},
);
_throttledDetailNotify.progress = 0;
_throttledDetailNotify.total = 0;
}
}
Future<bool> _startUpload(Iterable<Asset> allManualUploads) async {
bool hasErrors = false;
try {
_backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
await ref.read(fileMediaRepositoryProvider).clearFileCache();
final allAssetsFromDevice = allManualUploads.where((e) => e.isLocal && !e.isRemote).toList();
if (allAssetsFromDevice.length != allManualUploads.length) {
_log.warning(
'[_startUpload] Refreshed upload list -> ${allManualUploads.length - allAssetsFromDevice.length} asset will not be uploaded',
);
}
final selectedBackupAlbums = await _backupAlbumService.getAllBySelection(BackupSelection.select);
final excludedBackupAlbums = await _backupAlbumService.getAllBySelection(BackupSelection.exclude);
// Get candidates from selected albums and excluded albums
Set<BackupCandidate> candidates = await _backupService.buildUploadCandidates(
selectedBackupAlbums,
excludedBackupAlbums,
useTimeFilter: false,
);
// Extrack candidate from allAssetsFromDevice
final uploadAssets = candidates.where(
(candidate) =>
allAssetsFromDevice.firstWhereOrNull((asset) => asset.localId == candidate.asset.localId) != null,
);
if (uploadAssets.isEmpty) {
dPrint(() => "[_startUpload] No Assets to upload - Abort Process");
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
return false;
}
state = state.copyWith(
progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
totalAssetsToUpload: uploadAssets.length,
successfulUploads: 0,
currentAssetIndex: 0,
currentUploadAsset: CurrentUploadAsset(
id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'),
fileName: '...',
fileType: '...',
),
);
// Reset Error List
ref.watch(errorBackupListProvider.notifier).empty();
if (state.totalAssetsToUpload > 1) {
_throttledNotifiy();
}
// Show detailed asset if enabled in settings or if a single asset is uploaded
bool showDetailedNotification =
ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress) ||
state.totalAssetsToUpload == 1;
state = state.copyWith(showDetailedNotification: showDetailedNotification);
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
_cancelToken?.complete();
_cancelToken = Completer<void>();
final bool ok = await ref
.read(backupServiceProvider)
.backupAsset(
uploadAssets,
_cancelToken!,
pmProgressHandler: pmProgressHandler,
onSuccess: _onAssetUploaded,
onProgress: _onProgress,
onCurrentAsset: _onSetCurrentBackupAsset,
onError: _onAssetUploadError,
);
// Close detailed notification
await _localNotificationService.closeNotification(LocalNotificationService.manualUploadDetailedNotificationID);
_log.info(
'[_startUpload] Manual Upload Completed - success: ${state.successfulUploads},'
' failed: ${state.totalAssetsToUpload - state.successfulUploads}',
);
// User cancelled upload
if (!ok && _cancelToken == null) {
await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(),
"backup_manual_cancelled".tr(),
presentBanner: true,
);
hasErrors = true;
} else if (state.successfulUploads == 0 || (!ok && _cancelToken != null)) {
await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(),
"failed".tr(),
presentBanner: true,
);
hasErrors = true;
} else {
await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(),
"backup_manual_success".tr(),
presentBanner: true,
);
}
} else {
unawaited(openAppSettings());
dPrint(() => "[_startUpload] Do not have permission to the gallery");
}
} catch (e) {
dPrint(() => "ERROR _startUpload: ${e.toString()}");
hasErrors = true;
} finally {
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
_handleAppInActivity();
await _localNotificationService.closeNotification(LocalNotificationService.manualUploadDetailedNotificationID);
await _backupProvider.notifyBackgroundServiceCanRun();
}
return !hasErrors;
}
void _handleAppInActivity() {
final appState = ref.read(appStateProvider.notifier).getAppState();
// The app is currently in background. Perform the necessary cleanups which
// are on-hold for upload completion
if (appState != AppLifeCycleEnum.active && appState != AppLifeCycleEnum.resumed) {
ref.read(backupProvider.notifier).cancelBackup();
}
}
void cancelBackup() {
if (_backupProvider.backupProgress != BackUpProgressEnum.inProgress &&
_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.notifyBackgroundServiceCanRun();
}
_cancelToken?.complete();
_cancelToken = null;
if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
}
state = state.copyWith(
progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
);
}
Future<bool> uploadAssets(BuildContext context, Iterable<Asset> allManualUploads) async {
// assumes the background service is currently running and
// waits until it has stopped to start the backup.
final bool hasLock = await ref.read(backgroundServiceProvider).acquireLock();
if (!hasLock) {
dPrint(() => "[uploadAssets] could not acquire lock, exiting");
ImmichToast.show(
context: context,
msg: "failed".tr(),
toastType: ToastType.info,
gravity: ToastGravity.BOTTOM,
durationInSecond: 3,
);
return false;
}
bool showInProgress = false;
// check if backup is already in process - then return
if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
dPrint(() => "[uploadAssets] Manual upload is already running - abort");
showInProgress = true;
}
if (_backupProvider.backupProgress == BackUpProgressEnum.inProgress) {
dPrint(() => "[uploadAssets] Auto Backup is already in progress - abort");
showInProgress = true;
return false;
}
if (_backupProvider.backupProgress == BackUpProgressEnum.inBackground) {
dPrint(() => "[uploadAssets] Background backup is running - abort");
showInProgress = true;
}
if (showInProgress) {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "backup_manual_in_progress".tr(),
toastType: ToastType.info,
gravity: ToastGravity.BOTTOM,
durationInSecond: 3,
);
}
return false;
}
return _startUpload(allManualUploads);
}
}
-21
View File
@@ -1,6 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart' as old_asset_entity;
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/services/gcast.service.dart';
@@ -55,26 +54,6 @@ class CastNotifier extends StateNotifier<CastManagerState> {
_gCastService.loadMedia(asset, reload);
}
// TODO: remove this when we migrate to new timeline
void loadMediaOld(old_asset_entity.Asset asset, bool reload) {
final remoteAsset = RemoteAsset(
id: asset.remoteId.toString(),
name: asset.name,
ownerId: asset.ownerId.toString(),
checksum: asset.checksum,
type: asset.type == old_asset_entity.AssetType.image
? AssetType.image
: asset.type == old_asset_entity.AssetType.video
? AssetType.video
: AssetType.other,
createdAt: asset.fileCreatedAt,
updatedAt: asset.updatedAt,
isEdited: false,
);
_gCastService.loadMedia(remoteAsset, reload);
}
Future<void> connect(CastDestinationType type, dynamic device) async {
switch (type) {
case CastDestinationType.googleCast:
-5
View File
@@ -1,5 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:isar/isar.dart';
// overwritten in main.dart due to async loading
final dbProvider = Provider<Isar>((_) => throw UnimplementedError());
@@ -1,5 +0,0 @@
/// An exception for the [ImageLoader] and the Immich image providers
class ImageLoadingException implements Exception {
final String message;
const ImageLoadingException(this.message);
}
@@ -19,16 +19,12 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/timeline.service.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
final actionProvider = NotifierProvider<ActionNotifier, void>(
ActionNotifier.new,
dependencies: [multiSelectProvider, timelineServiceProvider],
);
final actionProvider = NotifierProvider<ActionNotifier, void>(ActionNotifier.new, dependencies: [multiSelectProvider]);
class ActionResult {
final int count;
@@ -2,13 +2,6 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:isar/isar.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'db.provider.g.dart';
@Riverpod(keepAlive: true)
Isar isar(Ref ref) => throw UnimplementedError('isar');
Drift Function(Ref ref) driftOverride(Drift drift) => (ref) {
ref.onDispose(() => unawaited(drift.close()));
-27
View File
@@ -1,27 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'db.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$isarHash() => r'69d3a06aa7e69a4381478e03f7956eb07d7f7feb';
/// See also [isar].
@ProviderFor(isar)
final isarProvider = Provider<Isar>.internal(
isar,
name: r'isarProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$isarHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef IsarRef = ProviderRef<Isar>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -1,7 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/device_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final deviceAssetRepositoryProvider = Provider<IsarDeviceAssetRepository>(
(ref) => IsarDeviceAssetRepository(ref.watch(isarProvider)),
);
@@ -1,9 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'exif.provider.g.dart';
@Riverpod(keepAlive: true)
IsarExifRepository exifRepository(Ref ref) => IsarExifRepository(ref.watch(isarProvider));
-27
View File
@@ -1,27 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'exif.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$exifRepositoryHash() => r'bf4a3f6a50d954a23d317659b4f3e2f381066463';
/// See also [exifRepository].
@ProviderFor(exifRepository)
final exifRepositoryProvider = Provider<IsarExifRepository>.internal(
exifRepository,
name: r'exifRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$exifRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ExifRepositoryRef = ProviderRef<IsarExifRepository>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -1,13 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'store.provider.g.dart';
@Riverpod(keepAlive: true)
IsarStoreRepository storeRepository(Ref ref) => IsarStoreRepository(ref.watch(isarProvider));
@Riverpod(keepAlive: true)
StoreService storeService(Ref _) => StoreService.I;
@@ -6,23 +6,6 @@ part of 'store.provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$storeRepositoryHash() => r'659cb134466e4b0d5f04e2fc93e426350d99545f';
/// See also [storeRepository].
@ProviderFor(storeRepository)
final storeRepositoryProvider = Provider<IsarStoreRepository>.internal(
storeRepository,
name: r'storeRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$storeRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef StoreRepositoryRef = ProviderRef<IsarStoreRepository>;
String _$storeServiceHash() => r'250e10497c42df360e9e1f9a618d0b19c1b5b0a0';
/// See also [storeService].
@@ -3,7 +3,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
@@ -14,18 +13,12 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'user.provider.g.dart';
@Riverpod(keepAlive: true)
IsarUserRepository userRepository(Ref ref) => IsarUserRepository(ref.watch(isarProvider));
@Riverpod(keepAlive: true)
UserApiRepository userApiRepository(Ref ref) => UserApiRepository(ref.watch(apiServiceProvider).usersApi);
@Riverpod(keepAlive: true)
UserService userService(Ref ref) => UserService(
isarUserRepository: ref.watch(userRepositoryProvider),
userApiRepository: ref.watch(userApiRepositoryProvider),
storeService: ref.watch(storeServiceProvider),
);
UserService userService(Ref ref) =>
UserService(userApiRepository: ref.watch(userApiRepositoryProvider), storeService: ref.watch(storeServiceProvider));
/// Drifts
final driftPartnerRepositoryProvider = Provider<DriftPartnerRepository>(
+1 -18
View File
@@ -6,23 +6,6 @@ part of 'user.provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$userRepositoryHash() => r'538791a4ad126ed086c9db682c67fc5c654d54f3';
/// See also [userRepository].
@ProviderFor(userRepository)
final userRepositoryProvider = Provider<IsarUserRepository>.internal(
userRepository,
name: r'userRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$userRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef UserRepositoryRef = ProviderRef<IsarUserRepository>;
String _$userApiRepositoryHash() => r'8a7340ca4544c8c6b20225c65bff2abb9e96baa2';
/// See also [userApiRepository].
@@ -40,7 +23,7 @@ final userApiRepositoryProvider = Provider<UserApiRepository>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef UserApiRepositoryRef = ProviderRef<UserApiRepository>;
String _$userServiceHash() => r'181414dddc7891be6237e13d568c287a804228d1';
String _$userServiceHash() => r'47e607f3b484b51bcb634d47e3cbf1f6ef25da97';
/// See also [userService].
@ProviderFor(userService)
@@ -1,9 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/services/memory.service.dart';
final memoryFutureProvider = FutureProvider.autoDispose<List<Memory>?>((ref) async {
final service = ref.watch(memoryServiceProvider);
return await service.getMemoryLane();
});
@@ -1,89 +0,0 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
import 'package:immich_mobile/services/partner.service.dart';
class PartnerSharedWithNotifier extends StateNotifier<List<UserDto>> {
final PartnerService _partnerService;
late final StreamSubscription<List<UserDto>> streamSub;
PartnerSharedWithNotifier(this._partnerService) : super([]) {
Function eq = const ListEquality<UserDto>().equals;
_partnerService
.getSharedWith()
.then((partners) {
if (!eq(state, partners)) {
state = partners;
}
})
.then((_) {
streamSub = _partnerService.watchSharedWith().listen((partners) {
if (!eq(state, partners)) {
state = partners;
}
});
});
}
Future<bool> updatePartner(UserDto partner, {required bool inTimeline}) {
return _partnerService.updatePartner(partner, inTimeline: inTimeline);
}
@override
void dispose() {
if (mounted) {
streamSub.cancel();
}
super.dispose();
}
}
final partnerSharedWithProvider = StateNotifierProvider<PartnerSharedWithNotifier, List<UserDto>>((ref) {
return PartnerSharedWithNotifier(ref.watch(partnerServiceProvider));
});
class PartnerSharedByNotifier extends StateNotifier<List<UserDto>> {
final PartnerService _partnerService;
late final StreamSubscription<List<UserDto>> streamSub;
PartnerSharedByNotifier(this._partnerService) : super([]) {
Function eq = const ListEquality<UserDto>().equals;
_partnerService
.getSharedBy()
.then((partners) {
if (!eq(state, partners)) {
state = partners;
}
})
.then((_) {
streamSub = _partnerService.watchSharedBy().listen((partners) {
if (!eq(state, partners)) {
state = partners;
}
});
});
}
@override
void dispose() {
if (mounted) {
streamSub.cancel();
}
super.dispose();
}
}
final partnerSharedByProvider = StateNotifierProvider<PartnerSharedByNotifier, List<UserDto>>((ref) {
return PartnerSharedByNotifier(ref.watch(partnerServiceProvider));
});
final partnerAvailableProvider = FutureProvider.autoDispose<List<UserDto>>((ref) async {
final otherUsers = await ref.watch(otherUsersProvider.future);
final currentPartners = ref.watch(partnerSharedByProvider);
final available = Set<UserDto>.of(otherUsers);
available.removeAll(currentPartners);
return available.toList();
});
@@ -1,7 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/asset.service.dart';
final allMotionPhotosProvider = FutureProvider<List<Asset>>((ref) async {
return ref.watch(assetServiceProvider).getMotionAssets();
});
@@ -1,46 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/search/search_result.model.dart';
import 'package:immich_mobile/services/timeline.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/services/search.service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'paginated_search.provider.g.dart';
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
);
class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
final SearchService _searchService;
PaginatedSearchNotifier(this._searchService) : super(const SearchResult(assets: [], nextPage: 1));
Future<bool> search(SearchFilter filter) async {
if (state.nextPage == null) {
return false;
}
final result = await _searchService.search(filter, state.nextPage!);
if (result == null) {
return false;
}
state = SearchResult(assets: [...state.assets, ...result.assets], nextPage: result.nextPage);
return true;
}
clear() {
state = const SearchResult(assets: [], nextPage: 1);
}
}
@riverpod
Future<RenderList> paginatedSearchRenderList(Ref ref) {
final result = ref.watch(paginatedSearchProvider);
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.getTimelineFromAssets(result.assets, GroupAssetsBy.none);
}
@@ -1,29 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'paginated_search.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$paginatedSearchRenderListHash() =>
r'22d715ff7864e5a946be38322ce7813616f899c2';
/// See also [paginatedSearchRenderList].
@ProviderFor(paginatedSearchRenderList)
final paginatedSearchRenderListProvider =
AutoDisposeFutureProvider<RenderList>.internal(
paginatedSearchRenderList,
name: r'paginatedSearchRenderListProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$paginatedSearchRenderListHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef PaginatedSearchRenderListRef = AutoDisposeFutureProviderRef<RenderList>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -1,9 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/services/person.service.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'people.provider.g.dart';
@@ -17,16 +14,6 @@ Future<List<PersonDto>> getAllPeople(Ref ref) async {
return people;
}
@riverpod
Future<RenderList> personAssets(Ref ref, String personId) async {
final PersonService personService = ref.read(personServiceProvider);
final assets = await personService.getPersonAssets(personId);
final settings = ref.read(appSettingsServiceProvider);
final groupBy = GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
return await RenderList.fromAssets(assets, groupBy);
}
@riverpod
Future<bool> updatePersonName(Ref ref, String personId, String updatedName) async {
final PersonService personService = ref.read(personServiceProvider);
+1 -121
View File
@@ -24,7 +24,7 @@ final getAllPeopleProvider =
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef GetAllPeopleRef = AutoDisposeFutureProviderRef<List<PersonDto>>;
String _$personAssetsHash() => r'c1d35ee0e024bd6915e21bc724be4b458a14bc24';
String _$updatePersonNameHash() => r'45f7693172de522a227406d8198811434cf2bbbc';
/// Copied from Dart SDK
class _SystemHash {
@@ -47,126 +47,6 @@ class _SystemHash {
}
}
/// See also [personAssets].
@ProviderFor(personAssets)
const personAssetsProvider = PersonAssetsFamily();
/// See also [personAssets].
class PersonAssetsFamily extends Family<AsyncValue<RenderList>> {
/// See also [personAssets].
const PersonAssetsFamily();
/// See also [personAssets].
PersonAssetsProvider call(String personId) {
return PersonAssetsProvider(personId);
}
@override
PersonAssetsProvider getProviderOverride(
covariant PersonAssetsProvider provider,
) {
return call(provider.personId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'personAssetsProvider';
}
/// See also [personAssets].
class PersonAssetsProvider extends AutoDisposeFutureProvider<RenderList> {
/// See also [personAssets].
PersonAssetsProvider(String personId)
: this._internal(
(ref) => personAssets(ref as PersonAssetsRef, personId),
from: personAssetsProvider,
name: r'personAssetsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$personAssetsHash,
dependencies: PersonAssetsFamily._dependencies,
allTransitiveDependencies:
PersonAssetsFamily._allTransitiveDependencies,
personId: personId,
);
PersonAssetsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.personId,
}) : super.internal();
final String personId;
@override
Override overrideWith(
FutureOr<RenderList> Function(PersonAssetsRef provider) create,
) {
return ProviderOverride(
origin: this,
override: PersonAssetsProvider._internal(
(ref) => create(ref as PersonAssetsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
personId: personId,
),
);
}
@override
AutoDisposeFutureProviderElement<RenderList> createElement() {
return _PersonAssetsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is PersonAssetsProvider && other.personId == personId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, personId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin PersonAssetsRef on AutoDisposeFutureProviderRef<RenderList> {
/// The parameter `personId` of this provider.
String get personId;
}
class _PersonAssetsProviderElement
extends AutoDisposeFutureProviderElement<RenderList>
with PersonAssetsRef {
_PersonAssetsProviderElement(super.provider);
@override
String get personId => (origin as PersonAssetsProvider).personId;
}
String _$updatePersonNameHash() => r'45f7693172de522a227406d8198811434cf2bbbc';
/// See also [updatePersonName].
@ProviderFor(updatePersonName)
const updatePersonNameProvider = UpdatePersonNameFamily();
@@ -1,9 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/asset.service.dart';
final recentlyTakenAssetProvider = FutureProvider<List<Asset>>((ref) async {
final assetService = ref.read(assetServiceProvider);
return assetService.getRecentlyTakenAssets();
});
@@ -1,68 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/services/timeline.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
final singleUserTimelineProvider = StreamProvider.family<RenderList, String?>((ref, userId) {
if (userId == null) {
return const Stream.empty();
}
ref.watch(localeProvider);
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.watchHomeTimeline(userId);
}, dependencies: [localeProvider]);
final multiUsersTimelineProvider = StreamProvider.family<RenderList, List<String>>((ref, userIds) {
ref.watch(localeProvider);
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.watchMultiUsersTimeline(userIds);
}, dependencies: [localeProvider]);
final albumTimelineProvider = StreamProvider.autoDispose.family<RenderList, int>((ref, id) {
final album = ref.watch(albumWatcher(id)).value;
final timelineService = ref.watch(timelineServiceProvider);
if (album != null) {
return timelineService.watchAlbumTimeline(album);
}
return const Stream.empty();
});
final archiveTimelineProvider = StreamProvider<RenderList>((ref) {
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.watchArchiveTimeline();
});
final favoriteTimelineProvider = StreamProvider<RenderList>((ref) {
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.watchFavoriteTimeline();
});
final trashTimelineProvider = StreamProvider<RenderList>((ref) {
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.watchTrashTimeline();
});
final allVideosTimelineProvider = StreamProvider<RenderList>((ref) {
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.watchAllVideosTimeline();
});
final assetSelectionTimelineProvider = StreamProvider<RenderList>((ref) {
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.watchAssetSelectionTimeline();
});
final assetsTimelineProvider = FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.getTimelineFromAssets(assets, null);
});
final lockedTimelineProvider = StreamProvider<RenderList>((ref) {
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.watchLockedTimelineProvider();
});
-45
View File
@@ -1,45 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/trash.service.dart';
import 'package:logging/logging.dart';
class TrashNotifier extends StateNotifier<bool> {
final TrashService _trashService;
final _log = Logger('TrashNotifier');
TrashNotifier(this._trashService) : super(false);
Future<void> emptyTrash() async {
try {
await _trashService.emptyTrash();
state = true;
} catch (error, stack) {
_log.severe("Cannot empty trash", error, stack);
state = false;
}
}
Future<bool> restoreAssets(Iterable<Asset> assetList) async {
try {
await _trashService.restoreAssets(assetList);
return true;
} catch (error, stack) {
_log.severe("Cannot restore assets", error, stack);
return false;
}
}
Future<void> restoreTrash() async {
try {
await _trashService.restoreTrash();
state = true;
} catch (error, stack) {
_log.severe("Cannot restore trash", error, stack);
state = false;
}
}
}
final trashProvider = StateNotifierProvider<TrashNotifier, bool>((ref) {
return TrashNotifier(ref.watch(trashServiceProvider));
});
-27
View File
@@ -1,11 +1,9 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/timeline.service.dart';
class CurrentUserProvider extends StateNotifier<UserDto?> {
CurrentUserProvider(this._userService) : super(null) {
@@ -32,28 +30,3 @@ class CurrentUserProvider extends StateNotifier<UserDto?> {
final currentUserProvider = StateNotifierProvider<CurrentUserProvider, UserDto?>((ref) {
return CurrentUserProvider(ref.watch(userServiceProvider));
});
class TimelineUserIdsProvider extends StateNotifier<List<String>> {
TimelineUserIdsProvider(this._timelineService) : super([]) {
final listEquality = const ListEquality();
_timelineService.getTimelineUserIds().then((users) => state = users);
streamSub = _timelineService.watchTimelineUserIds().listen((users) {
if (!listEquality.equals(state, users)) {
state = users;
}
});
}
late final StreamSubscription<List<String>> streamSub;
final TimelineService _timelineService;
@override
void dispose() {
streamSub.cancel();
super.dispose();
}
}
final timelineUsersIdsProvider = StateNotifierProvider<TimelineUserIdsProvider, List<String>>((ref) {
return TimelineUserIdsProvider(ref.watch(timelineServiceProvider));
});
+10 -172
View File
@@ -1,60 +1,27 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:socket_io_client/socket_io_client.dart';
enum PendingAction { assetDelete, assetUploaded, assetHidden, assetTrash }
class PendingChange {
final String id;
final PendingAction action;
final dynamic value;
const PendingChange(this.id, this.action, this.value);
@override
String toString() => 'PendingChange(id: $id, action: $action, value: $value)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PendingChange && other.id == id && other.action == action;
}
@override
int get hashCode => id.hashCode ^ action.hashCode;
}
class WebsocketState {
final Socket? socket;
final bool isConnected;
final List<PendingChange> pendingChanges;
const WebsocketState({this.socket, required this.isConnected, required this.pendingChanges});
const WebsocketState({this.socket, required this.isConnected});
WebsocketState copyWith({Socket? socket, bool? isConnected, List<PendingChange>? pendingChanges}) {
return WebsocketState(
socket: socket ?? this.socket,
isConnected: isConnected ?? this.isConnected,
pendingChanges: pendingChanges ?? this.pendingChanges,
);
WebsocketState copyWith({Socket? socket, bool? isConnected}) {
return WebsocketState(socket: socket ?? this.socket, isConnected: isConnected ?? this.isConnected);
}
@override
@@ -72,11 +39,10 @@ class WebsocketState {
}
class WebsocketNotifier extends StateNotifier<WebsocketState> {
WebsocketNotifier(this._ref) : super(const WebsocketState(socket: null, isConnected: false, pendingChanges: []));
WebsocketNotifier(this._ref) : super(const WebsocketState(socket: null, isConnected: false));
final _log = Logger('WebsocketNotifier');
final Ref _ref;
final Debouncer _debounce = Debouncer(interval: const Duration(milliseconds: 500));
final Debouncer _batchDebouncer = Debouncer(
interval: const Duration(seconds: 5),
@@ -115,32 +81,21 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.onConnect((_) {
dPrint(() => "Established Websocket Connection");
state = WebsocketState(isConnected: true, socket: socket, pendingChanges: state.pendingChanges);
state = WebsocketState(isConnected: true, socket: socket);
});
socket.onDisconnect((_) {
dPrint(() => "Disconnect to Websocket Connection");
state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges);
state = const WebsocketState(isConnected: false, socket: null);
});
socket.on('error', (errorMessage) {
_log.severe("Websocket Error - $errorMessage");
state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges);
state = const WebsocketState(isConnected: false, socket: null);
});
if (!Store.isBetaTimelineEnabled) {
socket.on('on_upload_success', _handleOnUploadSuccess);
socket.on('on_asset_delete', _handleOnAssetDelete);
socket.on('on_asset_trash', _handleOnAssetTrash);
socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates);
socket.on('on_asset_stack_update', _handleServerUpdates);
socket.on('on_asset_hidden', _handleOnAssetHidden);
} else {
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
socket.on('AssetEditReadyV1', _handleSyncAssetEditReady);
}
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
socket.on('AssetEditReadyV1', _handleSyncAssetEditReady);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates);
} catch (e) {
@@ -155,46 +110,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_batchedAssetUploadReady.clear();
state.socket?.dispose();
state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges);
}
void stopListenToEvent(String eventName) {
state.socket?.off(eventName);
}
void stopListenToOldEvents() {
state.socket?.off('on_upload_success');
state.socket?.off('on_asset_delete');
state.socket?.off('on_asset_trash');
state.socket?.off('on_asset_restore');
state.socket?.off('on_asset_update');
state.socket?.off('on_asset_stack_update');
state.socket?.off('on_asset_hidden');
}
void startListeningToOldEvents() {
state.socket?.on('on_upload_success', _handleOnUploadSuccess);
state.socket?.on('on_asset_delete', _handleOnAssetDelete);
state.socket?.on('on_asset_trash', _handleOnAssetTrash);
state.socket?.on('on_asset_restore', _handleServerUpdates);
state.socket?.on('on_asset_update', _handleServerUpdates);
state.socket?.on('on_asset_stack_update', _handleServerUpdates);
state.socket?.on('on_asset_hidden', _handleOnAssetHidden);
}
void stopListeningToBetaEvents() {
state.socket?.off('AssetUploadReadyV1');
state.socket?.off('AssetEditReadyV1');
}
void startListeningToBetaEvents() {
state.socket?.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
state.socket?.on('AssetEditReadyV1', _handleSyncAssetEditReady);
}
void listenUploadEvent() {
dPrint(() => "Start listening to event on_upload_success");
state.socket?.on('on_upload_success', _handleOnUploadSuccess);
state = const WebsocketState(isConnected: false, socket: null);
}
Future<void> waitForEvent(String event, bool Function(dynamic)? predicate, Duration timeout) {
@@ -218,89 +134,11 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
);
}
void addPendingChange(PendingAction action, dynamic value) {
final now = DateTime.now();
state = state.copyWith(
pendingChanges: [...state.pendingChanges, PendingChange(now.millisecondsSinceEpoch.toString(), action, value)],
);
_debounce.run(handlePendingChanges);
}
Future<void> _handlePendingTrashes() async {
final trashChanges = state.pendingChanges.where((c) => c.action == PendingAction.assetTrash).toList();
if (trashChanges.isNotEmpty) {
List<String> remoteIds = trashChanges.expand((a) => (a.value as List).map((e) => e.toString())).toList();
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
await _ref.read(assetProvider.notifier).getAllAsset();
state = state.copyWith(pendingChanges: state.pendingChanges.whereNot((c) => trashChanges.contains(c)).toList());
}
}
Future<void> _handlePendingDeletes() async {
final deleteChanges = state.pendingChanges.where((c) => c.action == PendingAction.assetDelete).toList();
if (deleteChanges.isNotEmpty) {
List<String> remoteIds = deleteChanges.map((a) => a.value.toString()).toList();
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
state = state.copyWith(pendingChanges: state.pendingChanges.whereNot((c) => deleteChanges.contains(c)).toList());
}
}
Future<void> _handlePendingUploaded() async {
final uploadedChanges = state.pendingChanges.where((c) => c.action == PendingAction.assetUploaded).toList();
if (uploadedChanges.isNotEmpty) {
List<AssetResponseDto?> remoteAssets = uploadedChanges.map((a) => AssetResponseDto.fromJson(a.value)).toList();
for (final dto in remoteAssets) {
if (dto != null) {
final newAsset = Asset.remote(dto);
await _ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
}
}
state = state.copyWith(
pendingChanges: state.pendingChanges.whereNot((c) => uploadedChanges.contains(c)).toList(),
);
}
}
Future<void> _handlingPendingHidden() async {
final hiddenChanges = state.pendingChanges.where((c) => c.action == PendingAction.assetHidden).toList();
if (hiddenChanges.isNotEmpty) {
List<String> remoteIds = hiddenChanges.map((a) => a.value.toString()).toList();
final db = _ref.watch(dbProvider);
await db.writeTxn(() => db.assets.deleteAllByRemoteId(remoteIds));
state = state.copyWith(pendingChanges: state.pendingChanges.whereNot((c) => hiddenChanges.contains(c)).toList());
}
}
Future<void> handlePendingChanges() async {
await _handlePendingUploaded();
await _handlePendingDeletes();
await _handlingPendingHidden();
await _handlePendingTrashes();
}
void _handleOnConfigUpdate(dynamic _) {
_ref.read(serverInfoProvider.notifier).getServerFeatures();
_ref.read(serverInfoProvider.notifier).getServerConfig();
}
// Refresh updated assets
void _handleServerUpdates(dynamic _) {
_ref.read(assetProvider.notifier).getAllAsset();
}
void _handleOnUploadSuccess(dynamic data) => addPendingChange(PendingAction.assetUploaded, data);
void _handleOnAssetDelete(dynamic data) => addPendingChange(PendingAction.assetDelete, data);
void _handleOnAssetTrash(dynamic data) {
addPendingChange(PendingAction.assetTrash, data);
}
void _handleOnAssetHidden(dynamic data) => addPendingChange(PendingAction.assetHidden, data);
_handleReleaseUpdates(dynamic data) {
// Json guard
if (data is! Map) {