import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:cancellation_token_http/http.dart' as http; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.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/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:path/path.dart' as p; import 'package:permission_handler/permission_handler.dart' as pm; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; final backupServiceProvider = Provider( (ref) => BackupService( ref.watch(apiServiceProvider), ref.watch(appSettingsServiceProvider), ref.watch(albumServiceProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(fileMediaRepositoryProvider), ref.watch(assetRepositoryProvider), ref.watch(assetMediaRepositoryProvider), ), ); class BackupService { final httpClient = http.Client(); final ApiService _apiService; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; final AlbumService _albumService; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; final IAssetRepository _assetRepository; final IAssetMediaRepository _assetMediaRepository; BackupService( this._apiService, this._appSetting, this._albumService, this._albumMediaRepository, this._fileMediaRepository, this._assetRepository, this._assetMediaRepository, ); Future?> getDeviceBackupAsset() async { final String deviceId = Store.get(StoreKey.deviceId); try { return await _apiService.assetsApi.getAllUserAssetsByDeviceId(deviceId); } catch (e) { debugPrint('Error [getDeviceBackupAsset] ${e.toString()}'); return null; } } Future _saveDuplicatedAssetIds(List deviceAssetIds) => _assetRepository.transaction( () => _assetRepository.upsertDuplicatedAssets(deviceAssetIds), ); /// Get duplicated asset id from database Future> getDuplicatedAssetIds() async { final duplicates = await _assetRepository.getAllDuplicatedAssetIds(); return duplicates.toSet(); } /// Returns all assets newer than the last successful backup per album /// if `useTimeFilter` is set to true, all assets will be returned Future> buildUploadCandidates( List selectedBackupAlbums, List excludedBackupAlbums, { bool useTimeFilter = true, }) async { final now = DateTime.now(); final Set toAdd = await _fetchAssetsAndUpdateLastBackup( selectedBackupAlbums, now, useTimeFilter: useTimeFilter, ); if (toAdd.isEmpty) return {}; final Set toRemove = await _fetchAssetsAndUpdateLastBackup( excludedBackupAlbums, now, useTimeFilter: useTimeFilter, ); return toAdd.difference(toRemove); } Future> _fetchAssetsAndUpdateLastBackup( List backupAlbums, DateTime now, { bool useTimeFilter = true, }) async { Set candidates = {}; for (final BackupAlbum backupAlbum in backupAlbums) { final Album localAlbum; try { localAlbum = await _albumMediaRepository.get(backupAlbum.id); } on StateError { // the album no longer exists continue; } if (useTimeFilter && localAlbum.modifiedAt.isBefore(backupAlbum.lastBackup)) { continue; } final List assets; try { assets = await _albumMediaRepository.getAssets( backupAlbum.id, modifiedFrom: useTimeFilter ? // subtract 2 seconds to prevent missing assets due to rounding issues backupAlbum.lastBackup.subtract(const Duration(seconds: 2)) : null, modifiedUntil: useTimeFilter ? now : null, ); } on StateError { // either there are no assets matching the filter criteria OR the album no longer exists continue; } // Add album's name to the asset info for (final asset in assets) { List albumNames = [localAlbum.name]; final existingAsset = candidates.firstWhereOrNull( (candidate) => candidate.asset.localId == asset.localId, ); if (existingAsset != null) { albumNames.addAll(existingAsset.albumNames); candidates.remove(existingAsset); } candidates.add(BackupCandidate(asset: asset, albumNames: albumNames)); } backupAlbum.lastBackup = now; } return candidates; } /// Returns a new list of assets not yet uploaded Future> removeAlreadyUploadedAssets( Set candidates, ) async { if (candidates.isEmpty) { return candidates; } final Set duplicatedAssetIds = await getDuplicatedAssetIds(); candidates.removeWhere( (candidate) => duplicatedAssetIds.contains(candidate.asset.localId), ); if (candidates.isEmpty) { return candidates; } final Set existing = {}; try { final String deviceId = Store.get(StoreKey.deviceId); final CheckExistingAssetsResponseDto? duplicates = await _apiService.assetsApi.checkExistingAssets( CheckExistingAssetsDto( deviceAssetIds: candidates.map((c) => c.asset.localId!).toList(), deviceId: deviceId, ), ); if (duplicates != null) { existing.addAll(duplicates.existingIds); } } on ApiException { // workaround for older server versions or when checking for too many assets at once final List? allAssetsInDatabase = await getDeviceBackupAsset(); if (allAssetsInDatabase != null) { existing.addAll(allAssetsInDatabase); } } if (existing.isNotEmpty) { candidates.removeWhere((c) => existing.contains(c.asset.localId)); } return candidates; } Future _checkPermissions() async { if (Platform.isAndroid && !(await pm.Permission.accessMediaLocation.status).isGranted) { // double check that permission is granted here, to guard against // uploading corrupt assets without EXIF information _log.warning("Media location permission is not granted. " "Cannot access original assets for backup."); return false; } // DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS if (Platform.isIOS) { await _fileMediaRepository.requestExtendedPermissions(); } return true; } /// Upload images before video assets for background tasks /// these are further sorted by using their creation date List _sortPhotosFirst(List candidates) { return candidates.sorted( (a, b) { final cmp = a.asset.type.index - b.asset.type.index; if (cmp != 0) return cmp; return a.asset.fileCreatedAt.compareTo(b.asset.fileCreatedAt); }, ); } Future backupAsset( Iterable assets, http.CancellationToken cancelToken, { bool isBackground = false, PMProgressHandler? pmProgressHandler, required void Function(SuccessUploadAsset result) onSuccess, required void Function(int bytes, int totalBytes) onProgress, required void Function(CurrentUploadAsset asset) onCurrentAsset, required void Function(ErrorUploadAsset error) onError, }) async { final bool isIgnoreIcloudAssets = _appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets); final shouldSyncAlbums = _appSetting.getSetting(AppSettingsEnum.syncAlbums); final String deviceId = Store.get(StoreKey.deviceId); final String savedEndpoint = Store.get(StoreKey.serverEndpoint); final List duplicatedAssetIds = []; bool anyErrors = false; final hasPermission = await _checkPermissions(); if (!hasPermission) { return false; } List candidates = assets.toList(); if (isBackground) { candidates = _sortPhotosFirst(candidates); } for (final candidate in candidates) { final Asset asset = candidate.asset; File? file; File? livePhotoFile; try { final isAvailableLocally = await asset.local!.isLocallyAvailable(isOrigin: true); // Handle getting files from iCloud if (!isAvailableLocally && Platform.isIOS) { // Skip iCloud assets if the user has disabled this feature if (isIgnoreIcloudAssets) { continue; } onCurrentAsset( CurrentUploadAsset( id: asset.localId!, fileCreatedAt: asset.fileCreatedAt.year == 1970 ? asset.fileModifiedAt : asset.fileCreatedAt, fileName: asset.fileName, fileType: _getAssetType(asset.type), iCloudAsset: true, ), ); file = await asset.local!.loadFile(progressHandler: pmProgressHandler); if (asset.local!.isLivePhoto) { livePhotoFile = await asset.local!.loadFile( withSubtype: true, progressHandler: pmProgressHandler, ); } } else { file = await asset.local!.originFile.timeout(const Duration(seconds: 5)); if (asset.local!.isLivePhoto) { livePhotoFile = await asset.local!.originFileWithSubtype .timeout(const Duration(seconds: 5)); } } if (file != null) { String? originalFileName = await _assetMediaRepository.getOriginalFilename(asset.localId!); originalFileName ??= asset.fileName; if (asset.local!.isLivePhoto) { if (livePhotoFile == null) { _log.warning( "Failed to obtain motion part of the livePhoto - $originalFileName", ); } } final fileStream = file.openRead(); final assetRawUploadData = http.MultipartFile( "assetData", fileStream, file.lengthSync(), filename: originalFileName, ); final baseRequest = MultipartRequest( 'POST', Uri.parse('$savedEndpoint/assets'), onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)), ); baseRequest.headers.addAll(ApiService.getRequestHeaders()); baseRequest.headers["Transfer-Encoding"] = "chunked"; baseRequest.fields['deviceAssetId'] = asset.localId!; baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['fileCreatedAt'] = asset.fileCreatedAt.toUtc().toIso8601String(); baseRequest.fields['fileModifiedAt'] = asset.fileModifiedAt.toUtc().toIso8601String(); baseRequest.fields['isFavorite'] = asset.isFavorite.toString(); baseRequest.fields['duration'] = asset.duration.toString(); baseRequest.files.add(assetRawUploadData); onCurrentAsset( CurrentUploadAsset( id: asset.localId!, fileCreatedAt: asset.fileCreatedAt.year == 1970 ? asset.fileModifiedAt : asset.fileCreatedAt, fileName: originalFileName, fileType: _getAssetType(asset.type), fileSize: file.lengthSync(), iCloudAsset: false, ), ); String? livePhotoVideoId; if (asset.local!.isLivePhoto && livePhotoFile != null) { livePhotoVideoId = await uploadLivePhotoVideo( originalFileName, livePhotoFile, baseRequest, cancelToken, ); } if (livePhotoVideoId != null) { baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; } final response = await httpClient.send( baseRequest, cancellationToken: cancelToken, ); final responseBody = jsonDecode(await response.stream.bytesToString()); if (![200, 201].contains(response.statusCode)) { final error = responseBody; final errorMessage = error['message'] ?? error['error']; debugPrint( "Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}", ); onError( ErrorUploadAsset( asset: asset, id: asset.localId!, fileCreatedAt: asset.fileCreatedAt, fileName: originalFileName, fileType: _getAssetType(candidate.asset.type), errorMessage: errorMessage, ), ); if (errorMessage == "Quota has been exceeded!") { anyErrors = true; break; } continue; } bool isDuplicate = false; if (response.statusCode == 200) { isDuplicate = true; duplicatedAssetIds.add(asset.localId!); } onSuccess( SuccessUploadAsset( candidate: candidate, remoteAssetId: responseBody['id'] as String, isDuplicate: isDuplicate, ), ); if (shouldSyncAlbums) { await _albumService.syncUploadAlbums( candidate.albumNames, [responseBody['id'] as String], ); } } } on http.CancelledException { debugPrint("Backup was cancelled by the user"); anyErrors = true; break; } catch (error, stackTrace) { debugPrint("Error backup asset: ${error.toString()}: $stackTrace"); anyErrors = true; continue; } finally { if (Platform.isIOS) { try { await file?.delete(); await livePhotoFile?.delete(); } catch (e) { debugPrint("ERROR deleting file: ${e.toString()}"); } } } } if (duplicatedAssetIds.isNotEmpty) { await _saveDuplicatedAssetIds(duplicatedAssetIds); } return !anyErrors; } Future uploadLivePhotoVideo( String originalFileName, File? livePhotoVideoFile, MultipartRequest baseRequest, http.CancellationToken cancelToken, ) async { if (livePhotoVideoFile == null) { return null; } final livePhotoTitle = p.setExtension( originalFileName, p.extension(livePhotoVideoFile.path), ); final fileStream = livePhotoVideoFile.openRead(); final livePhotoRawUploadData = http.MultipartFile( "assetData", fileStream, livePhotoVideoFile.lengthSync(), filename: livePhotoTitle, ); final livePhotoReq = MultipartRequest( baseRequest.method, baseRequest.url, onProgress: baseRequest.onProgress, ) ..headers.addAll(baseRequest.headers) ..fields.addAll(baseRequest.fields); livePhotoReq.files.add(livePhotoRawUploadData); var response = await httpClient.send( livePhotoReq, cancellationToken: cancelToken, ); var responseBody = jsonDecode(await response.stream.bytesToString()); if (![200, 201].contains(response.statusCode)) { var error = responseBody; debugPrint( "Error(${error['statusCode']}) uploading livePhoto for assetId | $livePhotoTitle | ${error['error']}", ); } return responseBody.containsKey('id') ? responseBody['id'] : null; } String _getAssetType(AssetType assetType) { switch (assetType) { case AssetType.audio: return "AUDIO"; case AssetType.image: return "IMAGE"; case AssetType.video: return "VIDEO"; case AssetType.other: return "OTHER"; } } } class MultipartRequest extends http.MultipartRequest { /// Creates a new [MultipartRequest]. MultipartRequest( super.method, super.url, { required this.onProgress, }); final void Function(int bytes, int totalBytes) onProgress; /// Freezes all mutable fields and returns a /// single-subscription [http.ByteStream] /// that will emit the request body. @override http.ByteStream finalize() { final byteStream = super.finalize(); final total = contentLength; var bytes = 0; final t = StreamTransformer.fromHandlers( handleData: (List data, EventSink> sink) { bytes += data.length; onProgress.call(bytes, total); sink.add(data); }, ); final stream = byteStream.transform(t); return http.ByteStream(stream); } }