import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/backup/models/available_album.model.dart'; import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; import 'package:immich_mobile/modules/backup/models/backup_state.model.dart'; import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart'; import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart'; import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart'; import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/shared/models/server_info/server_disk_info.model.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/providers/app_state.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/services/server_info.service.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart'; class BackupNotifier extends StateNotifier { BackupNotifier( this._backupService, this._serverInfoService, this._authState, this._backgroundService, this._galleryPermissionNotifier, this._db, this.ref, ) : super( BackUpState( backupProgress: BackUpProgressEnum.idle, allAssetsInDatabase: const [], progressInPercentage: 0, cancelToken: CancellationToken(), 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: '...', ), ), ); final log = Logger('BackupNotifier'); final BackupService _backupService; final ServerInfoService _serverInfoService; final AuthenticationState _authState; final BackgroundService _backgroundService; final GalleryPermissionNotifier _galleryPermissionNotifier; final Isar _db; final Ref ref; /// /// 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 currentSelectedAlbums = state.selectedBackupAlbums; currentSelectedAlbums.removeWhere((a) => a == album); state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums); } void removeExcludedAlbumForBackup(AvailableAlbum album) { Set currentExcludedAlbums = state.excludedBackupAlbums; currentExcludedAlbums.removeWhere((a) => a == album); state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums); } Future 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 _getBackupAlbumsInfo() async { Stopwatch stopwatch = Stopwatch()..start(); // Get all albums on the device List availableAlbums = []; List albums = await PhotoManager.getAssetPathList( hasAll: true, type: RequestType.common, ); // Map of id -> album for quick album lookup later on. Map albumMap = {}; log.info('Found ${albums.length} local albums'); for (AssetPathEntity album in albums) { AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); final assetCountInAlbum = await album.assetCountAsync; if (assetCountInAlbum > 0) { final assetList = await album.getAssetListPaged(page: 0, size: 1); // Even though we check assetCountInAlbum to make sure that there are assets in album // The `getAssetListPaged` method still return empty list and cause not assets get rendered if (assetList.isEmpty) { continue; } final thumbnailAsset = assetList.first; try { final thumbnailData = await thumbnailAsset .thumbnailDataWithSize(const ThumbnailSize(512, 512)); availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData); } catch (e, stack) { log.severe( "Failed to get thumbnail for album ${album.name}", e.toString(), stack, ); } availableAlbums.add(availableAlbum); albumMap[album.id] = album; } } state = state.copyWith(availableAlbums: availableAlbums); final List excludedBackupAlbums = await _backupService.excludedAlbumsQuery().findAll(); final List selectedBackupAlbums = await _backupService.selectedAlbumsQuery().findAll(); // Generate AssetPathEntity from id to add to local state final Set selectedAlbums = {}; for (final BackupAlbum ba in selectedBackupAlbums) { final albumAsset = albumMap[ba.id]; if (albumAsset != null) { selectedAlbums.add( AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup), ); } else { log.severe('Selected album not found'); } } final Set excludedAlbums = {}; for (final BackupAlbum ba in excludedBackupAlbums) { final albumAsset = albumMap[ba.id]; if (albumAsset != null) { excludedAlbums.add( AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup), ); } else { log.severe('Excluded album not found'); } } state = state.copyWith( selectedBackupAlbums: selectedAlbums, excludedBackupAlbums: excludedAlbums, ); debugPrint("_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 _updateBackupAssetCount() async { final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds(); final Set assetsFromSelectedAlbums = {}; final Set assetsFromExcludedAlbums = {}; for (final album in state.selectedBackupAlbums) { final assets = await album.albumEntity.getAssetListRange( start: 0, end: await album.albumEntity.assetCountAsync, ); assetsFromSelectedAlbums.addAll(assets); } for (final album in state.excludedBackupAlbums) { final assets = await album.albumEntity.getAssetListRange( start: 0, end: await album.albumEntity.assetCountAsync, ); assetsFromExcludedAlbums.addAll(assets); } final Set allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums); final allAssetsInDatabase = await _backupService.getDeviceBackupAsset(); if (allAssetsInDatabase == null) { return; } // Find asset that were backup from selected albums final Set selectedAlbumsBackupAssets = Set.from(allUniqueAssets.map((e) => e.id)); selectedAlbumsBackupAssets .removeWhere((assetId) => !allAssetsInDatabase.contains(assetId)); // Remove duplicated asset from all unique assets allUniqueAssets.removeWhere( (asset) => duplicatedAssetIds.contains(asset.id), ); if (allUniqueAssets.isEmpty) { log.info("Not found albums or assets on the device to backup"); state = state.copyWith( backupProgress: BackUpProgressEnum.idle, allAssetsInDatabase: allAssetsInDatabase, allUniqueAssets: {}, selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, ); } else { state = state.copyWith( allAssetsInDatabase: allAssetsInDatabase, allUniqueAssets: allUniqueAssets, selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, ); } // Save to persistent storage await _updatePersistentAlbumsSelection(); } /// 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 getBackupInfo() async { final isEnabled = await _backgroundService.isBackgroundBackupEnabled(); state = state.copyWith(backgroundBackup: isEnabled); if (isEnabled != Store.get(StoreKey.backgroundBackup, !isEnabled)) { Store.put(StoreKey.backgroundBackup, isEnabled); } if (state.backupProgress != BackUpProgressEnum.inBackground) { await _getBackupAlbumsInfo(); await updateServerInfo(); await _updateBackupAssetCount(); } } /// Save user selection of selected albums and excluded albums to database Future _updatePersistentAlbumsSelection() { 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 backupAlbums = selected.followedBy(excluded).toList(); backupAlbums.sortBy((e) => e.id); return _db.writeTxn(() async { final dbAlbums = await _db.backupAlbums.where().sortById().findAll(); final List toDelete = []; final List toUpsert = []; // stores the most recent `lastBackup` per album but always keeps the `selection` the user just made diffSortedListsSync( dbAlbums, backupAlbums, 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 _db.backupAlbums.deleteAll(toDelete); await _db.backupAlbums.putAll(toUpsert); }); } /// Invoke backup process Future startBackupProcess() async { debugPrint("Start backup process"); assert(state.backupProgress == BackUpProgressEnum.idle); state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress); await getBackupInfo(); final hasPermission = _galleryPermissionNotifier.hasPermission; if (hasPermission) { await PhotoManager.clearFileCache(); if (state.allUniqueAssets.isEmpty) { log.info("No Asset On Device - Abort Backup Process"); state = state.copyWith(backupProgress: BackUpProgressEnum.idle); return; } Set assetsWillBeBackup = Set.from(state.allUniqueAssets); // Remove item that has already been backed up for (final assetId in state.allAssetsInDatabase) { assetsWillBeBackup.removeWhere((e) => e.id == assetId); } if (assetsWillBeBackup.isEmpty) { state = state.copyWith(backupProgress: BackUpProgressEnum.idle); } // Perform Backup state = state.copyWith(cancelToken: CancellationToken()); await _backupService.backupAsset( assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress, _onSetCurrentBackupAsset, _onBackupError, ); await notifyBackgroundServiceCanRun(); } else { 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(); } state.cancelToken.cancel(); state = state.copyWith( backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0, ); } void _onAssetUploaded( String deviceAssetId, String deviceId, bool isDuplicated, ) { if (isDuplicated) { state = state.copyWith( allUniqueAssets: state.allUniqueAssets .where((asset) => asset.id != deviceAssetId) .toSet(), ); } else { state = state.copyWith( selectedAlbumsBackupAssetsIds: { ...state.selectedAlbumsBackupAssetsIds, deviceAssetId, }, allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId], ); } if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) { final latestAssetBackup = state.allUniqueAssets.map((e) => e.modifiedDateTime).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, ); _updatePersistentAlbumsSelection(); } updateServerInfo(); } void _onUploadProgress(int sent, int total) { state = state.copyWith( progressInPercentage: (sent.toDouble() / total.toDouble() * 100), ); } Future updateServerInfo() async { final serverInfo = await _serverInfoService.getServerInfo(); // Update server info if (serverInfo != null) { state = state.copyWith( serverInfo: serverInfo, ); } } Future _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 resumeBackup() async { final List selectedBackupAlbums = await _db.backupAlbums .filter() .selectionEqualTo(BackupSelection.select) .findAll(); final List excludedBackupAlbums = await _db.backupAlbums .filter() .selectionEqualTo(BackupSelection.exclude) .findAll(); Set selectedAlbums = state.selectedBackupAlbums; Set 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 _updateAlbumsBackupTime( Set albums, List backupAlbums, ) { Set 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 notifyBackgroundServiceCanRun() async { const allowedStates = [ AppStateEnum.inactive, AppStateEnum.paused, AppStateEnum.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); } } final backupProvider = StateNotifierProvider((ref) { return BackupNotifier( ref.watch(backupServiceProvider), ref.watch(serverInfoServiceProvider), ref.watch(authenticationProvider), ref.watch(backgroundServiceProvider), ref.watch(galleryPermissionNotifier.notifier), ref.watch(dbProvider), ref, ); });