Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

View file

@ -0,0 +1,33 @@
import 'package:cancellation_token_http/http.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
/// Tracks per-asset upload progress.
/// Key: local asset ID, Value: upload progress 0.0 to 1.0, or -1.0 for error
class AssetUploadProgressNotifier extends Notifier<Map<String, double>> {
static const double errorValue = -1.0;
@override
Map<String, double> build() => {};
void setProgress(String localAssetId, double progress) {
state = {...state, localAssetId: progress};
}
void setError(String localAssetId) {
state = {...state, localAssetId: errorValue};
}
void remove(String localAssetId) {
state = Map.from(state)..remove(localAssetId);
}
void clear() {
state = {};
}
}
final assetUploadProgressProvider = NotifierProvider<AssetUploadProgressNotifier, Map<String, double>>(
AssetUploadProgressNotifier.new,
);
final manualUploadCancelTokenProvider = StateProvider<CancellationToken?>((ref) => null);

View file

@ -0,0 +1,670 @@
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
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,
);
});
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,
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: '...',
fileSize: 0,
iCloudAsset: false,
),
iCloudDownloadProgress: 0.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;
///
/// 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
state = state.copyWith(cancelToken: CancellationToken());
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
pmProgressHandler?.stream.listen((event) {
final double progress = event.progress;
state = state.copyWith(iCloudDownloadProgress: progress);
});
await _backupService.backupAsset(
assetsWillBeBackup,
state.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();
}
state.cancelToken.cancel();
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);
}
}
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);
}
}

View file

@ -0,0 +1,59 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/services/local_album.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
final backupAlbumProvider = StateNotifierProvider<BackupAlbumNotifier, List<LocalAlbum>>(
(ref) => BackupAlbumNotifier(ref.watch(localAlbumServiceProvider)),
);
class BackupAlbumNotifier extends StateNotifier<List<LocalAlbum>> {
BackupAlbumNotifier(this._localAlbumService) : super([]) {
getAll();
}
final LocalAlbumService _localAlbumService;
Future<void> getAll() async {
state = await _localAlbumService.getAll(sortBy: {SortLocalAlbumsBy.assetCount});
}
Future<void> selectAlbum(LocalAlbum album) async {
album = album.copyWith(backupSelection: BackupSelection.selected);
await _localAlbumService.update(album);
state = state
.map(
(currentAlbum) => currentAlbum.id == album.id
? currentAlbum.copyWith(backupSelection: BackupSelection.selected)
: currentAlbum,
)
.toList();
}
Future<void> deselectAlbum(LocalAlbum album) async {
album = album.copyWith(backupSelection: BackupSelection.none);
await _localAlbumService.update(album);
state = state
.map(
(currentAlbum) =>
currentAlbum.id == album.id ? currentAlbum.copyWith(backupSelection: BackupSelection.none) : currentAlbum,
)
.toList();
}
Future<void> excludeAlbum(LocalAlbum album) async {
album = album.copyWith(backupSelection: BackupSelection.excluded);
await _localAlbumService.update(album);
state = state
.map(
(currentAlbum) => currentAlbum.id == album.id
? currentAlbum.copyWith(backupSelection: BackupSelection.excluded)
: currentAlbum,
)
.toList();
}
}

View file

@ -0,0 +1,101 @@
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;
}
}
}

View file

@ -0,0 +1,27 @@
// 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

View file

