123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682 |
- 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/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:openapi/api.dart';
- import 'package:permission_handler/permission_handler.dart';
- import 'package:photo_manager/photo_manager.dart';
- class BackupNotifier extends StateNotifier<BackUpState> {
- 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: false,
- backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
- backupRequireCharging:
- Store.get(StoreKey.backupRequireCharging, false),
- backupTriggerDelay: Store.get(StoreKey.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 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});
- _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<AvailableAlbum> currentSelectedAlbums = state.selectedBackupAlbums;
- currentSelectedAlbums.removeWhere((a) => a == album);
- state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums);
- _updateBackupAssetCount();
- }
- void removeExcludedAlbumForBackup(AvailableAlbum album) {
- Set<AvailableAlbum> currentExcludedAlbums = state.excludedBackupAlbums;
- currentExcludedAlbums.removeWhere((a) => a == album);
- state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums);
- _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);
- } 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<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
- hasAll: true,
- type: RequestType.common,
- );
- // Map of id -> album for quick album lookup later on.
- Map<String, AssetPathEntity> 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.getAssetListRange(start: 0, end: assetCountInAlbum);
- if (assetList.isNotEmpty) {
- 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<BackupAlbum> excludedBackupAlbums =
- await _backupService.excludedAlbumsQuery().findAll();
- final List<BackupAlbum> selectedBackupAlbums =
- await _backupService.selectedAlbumsQuery().findAll();
- // First time backup - set isAll album is the default one for backup.
- if (selectedBackupAlbums.isEmpty) {
- log.info("First time backup; setup 'Recent(s)' album as default");
- // Get album that contains all assets
- final list = await PhotoManager.getAssetPathList(
- hasAll: true,
- onlyAll: true,
- type: RequestType.common,
- );
- if (list.isEmpty) {
- return;
- }
- AssetPathEntity albumHasAllAssets = list.first;
- final ba = BackupAlbum(
- albumHasAllAssets.id,
- DateTime.fromMillisecondsSinceEpoch(0),
- BackupSelection.select,
- );
- await _db.writeTxn(() => _db.backupAlbums.put(ba));
- }
- // Generate AssetPathEntity from id to add to local state
- final Set<AvailableAlbum> 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<AvailableAlbum> 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<void> _updateBackupAssetCount() async {
- final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
- final Set<AssetEntity> assetsFromSelectedAlbums = {};
- final Set<AssetEntity> 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<AssetEntity> 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.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
- await _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<void> getBackupInfo() async {
- final 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 database
- Future<void> _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<int> toDelete = [];
- final List<BackupAlbum> 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<void> 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<AssetEntity> 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<void> updateServerInfo() async {
- final serverInfo = await _serverInfoService.getServerInfo();
- // Update server info
- if (serverInfo != null) {
- state = state.copyWith(
- serverInfo: serverInfo,
- );
- }
- }
- 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 _db.backupAlbums
- .filter()
- .selectionEqualTo(BackupSelection.select)
- .findAll();
- final List<BackupAlbum> excludedBackupAlbums = await _db.backupAlbums
- .filter()
- .selectionEqualTo(BackupSelection.exclude)
- .findAll();
- 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 = [
- 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<BackupNotifier, BackUpState>((ref) {
- return BackupNotifier(
- ref.watch(backupServiceProvider),
- ref.watch(serverInfoServiceProvider),
- ref.watch(authenticationProvider),
- ref.watch(backgroundServiceProvider),
- ref.watch(galleryPermissionNotifier.notifier),
- ref.watch(dbProvider),
- ref,
- );
- });
|