import 'package:cancellation_token_http/http.dart'; import 'package:flutter/widgets.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/modules/backup/models/available_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/models/hive_backup_albums.model.dart'; import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.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/shared/providers/app_state.provider.dart'; import 'package:immich_mobile/shared/services/server_info.service.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:photo_manager/photo_manager.dart'; class BackupNotifier extends StateNotifier { BackupNotifier( this._backupService, this._serverInfoService, this._authState, this._backgroundService, this.ref, ) : super( BackUpState( backupProgress: BackUpProgressEnum.idle, allAssetsInDatabase: const [], progressInPercentage: 0, cancelToken: CancellationToken(), backgroundBackup: false, backupRequireWifi: true, backupRequireCharging: false, backupTriggerDelay: 5000, serverInfo: ServerInfoResponseDto( diskAvailable: "0", diskAvailableRaw: 0, diskSize: "0", diskSizeRaw: 0, diskUsagePercentage: 0, diskUse: "0", diskUseRaw: 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 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}); _updateBackupAssetCount(); } void addExcludedAlbumForBackup(AvailableAlbum album) { if (state.selectedBackupAlbums.contains(album)) { removeAlbumForBackup(album); } state = state .copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album}); _updateBackupAssetCount(); } void removeAlbumForBackup(AvailableAlbum album) { Set currentSelectedAlbums = state.selectedBackupAlbums; currentSelectedAlbums.removeWhere((a) => a == album); state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums); _updateBackupAssetCount(); } void removeExcludedAlbumForBackup(AvailableAlbum album) { Set currentExcludedAlbums = state.excludedBackupAlbums; currentExcludedAlbums.removeWhere((a) => a == album); state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums); _updateBackupAssetCount(); } 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) { final box = Hive.box(backgroundBackupInfoBox); await Future.wait([ box.put(backupRequireWifi, state.backupRequireWifi), box.put(backupRequireCharging, state.backupRequireCharging), box.put(backupTriggerDelay, state.backupTriggerDelay), ]); } 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, ); log.info('Found ${albums.length} local albums'); for (AssetPathEntity album in albums) { AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); var assetCountInAlbum = await album.assetCountAsync; if (assetCountInAlbum > 0) { var assetList = await album.getAssetListRange(start: 0, end: assetCountInAlbum); if (assetList.isNotEmpty) { var thumbnailAsset = assetList.first; try { var 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); } } state = state.copyWith(availableAlbums: availableAlbums); // Put persistent storage info into local state of the app // Get local storage on selected backup album Box backupAlbumInfoBox = Hive.box(hiveBackupInfoBox); HiveBackupAlbums? backupAlbumInfo = backupAlbumInfoBox.get( backupInfoKey, defaultValue: HiveBackupAlbums( selectedAlbumIds: [], excludedAlbumsIds: [], lastSelectedBackupTime: [], lastExcludedBackupTime: [], ), ); if (backupAlbumInfo == null) { log.severe( "backupAlbumInfo == null", "Failed to get Hive backup album information", ); return; } // First time backup - set isAll album is the default one for backup. if (backupAlbumInfo.selectedAlbumIds.isEmpty) { log.info("First time backup; setup 'Recent(s)' album as default"); // Get album that contains all assets var list = await PhotoManager.getAssetPathList( hasAll: true, onlyAll: true, type: RequestType.common, ); if (list.isEmpty) { return; } AssetPathEntity albumHasAllAssets = list.first; backupAlbumInfoBox.put( backupInfoKey, HiveBackupAlbums( selectedAlbumIds: [albumHasAllAssets.id], excludedAlbumsIds: [], lastSelectedBackupTime: [ DateTime.fromMillisecondsSinceEpoch(0, isUtc: true) ], lastExcludedBackupTime: [], ), ); backupAlbumInfo = backupAlbumInfoBox.get(backupInfoKey); } // Generate AssetPathEntity from id to add to local state try { Set selectedAlbums = {}; for (var i = 0; i < backupAlbumInfo!.selectedAlbumIds.length; i++) { var albumAsset = await AssetPathEntity.fromId(backupAlbumInfo.selectedAlbumIds[i]); selectedAlbums.add( AvailableAlbum( albumEntity: albumAsset, lastBackup: backupAlbumInfo.lastSelectedBackupTime.length > i ? backupAlbumInfo.lastSelectedBackupTime[i] : DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), ), ); } Set excludedAlbums = {}; for (var i = 0; i < backupAlbumInfo.excludedAlbumsIds.length; i++) { var albumAsset = await AssetPathEntity.fromId(backupAlbumInfo.excludedAlbumsIds[i]); excludedAlbums.add( AvailableAlbum( albumEntity: albumAsset, lastBackup: backupAlbumInfo.lastExcludedBackupTime.length > i ? backupAlbumInfo.lastExcludedBackupTime[i] : DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), ), ); } state = state.copyWith( selectedBackupAlbums: selectedAlbums, excludedBackupAlbums: excludedAlbums, ); } catch (e, stackTrace) { log.severe("Failed to generate album from id", e, stackTrace); } 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 { Set duplicatedAssetIds = _backupService.getDuplicatedAssetIds(); Set assetsFromSelectedAlbums = {}; Set assetsFromExcludedAlbums = {}; for (var album in state.selectedBackupAlbums) { var assets = await album.albumEntity.getAssetListRange( start: 0, end: await album.albumEntity.assetCountAsync, ); assetsFromSelectedAlbums.addAll(assets); } for (var album in state.excludedBackupAlbums) { var assets = await album.albumEntity.getAssetListRange( start: 0, end: await album.albumEntity.assetCountAsync, ); assetsFromExcludedAlbums.addAll(assets); } Set allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums); var allAssetsInDatabase = await _backupService.getDeviceBackupAsset(); if (allAssetsInDatabase == null) { return; } // Find asset that were backup from selected albums 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, ); return; } else { state = state.copyWith( allAssetsInDatabase: allAssetsInDatabase, allUniqueAssets: allUniqueAssets, selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, ); } // Save to persistent storage _updatePersistentAlbumsSelection(); return; } /// 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 { var isEnabled = await _backgroundService.isBackgroundBackupEnabled(); state = state.copyWith(backgroundBackup: isEnabled); if (state.backupProgress != BackUpProgressEnum.inBackground) { await _getBackupAlbumsInfo(); await _updateServerInfo(); await _updateBackupAssetCount(); } } /// Save user selection of selected albums and excluded albums to /// Hive database void _updatePersistentAlbumsSelection() { final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); Box backupAlbumInfoBox = Hive.box(hiveBackupInfoBox); backupAlbumInfoBox.put( backupInfoKey, HiveBackupAlbums( selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(), excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(), lastSelectedBackupTime: state.selectedBackupAlbums .map((e) => e.lastBackup ?? epoch) .toList(), lastExcludedBackupTime: state.excludedBackupAlbums .map((e) => e.lastBackup ?? epoch) .toList(), ), ); } /// Invoke backup process Future startBackupProcess() async { debugPrint("Start backup process"); assert(state.backupProgress == BackUpProgressEnum.idle); state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress); await getBackupInfo(); var authResult = await PhotoManager.requestPermissionExtend(); if (authResult.isAuth) { 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 (var 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 { PhotoManager.openSetting(); } } 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 { var serverInfo = await _serverInfoService.getServerInfo(); // Update server info if (serverInfo != null) { state = state.copyWith( serverInfo: serverInfo, ); } } Future _resumeBackup() async { // Check if user is login var accessKey = Hive.box(userInfoBox).get(accessTokenKey); // 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 ((_authState.deviceInfo.deviceId == _authState.deviceId) && _authState.deviceInfo.isAutoBackup) { // check if backup is alreayd in process - then return if (state.backupProgress == BackUpProgressEnum.inProgress) { log.info("[_resumeBackup] Backup is already in progress - abort"); return; } if (state.backupProgress == BackUpProgressEnum.inBackground) { log.info("[_resumeBackup] Background backup is running - abort"); return; } // Run backup log.info("[_resumeBackup] Start back up"); await startBackupProcess(); } return; } Future resumeBackup() async { // assumes the background service is currently running // if true, waits until it has stopped to update the app state from HiveDB // before actually resuming backup by calling the internal `_resumeBackup` final BackUpProgressEnum previous = state.backupProgress; state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground); final bool hasLock = await _backgroundService.acquireLock(); if (!hasLock) { log.warning("WARNING [resumeBackup] failed to acquireLock"); return; } await Future.wait([ Hive.openBox(hiveBackupInfoBox), Hive.openBox(duplicatedAssetsBox), Hive.openBox(backgroundBackupInfoBox), ]); final HiveBackupAlbums? albums = Hive.box(hiveBackupInfoBox).get(backupInfoKey); Set selectedAlbums = state.selectedBackupAlbums; Set excludedAlbums = state.excludedBackupAlbums; if (albums != null) { if (selectedAlbums.isNotEmpty) { selectedAlbums = _updateAlbumsBackupTime( selectedAlbums, albums.selectedAlbumIds, albums.lastSelectedBackupTime, ); } if (excludedAlbums.isNotEmpty) { excludedAlbums = _updateAlbumsBackupTime( excludedAlbums, albums.excludedAlbumsIds, albums.lastExcludedBackupTime, ); } } final Box backgroundBox = Hive.box(backgroundBackupInfoBox); state = state.copyWith( backupProgress: previous, selectedBackupAlbums: selectedAlbums, excludedBackupAlbums: excludedAlbums, backupRequireWifi: backgroundBox.get(backupRequireWifi), backupRequireCharging: backgroundBox.get(backupRequireCharging), backupTriggerDelay: backgroundBox.get(backupTriggerDelay), ); return _resumeBackup(); } Set _updateAlbumsBackupTime( Set albums, List ids, List times, ) { Set result = {}; for (int i = 0; i < ids.length; i++) { try { AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]); result.add(a.copyWith(lastBackup: times[i])); } 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)) { try { if (Hive.isBoxOpen(hiveBackupInfoBox)) { await Hive.box(hiveBackupInfoBox).close(); } } catch (error) { log.info("[_notifyBackgroundServiceCanRun] failed to close box"); } try { if (Hive.isBoxOpen(duplicatedAssetsBox)) { await Hive.box(duplicatedAssetsBox).close(); } } catch (error, stackTrace) { log.severe( "[_notifyBackgroundServiceCanRun] failed to close box", error, stackTrace, ); } try { if (Hive.isBoxOpen(backgroundBackupInfoBox)) { await Hive.box(backgroundBackupInfoBox).close(); } } catch (error, stackTrace) { log.severe( "[_notifyBackgroundServiceCanRun] failed to close box", error, stackTrace, ); } _backgroundService.releaseLock(); } } } final backupProvider = StateNotifierProvider((ref) { return BackupNotifier( ref.watch(backupServiceProvider), ref.watch(serverInfoServiceProvider), ref.watch(authenticationProvider), ref.watch(backgroundServiceProvider), ref, ); });