@ -0,0 +1,411 @@
import 'dart:async';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
class EnqueueStatus {
final int enqueueCount;
final int totalCount;
const EnqueueStatus({required this.enqueueCount, required this.totalCount});
EnqueueStatus copyWith({int? enqueueCount, int? totalCount}) {
return EnqueueStatus(enqueueCount: enqueueCount ?? this.enqueueCount, totalCount: totalCount ?? this.totalCount);
}
@override
String toString() => 'EnqueueStatus(enqueueCount: $enqueueCount, totalCount: $totalCount)';
}
class DriftUploadStatus {
final String taskId;
final String filename;
final double progress;
final int fileSize;
final String networkSpeedAsString;
final bool? isFailed;
final String? error;
const DriftUploadStatus({
required this.taskId,
required this.filename,
required this.progress,
required this.fileSize,
required this.networkSpeedAsString,
this.isFailed,
this.error,
});
DriftUploadStatus copyWith({
String? taskId,
String? filename,
double? progress,
int? fileSize,
String? networkSpeedAsString,
bool? isFailed,
String? error,
}) {
return DriftUploadStatus(
taskId: taskId ?? this.taskId,
filename: filename ?? this.filename,
progress: progress ?? this.progress,
fileSize: fileSize ?? this.fileSize,
networkSpeedAsString: networkSpeedAsString ?? this.networkSpeedAsString,
isFailed: isFailed ?? this.isFailed,
error: error ?? this.error,
);
}
@override
String toString() {
return 'DriftUploadStatus(taskId: $taskId, filename: $filename, progress: $progress, fileSize: $fileSize, networkSpeedAsString: $networkSpeedAsString, isFailed: $isFailed, error: $error)';
}
@override
bool operator ==(covariant DriftUploadStatus other) {
if (identical(this, other)) return true;
return other.taskId == taskId &&
other.filename == filename &&
other.progress == progress &&
other.fileSize == fileSize &&
other.networkSpeedAsString == networkSpeedAsString &&
other.isFailed == isFailed &&
other.error == error;
}
@override
int get hashCode {
return taskId.hashCode ^
filename.hashCode ^
progress.hashCode ^
fileSize.hashCode ^
networkSpeedAsString.hashCode ^
isFailed.hashCode ^
error.hashCode;
}
}
enum BackupError { none, syncFailed }
class DriftBackupState {
final int totalCount;
final int backupCount;
final int remainderCount;
final int processingCount;
final bool isSyncing;
final BackupError error;
final Map<String, DriftUploadStatus> uploadItems;
final CancellationToken? cancelToken;
final Map<String, double> iCloudDownloadProgress;
const DriftBackupState({
required this.totalCount,
required this.backupCount,
required this.remainderCount,
required this.processingCount,
required this.isSyncing,
this.error = BackupError.none,
required this.uploadItems,
this.cancelToken,
this.iCloudDownloadProgress = const {},
});
DriftBackupState copyWith({
int? totalCount,
int? backupCount,
int? remainderCount,
int? processingCount,
bool? isSyncing,
BackupError? error,
Map<String, DriftUploadStatus>? uploadItems,
CancellationToken? cancelToken,
Map<String, double>? iCloudDownloadProgress,
}) {
return DriftBackupState(
totalCount: totalCount ?? this.totalCount,
backupCount: backupCount ?? this.backupCount,
remainderCount: remainderCount ?? this.remainderCount,
processingCount: processingCount ?? this.processingCount,
isSyncing: isSyncing ?? this.isSyncing,
error: error ?? this.error,
uploadItems: uploadItems ?? this.uploadItems,
cancelToken: cancelToken ?? this.cancelToken,
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
);
}
int get errorCount => uploadItems.values.where((item) => item.isFailed == true).length;
@override
String toString() {
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)';
}
@override
bool operator ==(covariant DriftBackupState other) {
if (identical(this, other)) return true;
final mapEquals = const DeepCollectionEquality().equals;
return other.totalCount == totalCount &&
other.backupCount == backupCount &&
other.remainderCount == remainderCount &&
other.processingCount == processingCount &&
other.isSyncing == isSyncing &&
other.error == error &&
mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) &&
mapEquals(other.uploadItems, uploadItems) &&
other.cancelToken == cancelToken;
}
@override
int get hashCode {
return totalCount.hashCode ^
backupCount.hashCode ^
remainderCount.hashCode ^
processingCount.hashCode ^
isSyncing.hashCode ^
error.hashCode ^
uploadItems.hashCode ^
cancelToken.hashCode ^
iCloudDownloadProgress.hashCode;
}
}
final driftBackupProvider = StateNotifierProvider<DriftBackupNotifier, DriftBackupState>((ref) {
return DriftBackupNotifier(
ref.watch(foregroundUploadServiceProvider),
ref.watch(backgroundUploadServiceProvider),
UploadSpeedManager(),
);
});
class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
DriftBackupNotifier(this._foregroundUploadService, this._backgroundUploadService, this._uploadSpeedManager)
: super(
const DriftBackupState(
totalCount: 0,
backupCount: 0,
remainderCount: 0,
processingCount: 0,
isSyncing: false,
uploadItems: {},
error: BackupError.none,
),
);
final ForegroundUploadService _foregroundUploadService;
final BackgroundUploadService _backgroundUploadService;
final UploadSpeedManager _uploadSpeedManager;
final _logger = Logger("DriftBackupNotifier");
/// Remove upload item from state
void _removeUploadItem(String taskId) {
if (!mounted) {
_logger.warning("Skip _removeUploadItem: notifier disposed");
return;
}
if (state.uploadItems.containsKey(taskId)) {
final updatedItems = Map<String, DriftUploadStatus>.from(state.uploadItems);
updatedItems.remove(taskId);
state = state.copyWith(uploadItems: updatedItems);
}
}
Future<void> getBackupStatus(String userId) async {
if (!mounted) {
_logger.warning("Skip getBackupStatus (pre-call): notifier disposed");
return;
}
final counts = await _foregroundUploadService.getBackupCounts(userId);
if (!mounted) {
_logger.warning("Skip getBackupStatus (post-call): notifier disposed");
return;
}
state = state.copyWith(
totalCount: counts.total,
backupCount: counts.total - counts.remainder,
remainderCount: counts.remainder,
processingCount: counts.processing,
);
}
void updateError(BackupError error) async {
if (!mounted) {
_logger.warning("Skip updateError: notifier disposed");
return;
}
state = state.copyWith(error: error);
}
void updateSyncing(bool isSyncing) async {
state = state.copyWith(isSyncing: isSyncing);
}
Future<void> startForegroundBackup(String userId) async {
state = state.copyWith(error: BackupError.none);
final cancelToken = CancellationToken();
state = state.copyWith(cancelToken: cancelToken);
return _foregroundUploadService.uploadCandidates(
userId,
cancelToken,
callbacks: UploadCallbacks(
onProgress: _handleForegroundBackupProgress,
onSuccess: _handleForegroundBackupSuccess,
onError: _handleForegroundBackupError,
onICloudProgress: _handleICloudProgress,
),
);
}
Future<void> stopForegroundBackup() async {
state.cancelToken?.cancel();
_uploadSpeedManager.clear();
state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {});
}
void _handleICloudProgress(String localAssetId, double progress) {
state = state.copyWith(iCloudDownloadProgress: {...state.iCloudDownloadProgress, localAssetId: progress});
if (progress >= 1.0) {
Future.delayed(const Duration(milliseconds: 250), () {
final updatedProgress = Map<String, double>.from(state.iCloudDownloadProgress);
updatedProgress.remove(localAssetId);
state = state.copyWith(iCloudDownloadProgress: updatedProgress);
});
}
}
void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) {
if (state.cancelToken == null) {
return;
}
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
final networkSpeedAsString = _uploadSpeedManager.updateProgress(localAssetId, bytes, totalBytes);
final currentItem = state.uploadItems[localAssetId];
if (currentItem != null) {
state = state.copyWith(
uploadItems: {
...state.uploadItems,
localAssetId: currentItem.copyWith(
filename: filename,
progress: progress,
fileSize: totalBytes,
networkSpeedAsString: networkSpeedAsString,
),
},
);
} else {
state = state.copyWith(
uploadItems: {
...state.uploadItems,
localAssetId: DriftUploadStatus(
taskId: localAssetId,
filename: filename,
progress: progress,
fileSize: totalBytes,
networkSpeedAsString: networkSpeedAsString,
),
},
);
}
}
void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) {
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
_uploadSpeedManager.removeTask(localAssetId);
Future.delayed(const Duration(milliseconds: 1000), () {
_removeUploadItem(localAssetId);
});
}
void _handleForegroundBackupError(String localAssetId, String errorMessage) {
_logger.severe("Upload failed for $localAssetId: $errorMessage");
final currentItem = state.uploadItems[localAssetId];
if (currentItem != null) {
state = state.copyWith(
uploadItems: {
...state.uploadItems,
localAssetId: currentItem.copyWith(isFailed: true, error: errorMessage),
},
);
} else {
state = state.copyWith(
uploadItems: {
...state.uploadItems,
localAssetId: DriftUploadStatus(
taskId: localAssetId,
filename: 'Unknown',
progress: 0,
fileSize: 0,
networkSpeedAsString: '',
isFailed: true,
error: errorMessage,
),
},
);
}
_uploadSpeedManager.removeTask(localAssetId);
}
Future<void> startBackupWithURLSession(String userId) async {
if (!mounted) {
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
return;
}
_logger.info("Resuming backup tasks...");
state = state.copyWith(error: BackupError.none);
final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup);
if (!mounted) {
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
return;
}
_logger.info("Found ${tasks.length} tasks");
if (tasks.isEmpty) {
_logger.info("Start backup with URLSession");
return _backgroundUploadService.uploadBackupCandidates(userId);
}
_logger.info("Tasks to resume: ${tasks.length}");
return _backgroundUploadService.resume();
}
}
final driftBackupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>>((ref) async {
final user = ref.watch(currentUserProvider);
if (user == null) {
return [];
}
return ref.read(foregroundUploadServiceProvider).getBackupCandidates(user.id, onlyHashed: false);
});
final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family<List<LocalAlbum>, String>((
ref,
assetId,
) {
return ref.read(localAssetRepository).getSourceAlbums(assetId, backupSelection: BackupSelection.selected);
});

View file

@ -0,0 +1,22 @@
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(),
);

View file

@ -0,0 +1,54 @@
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)),
);

View file

@ -0,0 +1,390 @@
import 'dart:async';
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
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;
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,
cancelToken: CancellationToken(),
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: '...',
),
cancelToken: CancellationToken(),
);
// 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;
final bool ok = await ref
.read(backupServiceProvider)
.backupAsset(
uploadAssets,
state.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 && state.cancelToken.isCancelled) {
await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(),
"backup_manual_cancelled".tr(),
presentBanner: true,
);
hasErrors = true;
} else if (state.successfulUploads == 0 || (!ok && !state.cancelToken.isCancelled)) {
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();
}
state.cancelToken.cancel();
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);
}
}