1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-28 06:32:44 +01:00

feat(mobile): preserve mobile album info on upload ()

* curating assets with albums to upload

* sorting for background backup

* background upload works

* transform fields string array to javascript array

* send json array

* generate sql

* refactor upload callback

* remove albums info from upload payload

* mechanism to create album on album selection

* album creation

* Sync to upload album

* Remove unused service

* unify name changes

* Add mechanism to sync uploaded assets to albums

* Put add to album operation after updating the UI state

* clean up

* background album sync

* add to album in background context

* remove add to album in callback

* refactor

* refactor

* refactor

* fix: make sure all selected albums are selected for building upload candidate

* clean up

* add manual sync button

* lint

* revert server changes

* pr feedback

* revert time filtering

* const

* sync album on manual upload

* linting

* pr feedback and proper time filtering

* wording
This commit is contained in:
Alex 2024-08-26 13:21:19 -05:00 committed by GitHub
parent f4371578f5
commit 6b6d2a6621
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 657 additions and 233 deletions

View file

@ -573,5 +573,9 @@
"version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_2": "please take your time to visit the ",
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
"viewer_unstack": "Un-Stack" "viewer_unstack": "Un-Stack",
"sync_albums": "Sync albums",
"sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich",
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
"sync": "Sync"
} }

View file

@ -234,6 +234,8 @@ enum StoreKey<T> {
primaryColor<String>(128, type: String), primaryColor<String>(128, type: String),
dynamicTheme<bool>(129, type: bool), dynamicTheme<bool>(129, type: bool),
colorfulInterface<bool>(130, type: bool), colorfulInterface<bool>(130, type: bool),
syncAlbums<bool>(131, type: bool),
; ;
const StoreKey( const StoreKey(

View file

@ -0,0 +1,19 @@
import 'package:photo_manager/photo_manager.dart';
class BackupCandidate {
BackupCandidate({required this.asset, required this.albumNames});
AssetEntity asset;
List<String> albumNames;
@override
int get hashCode => asset.hashCode;
@override
bool operator ==(Object other) {
if (other is! BackupCandidate) {
return false;
}
return asset == other.asset;
}
}

View file

@ -2,7 +2,7 @@
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
@ -41,7 +41,7 @@ class BackUpState {
final Set<AvailableAlbum> excludedBackupAlbums; final Set<AvailableAlbum> excludedBackupAlbums;
/// Assets that are not overlapping in selected backup albums and excluded backup albums /// Assets that are not overlapping in selected backup albums and excluded backup albums
final Set<AssetEntity> allUniqueAssets; final Set<BackupCandidate> allUniqueAssets;
/// All assets from the selected albums that have been backup /// All assets from the selected albums that have been backup
final Set<String> selectedAlbumsBackupAssetsIds; final Set<String> selectedAlbumsBackupAssetsIds;
@ -94,7 +94,7 @@ class BackUpState {
List<AvailableAlbum>? availableAlbums, List<AvailableAlbum>? availableAlbums,
Set<AvailableAlbum>? selectedBackupAlbums, Set<AvailableAlbum>? selectedBackupAlbums,
Set<AvailableAlbum>? excludedBackupAlbums, Set<AvailableAlbum>? excludedBackupAlbums,
Set<AssetEntity>? allUniqueAssets, Set<BackupCandidate>? allUniqueAssets,
Set<String>? selectedAlbumsBackupAssetsIds, Set<String>? selectedAlbumsBackupAssetsIds,
CurrentUploadAsset? currentUploadAsset, CurrentUploadAsset? currentUploadAsset,
}) { }) {

View file

@ -0,0 +1,42 @@
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
class SuccessUploadAsset {
final BackupCandidate candidate;
final String remoteAssetId;
final bool isDuplicate;
SuccessUploadAsset({
required this.candidate,
required this.remoteAssetId,
required this.isDuplicate,
});
SuccessUploadAsset copyWith({
BackupCandidate? candidate,
String? remoteAssetId,
bool? isDuplicate,
}) {
return SuccessUploadAsset(
candidate: candidate ?? this.candidate,
remoteAssetId: remoteAssetId ?? this.remoteAssetId,
isDuplicate: isDuplicate ?? this.isDuplicate,
);
}
@override
String toString() =>
'SuccessUploadAsset(asset: $candidate, remoteAssetId: $remoteAssetId, isDuplicate: $isDuplicate)';
@override
bool operator ==(covariant SuccessUploadAsset other) {
if (identical(this, other)) return true;
return other.candidate == candidate &&
other.remoteAssetId == remoteAssetId &&
other.isDuplicate == isDuplicate;
}
@override
int get hashCode =>
candidate.hashCode ^ remoteAssetId.hashCode ^ isDuplicate.hashCode;
}

View file

@ -4,19 +4,24 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/backup/album_info_card.dart'; import 'package:immich_mobile/widgets/backup/album_info_card.dart';
import 'package:immich_mobile/widgets/backup/album_info_list_tile.dart'; import 'package:immich_mobile/widgets/backup/album_info_list_tile.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@RoutePage() @RoutePage()
class BackupAlbumSelectionPage extends HookConsumerWidget { class BackupAlbumSelectionPage extends HookConsumerWidget {
const BackupAlbumSelectionPage({super.key}); const BackupAlbumSelectionPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// final availableAlbums = ref.watch(backupProvider).availableAlbums;
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums; final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums; final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
final enableSyncUploadAlbum =
useAppSettingsState(AppSettingsEnum.syncAlbums);
final isDarkTheme = context.isDarkTheme; final isDarkTheme = context.isDarkTheme;
final albums = ref.watch(backupProvider).availableAlbums; final albums = ref.watch(backupProvider).availableAlbums;
@ -144,47 +149,14 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
}).toSet(); }).toSet();
} }
// buildSearchBar() { handleSyncAlbumToggle(bool isEnable) async {
// return Padding( if (isEnable) {
// padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0), await ref.read(albumProvider.notifier).getAllAlbums();
// child: TextFormField( for (final album in selectedBackupAlbums) {
// onChanged: (searchValue) { await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
// // if (searchValue.isEmpty) { }
// // albums = availableAlbums; }
// // } else { }
// // albums.value = availableAlbums
// // .where(
// // (album) => album.name
// // .toLowerCase()
// // .contains(searchValue.toLowerCase()),
// // )
// // .toList();
// // }
// },
// decoration: InputDecoration(
// contentPadding: const EdgeInsets.symmetric(
// horizontal: 8.0,
// vertical: 8.0,
// ),
// hintText: "Search",
// hintStyle: TextStyle(
// color: isDarkTheme ? Colors.white : Colors.grey,
// fontSize: 14.0,
// ),
// prefixIcon: const Icon(
// Icons.search,
// color: Colors.grey,
// ),
// border: OutlineInputBorder(
// borderRadius: BorderRadius.circular(10),
// borderSide: BorderSide.none,
// ),
// filled: true,
// fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
// ),
// ),
// );
// }
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -226,6 +198,20 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
), ),
), ),
SettingsSwitchListTile(
valueNotifier: enableSyncUploadAlbum,
title: "sync_albums".tr(),
subtitle: "sync_upload_album_setting_subtitle".tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
titleStyle: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
subtitleStyle: context.textTheme.labelLarge?.copyWith(
color: context.colorScheme.primary,
),
onChanged: handleSyncAlbumToggle,
),
ListTile( ListTile(
title: Text( title: Text(
"backup_album_selection_page_albums_device".tr( "backup_album_selection_page_albums_device".tr(

View file

@ -23,6 +23,7 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
}); });
_streamSub = query.watch().listen((data) => state = data); _streamSub = query.watch().listen((data) => state = data);
} }
final AlbumService _albumService; final AlbumService _albumService;
late final StreamSubscription<List<Album>> _streamSub; late final StreamSubscription<List<Album>> _streamSub;
@ -41,6 +42,23 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
) => ) =>
_albumService.createAlbum(albumTitle, assets, []); _albumService.createAlbum(albumTitle, assets, []);
Future<Album?> getAlbumByName(String albumName, {bool remoteOnly = false}) =>
_albumService.getAlbumByName(albumName, remoteOnly);
/// Create an album on the server with the same name as the selected album for backup
/// First this will check if the album already exists on the server with name
/// If it does not exist, it will create the album on the server
Future<void> createSyncAlbum(
String albumName,
) async {
final album = await getAlbumByName(albumName, remoteOnly: true);
if (album != null) {
return;
}
await createAlbum(albumName, {});
}
@override @override
void dispose() { void dispose() {
_streamSub.cancel(); _streamSub.cancel();

View file

@ -2,13 +2,16 @@ import 'dart:io';
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/entities/backup_album.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/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.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/error_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/backup.service.dart';
@ -290,8 +293,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// ///
Future<void> _updateBackupAssetCount() async { Future<void> _updateBackupAssetCount() async {
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds(); final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
final Set<AssetEntity> assetsFromSelectedAlbums = {}; final Set<BackupCandidate> assetsFromSelectedAlbums = {};
final Set<AssetEntity> assetsFromExcludedAlbums = {}; final Set<BackupCandidate> assetsFromExcludedAlbums = {};
for (final album in state.selectedBackupAlbums) { for (final album in state.selectedBackupAlbums) {
final assetCount = await album.albumEntity.assetCountAsync; final assetCount = await album.albumEntity.assetCountAsync;
@ -304,7 +307,27 @@ class BackupNotifier extends StateNotifier<BackUpState> {
start: 0, start: 0,
end: assetCount, end: assetCount,
); );
assetsFromSelectedAlbums.addAll(assets);
// 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.id == asset.id,
);
if (existingAsset != null) {
albumNames.addAll(existingAsset.albumNames);
assetsFromSelectedAlbums.remove(existingAsset);
}
assetsFromSelectedAlbums.add(
BackupCandidate(
asset: asset,
albumNames: albumNames,
),
);
}
} }
for (final album in state.excludedBackupAlbums) { for (final album in state.excludedBackupAlbums) {
@ -318,11 +341,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
start: 0, start: 0,
end: assetCount, end: assetCount,
); );
assetsFromExcludedAlbums.addAll(assets);
for (final asset in assets) {
assetsFromExcludedAlbums.add(
BackupCandidate(asset: asset, albumNames: [album.name]),
);
}
} }
final Set<AssetEntity> allUniqueAssets = final Set<BackupCandidate> allUniqueAssets =
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums); assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset(); final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
if (allAssetsInDatabase == null) { if (allAssetsInDatabase == null) {
@ -331,14 +360,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Find asset that were backup from selected albums // Find asset that were backup from selected albums
final Set<String> selectedAlbumsBackupAssets = final Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.id)); Set.from(allUniqueAssets.map((e) => e.asset.id));
selectedAlbumsBackupAssets selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId)); .removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets // Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere( allUniqueAssets.removeWhere(
(asset) => duplicatedAssetIds.contains(asset.id), (candidate) => duplicatedAssetIds.contains(candidate.asset.id),
); );
if (allUniqueAssets.isEmpty) { if (allUniqueAssets.isEmpty) {
@ -433,10 +462,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return; return;
} }
Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets); Set<BackupCandidate> assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up // Remove item that has already been backed up
for (final assetId in state.allAssetsInDatabase) { for (final assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.id == assetId); assetsWillBeBackup.removeWhere((e) => e.asset.id == assetId);
} }
if (assetsWillBeBackup.isEmpty) { if (assetsWillBeBackup.isEmpty) {
@ -456,11 +485,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await _backupService.backupAsset( await _backupService.backupAsset(
assetsWillBeBackup, assetsWillBeBackup,
state.cancelToken, state.cancelToken,
pmProgressHandler, pmProgressHandler: pmProgressHandler,
_onAssetUploaded, onSuccess: _onAssetUploaded,
_onUploadProgress, onProgress: _onUploadProgress,
_onSetCurrentBackupAsset, onCurrentAsset: _onSetCurrentBackupAsset,
_onBackupError, onError: _onBackupError,
); );
await notifyBackgroundServiceCanRun(); await notifyBackgroundServiceCanRun();
} else { } else {
@ -497,34 +526,36 @@ class BackupNotifier extends StateNotifier<BackUpState> {
); );
} }
void _onAssetUploaded( void _onAssetUploaded(SuccessUploadAsset result) async {
String deviceAssetId, if (result.isDuplicate) {
String deviceId,
bool isDuplicated,
) {
if (isDuplicated) {
state = state.copyWith( state = state.copyWith(
allUniqueAssets: state.allUniqueAssets allUniqueAssets: state.allUniqueAssets
.where((asset) => asset.id != deviceAssetId) .where(
(candidate) => candidate.asset.id != result.candidate.asset.id,
)
.toSet(), .toSet(),
); );
} else { } else {
state = state.copyWith( state = state.copyWith(
selectedAlbumsBackupAssetsIds: { selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds, ...state.selectedAlbumsBackupAssetsIds,
deviceAssetId, result.candidate.asset.id,
}, },
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId], allAssetsInDatabase: [
...state.allAssetsInDatabase,
result.candidate.asset.id,
],
); );
} }
if (state.allUniqueAssets.length - if (state.allUniqueAssets.length -
state.selectedAlbumsBackupAssetsIds.length == state.selectedAlbumsBackupAssetsIds.length ==
0) { 0) {
final latestAssetBackup = final latestAssetBackup = state.allUniqueAssets
state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce( .map((candidate) => candidate.asset.modifiedDateTime)
(v, e) => e.isAfter(v) ? e : v, .reduce(
); (v, e) => e.isAfter(v) ? e : v,
);
state = state.copyWith( state = state.copyWith(
selectedBackupAlbums: state.selectedBackupAlbums selectedBackupAlbums: state.selectedBackupAlbums
.map((e) => e.copyWith(lastBackup: latestAssetBackup)) .map((e) => e.copyWith(lastBackup: latestAssetBackup))

View file

@ -6,6 +6,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/models/backup/backup_state.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/current_upload_asset.model.dart';
@ -22,6 +24,7 @@ import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
@ -31,6 +34,7 @@ final manualUploadProvider =
return ManualUploadNotifier( return ManualUploadNotifier(
ref.watch(localNotificationService), ref.watch(localNotificationService),
ref.watch(backupProvider.notifier), ref.watch(backupProvider.notifier),
ref.watch(backupServiceProvider),
ref, ref,
); );
}); });
@ -39,11 +43,13 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
final Logger _log = Logger("ManualUploadNotifier"); final Logger _log = Logger("ManualUploadNotifier");
final LocalNotificationService _localNotificationService; final LocalNotificationService _localNotificationService;
final BackupNotifier _backupProvider; final BackupNotifier _backupProvider;
final BackupService _backupService;
final Ref ref; final Ref ref;
ManualUploadNotifier( ManualUploadNotifier(
this._localNotificationService, this._localNotificationService,
this._backupProvider, this._backupProvider,
this._backupService,
this.ref, this.ref,
) : super( ) : super(
ManualUploadState( ManualUploadState(
@ -115,11 +121,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
} }
} }
void _onAssetUploaded( void _onAssetUploaded(SuccessUploadAsset result) {
String deviceAssetId,
String deviceId,
bool isDuplicated,
) {
state = state.copyWith(successfulUploads: state.successfulUploads + 1); state = state.copyWith(successfulUploads: state.successfulUploads + 1);
_backupProvider.updateDiskInfo(); _backupProvider.updateDiskInfo();
} }
@ -209,9 +211,23 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
); );
} }
Set<AssetEntity> allUploadAssets = allAssetsFromDevice.nonNulls.toSet(); final selectedBackupAlbums =
_backupService.selectedAlbumsQuery().findAllSync();
final excludedBackupAlbums =
_backupService.excludedAlbumsQuery().findAllSync();
if (allUploadAssets.isEmpty) { // Get candidates from selected albums and excluded albums
Set<BackupCandidate> candidates =
await _backupService.buildUploadCandidates(
selectedBackupAlbums,
excludedBackupAlbums,
);
// Extrack candidate from allAssetsFromDevice.nonNulls
final uploadAssets = candidates
.where((e) => allAssetsFromDevice.nonNulls.contains(e.asset));
if (uploadAssets.isEmpty) {
debugPrint("[_startUpload] No Assets to upload - Abort Process"); debugPrint("[_startUpload] No Assets to upload - Abort Process");
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle); _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
return false; return false;
@ -221,7 +237,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
progressInPercentage: 0, progressInPercentage: 0,
progressInFileSize: "0 B / 0 B", progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0, progressInFileSpeed: 0,
totalAssetsToUpload: allUploadAssets.length, totalAssetsToUpload: uploadAssets.length,
successfulUploads: 0, successfulUploads: 0,
currentAssetIndex: 0, currentAssetIndex: 0,
currentUploadAsset: CurrentUploadAsset( currentUploadAsset: CurrentUploadAsset(
@ -250,13 +266,13 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
final bool ok = await ref.read(backupServiceProvider).backupAsset( final bool ok = await ref.read(backupServiceProvider).backupAsset(
allUploadAssets, uploadAssets,
state.cancelToken, state.cancelToken,
pmProgressHandler, pmProgressHandler: pmProgressHandler,
_onAssetUploaded, onSuccess: _onAssetUploaded,
_onProgress, onProgress: _onProgress,
_onSetCurrentBackupAsset, onCurrentAsset: _onSetCurrentBackupAsset,
_onAssetUploadError, onError: _onAssetUploadError,
); );
// Close detailed notification // Close detailed notification

View file

@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
@ -28,7 +27,6 @@ final albumServiceProvider = Provider(
ref.watch(userServiceProvider), ref.watch(userServiceProvider),
ref.watch(syncServiceProvider), ref.watch(syncServiceProvider),
ref.watch(dbProvider), ref.watch(dbProvider),
ref.watch(backupServiceProvider),
), ),
); );
@ -37,7 +35,6 @@ class AlbumService {
final UserService _userService; final UserService _userService;
final SyncService _syncService; final SyncService _syncService;
final Isar _db; final Isar _db;
final BackupService _backupService;
final Logger _log = Logger('AlbumService'); final Logger _log = Logger('AlbumService');
Completer<bool> _localCompleter = Completer()..complete(false); Completer<bool> _localCompleter = Completer()..complete(false);
Completer<bool> _remoteCompleter = Completer()..complete(false); Completer<bool> _remoteCompleter = Completer()..complete(false);
@ -47,9 +44,15 @@ class AlbumService {
this._userService, this._userService,
this._syncService, this._syncService,
this._db, this._db,
this._backupService,
); );
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
selectedAlbumsQuery() =>
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.select);
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
excludedAlbumsQuery() =>
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
/// Checks all selected device albums for changes of albums and their assets /// Checks all selected device albums for changes of albums and their assets
/// Updates the local database and returns `true` if there were any changes /// Updates the local database and returns `true` if there were any changes
Future<bool> refreshDeviceAlbums() async { Future<bool> refreshDeviceAlbums() async {
@ -63,9 +66,9 @@ class AlbumService {
bool changes = false; bool changes = false;
try { try {
final List<String> excludedIds = final List<String> excludedIds =
await _backupService.excludedAlbumsQuery().idProperty().findAll(); await excludedAlbumsQuery().idProperty().findAll();
final List<String> selectedIds = final List<String> selectedIds =
await _backupService.selectedAlbumsQuery().idProperty().findAll(); await selectedAlbumsQuery().idProperty().findAll();
if (selectedIds.isEmpty) { if (selectedIds.isEmpty) {
final numLocal = await _db.albums.where().localIdIsNotNull().count(); final numLocal = await _db.albums.where().localIdIsNotNull().count();
if (numLocal > 0) { if (numLocal > 0) {
@ -441,4 +444,33 @@ class AlbumService {
return false; return false;
} }
} }
Future<Album?> getAlbumByName(String name, bool remoteOnly) async {
return _db.albums
.filter()
.optional(remoteOnly, (q) => q.localIdIsNull())
.nameEqualTo(name)
.sharedEqualTo(false)
.findFirst();
}
///
/// Add the uploaded asset to the selected albums
///
Future<void> syncUploadAlbums(
List<String> albumNames,
List<String> assetIds,
) async {
for (final albumName in albumNames) {
Album? album = await getAlbumByName(albumName, true);
album ??= await createAlbum(albumName, []);
if (album != null && album.remoteId != null) {
await _apiService.albumsApi.addAssetsToAlbum(
album.remoteId!,
BulkIdsDto(ids: assetIds),
);
}
}
}
} }

View file

@ -76,6 +76,7 @@ enum AppSettingsEnum<T> {
false, false,
), ),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true), enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
; ;
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View file

@ -2,15 +2,20 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/services/user.service.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
@ -23,6 +28,8 @@ final assetServiceProvider = Provider(
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(syncServiceProvider), ref.watch(syncServiceProvider),
ref.watch(userServiceProvider), ref.watch(userServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(dbProvider), ref.watch(dbProvider),
), ),
); );
@ -31,6 +38,8 @@ class AssetService {
final ApiService _apiService; final ApiService _apiService;
final SyncService _syncService; final SyncService _syncService;
final UserService _userService; final UserService _userService;
final BackupService _backupService;
final AlbumService _albumService;
final log = Logger('AssetService'); final log = Logger('AssetService');
final Isar _db; final Isar _db;
@ -38,6 +47,8 @@ class AssetService {
this._apiService, this._apiService,
this._syncService, this._syncService,
this._userService, this._userService,
this._backupService,
this._albumService,
this._db, this._db,
); );
@ -284,4 +295,64 @@ class AssetService {
return Future.value(null); return Future.value(null);
} }
} }
Future<void> syncUploadedAssetToAlbums() async {
try {
final [selectedAlbums, excludedAlbums] = await Future.wait([
_backupService.selectedAlbumsQuery().findAll(),
_backupService.excludedAlbumsQuery().findAll(),
]);
final candidates = await _backupService.buildUploadCandidates(
selectedAlbums,
excludedAlbums,
useTimeFilter: false,
);
final duplicates = await _apiService.assetsApi.checkExistingAssets(
CheckExistingAssetsDto(
deviceAssetIds: candidates.map((c) => c.asset.id).toList(),
deviceId: Store.get(StoreKey.deviceId),
),
);
if (duplicates != null) {
candidates
.removeWhere((c) => !duplicates.existingIds.contains(c.asset.id));
}
await refreshRemoteAssets();
final remoteAssets = await _db.assets
.where()
.localIdIsNotNull()
.filter()
.remoteIdIsNotNull()
.findAll();
/// Map<AlbumName, [AssetId]>
Map<String, List<String>> assetToAlbums = {};
for (BackupCandidate candidate in candidates) {
final asset = remoteAssets.firstWhereOrNull(
(a) => a.localId == candidate.asset.id,
);
if (asset != null) {
for (final albumName in candidate.albumNames) {
assetToAlbums.putIfAbsent(albumName, () => []).add(asset.remoteId!);
}
}
}
// Upload assets to albums
for (final entry in assetToAlbums.entries) {
final albumName = entry.key;
final assetIds = entry.value;
await _albumService.syncUploadAlbums([albumName], assetIds);
}
} catch (error, stack) {
log.severe("Error while syncing uploaded asset to albums", error, stack);
}
}
} }

View file

@ -10,6 +10,10 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/main.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
@ -18,6 +22,9 @@ import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/partner.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
@ -345,8 +352,16 @@ class BackgroundService {
ApiService apiService = ApiService(); ApiService apiService = ApiService();
apiService.setAccessToken(Store.get(StoreKey.accessToken)); apiService.setAccessToken(Store.get(StoreKey.accessToken));
AppSettingsService settingService = AppSettingsService(); AppSettingsService settingService = AppSettingsService();
BackupService backupService = BackupService(apiService, db, settingService);
AppSettingsService settingsService = AppSettingsService(); AppSettingsService settingsService = AppSettingsService();
PartnerService partnerService = PartnerService(apiService, db);
HashService hashService = HashService(db, this);
SyncService syncSerive = SyncService(db, hashService);
UserService userService =
UserService(apiService, db, syncSerive, partnerService);
AlbumService albumService =
AlbumService(apiService, userService, syncSerive, db);
BackupService backupService =
BackupService(apiService, db, settingService, albumService);
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
@ -416,7 +431,7 @@ class BackgroundService {
return false; return false;
} }
List<AssetEntity> toUpload = await backupService.buildUploadCandidates( Set<BackupCandidate> toUpload = await backupService.buildUploadCandidates(
selectedAlbums, selectedAlbums,
excludedAlbums, excludedAlbums,
); );
@ -460,29 +475,47 @@ class BackgroundService {
final bool ok = await backupService.backupAsset( final bool ok = await backupService.backupAsset(
toUpload, toUpload,
_cancellationToken!, _cancellationToken!,
pmProgressHandler, pmProgressHandler: pmProgressHandler,
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {}, onSuccess: (result) => _onAssetUploaded(
notifySingleProgress ? _onProgress : (sent, total) {}, result: result,
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {}, shouldNotify: notifyTotalProgress,
_onBackupError, ),
sortAssets: true, onProgress: (bytes, totalBytes) =>
_onProgress(bytes, totalBytes, shouldNotify: notifySingleProgress),
onCurrentAsset: (asset) =>
_onSetCurrentBackupAsset(asset, shouldNotify: notifySingleProgress),
onError: _onBackupError,
isBackground: true,
); );
if (!ok && !_cancellationToken!.isCancelled) { if (!ok && !_cancellationToken!.isCancelled) {
_showErrorNotification( _showErrorNotification(
title: "backup_background_service_error_title".tr(), title: "backup_background_service_error_title".tr(),
content: "backup_background_service_backup_failed_message".tr(), content: "backup_background_service_backup_failed_message".tr(),
); );
} }
return ok; return ok;
} }
void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) { void _onAssetUploaded({
required SuccessUploadAsset result,
bool shouldNotify = false,
}) async {
if (!shouldNotify) {
return;
}
_uploadedAssetsCount++; _uploadedAssetsCount++;
_throttledNotifiy(); _throttledNotifiy();
} }
void _onProgress(int sent, int total) { void _onProgress(int bytes, int totalBytes, {bool shouldNotify = false}) {
_throttledDetailNotify(progress: sent, total: total); if (!shouldNotify) {
return;
}
_throttledDetailNotify(progress: bytes, total: totalBytes);
} }
void _updateDetailProgress(String? title, int progress, int total) { void _updateDetailProgress(String? title, int progress, int total) {
@ -522,7 +555,14 @@ class BackgroundService {
); );
} }
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) { void _onSetCurrentBackupAsset(
CurrentUploadAsset currentUploadAsset, {
bool shouldNotify = false,
}) {
if (!shouldNotify) {
return;
}
_throttledDetailNotify.title = _throttledDetailNotify.title =
"backup_background_service_current_upload_notification" "backup_background_service_current_upload_notification"
.tr(args: [currentUploadAsset.fileName]); .tr(args: [currentUploadAsset.fileName]);

View file

@ -9,11 +9,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.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/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_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/api.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
@ -28,6 +31,7 @@ final backupServiceProvider = Provider(
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(dbProvider), ref.watch(dbProvider),
ref.watch(appSettingsServiceProvider), ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider),
), ),
); );
@ -37,8 +41,14 @@ class BackupService {
final Isar _db; final Isar _db;
final Logger _log = Logger("BackupService"); final Logger _log = Logger("BackupService");
final AppSettingsService _appSetting; final AppSettingsService _appSetting;
final AlbumService _albumService;
BackupService(this._apiService, this._db, this._appSetting); BackupService(
this._apiService,
this._db,
this._appSetting,
this._albumService,
);
Future<List<String>?> getDeviceBackupAsset() async { Future<List<String>?> getDeviceBackupAsset() async {
final String deviceId = Store.get(StoreKey.deviceId); final String deviceId = Store.get(StoreKey.deviceId);
@ -70,10 +80,12 @@ class BackupService {
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
/// Returns all assets newer than the last successful backup per album /// Returns all assets newer than the last successful backup per album
Future<List<AssetEntity>> buildUploadCandidates( /// if `useTimeFilter` is set to true, all assets will be returned
Future<Set<BackupCandidate>> buildUploadCandidates(
List<BackupAlbum> selectedBackupAlbums, List<BackupAlbum> selectedBackupAlbums,
List<BackupAlbum> excludedBackupAlbums, List<BackupAlbum> excludedBackupAlbums, {
) async { bool useTimeFilter = true,
}) async {
final filter = FilterOptionGroup( final filter = FilterOptionGroup(
containsPathModified: true, containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)], orders: [const OrderOption(type: OrderOptionType.updateDate)],
@ -82,105 +94,156 @@ class BackupService {
videoOption: const FilterOption(needTitle: true), videoOption: const FilterOption(needTitle: true),
); );
final now = DateTime.now(); final now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums = final List<AssetPathEntity?> selectedAlbums =
await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now); await _loadAlbumsWithTimeFilter(
selectedBackupAlbums,
filter,
now,
useTimeFilter: useTimeFilter,
);
if (selectedAlbums.every((e) => e == null)) { if (selectedAlbums.every((e) => e == null)) {
return []; return {};
}
final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
if (allIdx != -1) {
final List<AssetPathEntity?> excludedAlbums =
await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now);
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
selectedAlbums.slice(allIdx, allIdx + 1),
selectedBackupAlbums.slice(allIdx, allIdx + 1),
now,
);
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
excludedAlbums,
excludedBackupAlbums,
now,
);
return toAdd.toSet().difference(toRemove.toSet()).toList();
} else {
return await _fetchAssetsAndUpdateLastBackup(
selectedAlbums,
selectedBackupAlbums,
now,
);
} }
final List<AssetPathEntity?> excludedAlbums =
await _loadAlbumsWithTimeFilter(
excludedBackupAlbums,
filter,
now,
useTimeFilter: useTimeFilter,
);
final Set<BackupCandidate> toAdd = await _fetchAssetsAndUpdateLastBackup(
selectedAlbums,
selectedBackupAlbums,
now,
useTimeFilter: useTimeFilter,
);
final Set<BackupCandidate> toRemove = await _fetchAssetsAndUpdateLastBackup(
excludedAlbums,
excludedBackupAlbums,
now,
useTimeFilter: useTimeFilter,
);
return toAdd.difference(toRemove);
} }
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter( Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
List<BackupAlbum> albums, List<BackupAlbum> albums,
FilterOptionGroup filter, FilterOptionGroup filter,
DateTime now, DateTime now, {
) async { bool useTimeFilter = true,
}) async {
List<AssetPathEntity?> result = []; List<AssetPathEntity?> result = [];
for (BackupAlbum a in albums) { for (BackupAlbum backupAlbum in albums) {
try { try {
final optionGroup = useTimeFilter
? filter.copyWith(
updateTimeCond: DateTimeCond(
// subtract 2 seconds to prevent missing assets due to rounding issues
min: backupAlbum.lastBackup
.subtract(const Duration(seconds: 2)),
max: now,
),
)
: filter;
final AssetPathEntity album = final AssetPathEntity album =
await AssetPathEntity.obtainPathFromProperties( await AssetPathEntity.obtainPathFromProperties(
id: a.id, id: backupAlbum.id,
optionGroup: filter.copyWith( optionGroup: optionGroup,
updateTimeCond: DateTimeCond(
// subtract 2 seconds to prevent missing assets due to rounding issues
min: a.lastBackup.subtract(const Duration(seconds: 2)),
max: now,
),
),
maxDateTimeToNow: false, maxDateTimeToNow: false,
); );
result.add(album); result.add(album);
} on StateError { } on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists // either there are no assets matching the filter criteria OR the album no longer exists
} }
} }
return result; return result;
} }
Future<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup( Future<Set<BackupCandidate>> _fetchAssetsAndUpdateLastBackup(
List<AssetPathEntity?> albums, List<AssetPathEntity?> localAlbums,
List<BackupAlbum> backupAlbums, List<BackupAlbum> backupAlbums,
DateTime now, DateTime now, {
) async { bool useTimeFilter = true,
List<AssetEntity> result = []; }) async {
for (int i = 0; i < albums.length; i++) { Set<BackupCandidate> candidate = {};
final AssetPathEntity? a = albums[i];
if (a != null && for (int i = 0; i < localAlbums.length; i++) {
a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) { final localAlbum = localAlbums[i];
result.addAll( if (localAlbum == null) {
await a.getAssetListRange(start: 0, end: await a.assetCountAsync), continue;
);
backupAlbums[i].lastBackup = now;
} }
if (useTimeFilter &&
localAlbum.lastModified?.isBefore(backupAlbums[i].lastBackup) ==
true) {
continue;
}
final assets = await localAlbum.getAssetListRange(
start: 0,
end: await localAlbum.assetCountAsync,
);
// Add album's name to the asset info
for (final asset in assets) {
List<String> albumNames = [localAlbum.name];
final existingAsset = candidate.firstWhereOrNull(
(a) => a.asset.id == asset.id,
);
if (existingAsset != null) {
albumNames.addAll(existingAsset.albumNames);
candidate.remove(existingAsset);
}
candidate.add(
BackupCandidate(
asset: asset,
albumNames: albumNames,
),
);
}
backupAlbums[i].lastBackup = now;
} }
return result;
return candidate;
} }
/// Returns a new list of assets not yet uploaded /// Returns a new list of assets not yet uploaded
Future<List<AssetEntity>> removeAlreadyUploadedAssets( Future<Set<BackupCandidate>> removeAlreadyUploadedAssets(
List<AssetEntity> candidates, Set<BackupCandidate> candidates,
) async { ) async {
if (candidates.isEmpty) { if (candidates.isEmpty) {
return candidates; return candidates;
} }
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds(); final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
candidates = duplicatedAssetIds.isEmpty candidates.removeWhere(
? candidates (candidate) => duplicatedAssetIds.contains(candidate.asset.id),
: candidates );
.whereNot((asset) => duplicatedAssetIds.contains(asset.id))
.toList();
if (candidates.isEmpty) { if (candidates.isEmpty) {
return candidates; return candidates;
} }
final Set<String> existing = {}; final Set<String> existing = {};
try { try {
final String deviceId = Store.get(StoreKey.deviceId); final String deviceId = Store.get(StoreKey.deviceId);
final CheckExistingAssetsResponseDto? duplicates = final CheckExistingAssetsResponseDto? duplicates =
await _apiService.assetsApi.checkExistingAssets( await _apiService.assetsApi.checkExistingAssets(
CheckExistingAssetsDto( CheckExistingAssetsDto(
deviceAssetIds: candidates.map((e) => e.id).toList(), deviceAssetIds: candidates.map((c) => c.asset.id).toList(),
deviceId: deviceId, deviceId: deviceId,
), ),
); );
@ -194,55 +257,75 @@ class BackupService {
existing.addAll(allAssetsInDatabase); existing.addAll(allAssetsInDatabase);
} }
} }
return existing.isEmpty
? candidates if (existing.isNotEmpty) {
: candidates.whereNot((e) => existing.contains(e.id)).toList(); candidates.removeWhere((c) => existing.contains(c.asset.id));
}
return candidates;
} }
Future<bool> backupAsset( Future<bool> _checkPermissions() async {
Iterable<AssetEntity> assetList,
http.CancellationToken cancelToken,
PMProgressHandler? pmProgressHandler,
Function(String, String, bool) uploadSuccessCb,
Function(int, int) uploadProgressCb,
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
Function(ErrorUploadAsset) errorCb, {
bool sortAssets = false,
}) async {
final bool isIgnoreIcloudAssets =
_appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets);
if (Platform.isAndroid && if (Platform.isAndroid &&
!(await pm.Permission.accessMediaLocation.status).isGranted) { !(await pm.Permission.accessMediaLocation.status).isGranted) {
// double check that permission is granted here, to guard against // double check that permission is granted here, to guard against
// uploading corrupt assets without EXIF information // uploading corrupt assets without EXIF information
_log.warning("Media location permission is not granted. " _log.warning("Media location permission is not granted. "
"Cannot access original assets for backup."); "Cannot access original assets for backup.");
return false; return false;
} }
final String deviceId = Store.get(StoreKey.deviceId);
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
bool anyErrors = false;
final List<String> duplicatedAssetIds = [];
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS // DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
if (Platform.isIOS) { if (Platform.isIOS) {
await PhotoManager.requestPermissionExtend(); await PhotoManager.requestPermissionExtend();
} }
List<AssetEntity> assetsToUpload = sortAssets return true;
// Upload images before video assets }
// these are further sorted by using their creation date
? assetList.sorted(
(a, b) {
final cmp = a.typeInt - b.typeInt;
if (cmp != 0) return cmp;
return a.createDateTime.compareTo(b.createDateTime);
},
)
: assetList.toList();
for (var entity in assetsToUpload) { /// Upload images before video assets for background tasks
/// these are further sorted by using their creation date
List<BackupCandidate> _sortPhotosFirst(List<BackupCandidate> candidates) {
return candidates.sorted(
(a, b) {
final cmp = a.asset.typeInt - b.asset.typeInt;
if (cmp != 0) return cmp;
return a.asset.createDateTime.compareTo(b.asset.createDateTime);
},
);
}
Future<bool> backupAsset(
Iterable<BackupCandidate> 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<String> duplicatedAssetIds = [];
bool anyErrors = false;
final hasPermission = await _checkPermissions();
if (!hasPermission) {
return false;
}
List<BackupCandidate> candidates = assets.toList();
if (isBackground) {
candidates = _sortPhotosFirst(candidates);
}
for (final candidate in candidates) {
final AssetEntity entity = candidate.asset;
File? file; File? file;
File? livePhotoFile; File? livePhotoFile;
@ -257,7 +340,7 @@ class BackupService {
continue; continue;
} }
setCurrentUploadAssetCb( onCurrentAsset(
CurrentUploadAsset( CurrentUploadAsset(
id: entity.id, id: entity.id,
fileCreatedAt: entity.createDateTime.year == 1970 fileCreatedAt: entity.createDateTime.year == 1970
@ -299,23 +382,22 @@ class BackupService {
} }
} }
var fileStream = file.openRead(); final fileStream = file.openRead();
var assetRawUploadData = http.MultipartFile( final assetRawUploadData = http.MultipartFile(
"assetData", "assetData",
fileStream, fileStream,
file.lengthSync(), file.lengthSync(),
filename: originalFileName, filename: originalFileName,
); );
var baseRequest = MultipartRequest( final baseRequest = MultipartRequest(
'POST', 'POST',
Uri.parse('$savedEndpoint/assets'), Uri.parse('$savedEndpoint/assets'),
onProgress: ((bytes, totalBytes) => onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
uploadProgressCb(bytes, totalBytes)),
); );
baseRequest.headers.addAll(ApiService.getRequestHeaders()); baseRequest.headers.addAll(ApiService.getRequestHeaders());
baseRequest.headers["Transfer-Encoding"] = "chunked"; baseRequest.headers["Transfer-Encoding"] = "chunked";
baseRequest.fields['deviceAssetId'] = entity.id; baseRequest.fields['deviceAssetId'] = entity.id;
baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['deviceId'] = deviceId;
baseRequest.fields['fileCreatedAt'] = baseRequest.fields['fileCreatedAt'] =
@ -324,12 +406,9 @@ class BackupService {
entity.modifiedDateTime.toUtc().toIso8601String(); entity.modifiedDateTime.toUtc().toIso8601String();
baseRequest.fields['isFavorite'] = entity.isFavorite.toString(); baseRequest.fields['isFavorite'] = entity.isFavorite.toString();
baseRequest.fields['duration'] = entity.videoDuration.toString(); baseRequest.fields['duration'] = entity.videoDuration.toString();
baseRequest.files.add(assetRawUploadData); baseRequest.files.add(assetRawUploadData);
var fileSize = file.lengthSync(); onCurrentAsset(
setCurrentUploadAssetCb(
CurrentUploadAsset( CurrentUploadAsset(
id: entity.id, id: entity.id,
fileCreatedAt: entity.createDateTime.year == 1970 fileCreatedAt: entity.createDateTime.year == 1970
@ -337,7 +416,7 @@ class BackupService {
: entity.createDateTime, : entity.createDateTime,
fileName: originalFileName, fileName: originalFileName,
fileType: _getAssetType(entity.type), fileType: _getAssetType(entity.type),
fileSize: fileSize, fileSize: file.lengthSync(),
iCloudAsset: false, iCloudAsset: false,
), ),
); );
@ -356,22 +435,23 @@ class BackupService {
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
} }
var response = await httpClient.send( final response = await httpClient.send(
baseRequest, baseRequest,
cancellationToken: cancelToken, cancellationToken: cancelToken,
); );
var responseBody = jsonDecode(await response.stream.bytesToString()); final responseBody =
jsonDecode(await response.stream.bytesToString());
if (![200, 201].contains(response.statusCode)) { if (![200, 201].contains(response.statusCode)) {
var error = responseBody; final error = responseBody;
var errorMessage = error['message'] ?? error['error']; final errorMessage = error['message'] ?? error['error'];
debugPrint( debugPrint(
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}", "Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
); );
errorCb( onError(
ErrorUploadAsset( ErrorUploadAsset(
asset: entity, asset: entity,
id: entity.id, id: entity.id,
@ -386,23 +466,37 @@ class BackupService {
anyErrors = true; anyErrors = true;
break; break;
} }
continue; continue;
} }
var isDuplicate = false; bool isDuplicate = false;
if (response.statusCode == 200) { if (response.statusCode == 200) {
isDuplicate = true; isDuplicate = true;
duplicatedAssetIds.add(entity.id); duplicatedAssetIds.add(entity.id);
} }
uploadSuccessCb(entity.id, deviceId, isDuplicate); onSuccess(
SuccessUploadAsset(
candidate: candidate,
remoteAssetId: responseBody['id'] as String,
isDuplicate: isDuplicate,
),
);
if (shouldSyncAlbums && !isDuplicate) {
await _albumService.syncUploadAlbums(
candidate.albumNames,
[responseBody['id'] as String],
);
}
} }
} on http.CancelledException { } on http.CancelledException {
debugPrint("Backup was cancelled by the user"); debugPrint("Backup was cancelled by the user");
anyErrors = true; anyErrors = true;
break; break;
} catch (e) { } catch (error, stackTrace) {
debugPrint("ERROR backupAsset: ${e.toString()}"); debugPrint("Error backup asset: ${error.toString()}: $stackTrace");
anyErrors = true; anyErrors = true;
continue; continue;
} finally { } finally {
@ -416,9 +510,11 @@ class BackupService {
} }
} }
} }
if (duplicatedAssetIds.isNotEmpty) { if (duplicatedAssetIds.isNotEmpty) {
await _saveDuplicatedAssetIds(duplicatedAssetIds); await _saveDuplicatedAssetIds(duplicatedAssetIds);
} }
return !anyErrors; return !anyErrors;
} }

View file

@ -5,15 +5,21 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/providers/album/album.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/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
class AlbumInfoCard extends HookConsumerWidget { class AlbumInfoCard extends HookConsumerWidget {
final AvailableAlbum album; final AvailableAlbum album;
const AlbumInfoCard({super.key, required this.album}); const AlbumInfoCard({
super.key,
required this.album,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -21,6 +27,9 @@ class AlbumInfoCard extends HookConsumerWidget {
ref.watch(backupProvider).selectedBackupAlbums.contains(album); ref.watch(backupProvider).selectedBackupAlbums.contains(album);
final bool isExcluded = final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(album); ref.watch(backupProvider).excludedBackupAlbums.contains(album);
final syncAlbum = ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.syncAlbums);
final isDarkTheme = context.isDarkTheme; final isDarkTheme = context.isDarkTheme;
@ -85,6 +94,9 @@ class AlbumInfoCard extends HookConsumerWidget {
ref.read(backupProvider.notifier).removeAlbumForBackup(album); ref.read(backupProvider.notifier).removeAlbumForBackup(album);
} else { } else {
ref.read(backupProvider.notifier).addAlbumForBackup(album); ref.read(backupProvider.notifier).addAlbumForBackup(album);
if (syncAlbum) {
ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
} }
}, },
onDoubleTap: () { onDoubleTap: () {

View file

@ -5,9 +5,12 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/providers/album/album.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/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
class AlbumInfoListTile extends HookConsumerWidget { class AlbumInfoListTile extends HookConsumerWidget {
@ -21,7 +24,10 @@ class AlbumInfoListTile extends HookConsumerWidget {
ref.watch(backupProvider).selectedBackupAlbums.contains(album); ref.watch(backupProvider).selectedBackupAlbums.contains(album);
final bool isExcluded = final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(album); ref.watch(backupProvider).excludedBackupAlbums.contains(album);
var assetCount = useState(0); final assetCount = useState(0);
final syncAlbum = ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.syncAlbums);
useEffect( useEffect(
() { () {
@ -98,6 +104,9 @@ class AlbumInfoListTile extends HookConsumerWidget {
ref.read(backupProvider.notifier).removeAlbumForBackup(album); ref.read(backupProvider.notifier).removeAlbumForBackup(album);
} else { } else {
ref.read(backupProvider.notifier).addAlbumForBackup(album); ref.read(backupProvider.notifier).addAlbumForBackup(album);
if (syncAlbum) {
ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
} }
}, },
leading: buildIcon(), leading: buildIcon(),

View file

@ -1,9 +1,12 @@
import 'dart:io'; import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/backup/backup_verification.provider.dart'; import 'package:immich_mobile/providers/backup/backup_verification.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/background_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/background_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/foreground_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/foreground_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';
@ -23,7 +26,21 @@ class BackupSettings extends HookConsumerWidget {
useAppSettingsState(AppSettingsEnum.ignoreIcloudAssets); useAppSettingsState(AppSettingsEnum.ignoreIcloudAssets);
final isAdvancedTroubleshooting = final isAdvancedTroubleshooting =
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final albumSync = useAppSettingsState(AppSettingsEnum.syncAlbums);
final isCorruptCheckInProgress = ref.watch(backupVerificationProvider); final isCorruptCheckInProgress = ref.watch(backupVerificationProvider);
final isAlbumSyncInProgress = useState(false);
syncAlbums() async {
isAlbumSyncInProgress.value = true;
try {
await ref.read(assetServiceProvider).syncUploadedAssetToAlbums();
} catch (_) {
} finally {
Future.delayed(const Duration(seconds: 1), () {
isAlbumSyncInProgress.value = false;
});
}
}
final backupSettings = [ final backupSettings = [
const ForegroundBackupSettings(), const ForegroundBackupSettings(),
@ -58,6 +75,23 @@ class BackupSettings extends HookConsumerWidget {
.performBackupCheck(context) .performBackupCheck(context)
: null, : null,
), ),
if (albumSync.value)
SettingsButtonListTile(
icon: Icons.photo_album_outlined,
title: 'sync_albums'.tr(),
subtitle: Text(
"sync_albums_manual_subtitle".tr(),
),
buttonText: 'sync_albums'.tr(),
child: isAlbumSyncInProgress.value
? const CircularProgressIndicator.adaptive(
strokeWidth: 2,
)
: ElevatedButton(
onPressed: syncAlbums,
child: Text('sync'.tr()),
),
),
]; ];
return SettingsSubPageScaffold( return SettingsSubPageScaffold(

View file

@ -9,6 +9,7 @@ class SettingsButtonListTile extends StatelessWidget {
final Widget? subtitle; final Widget? subtitle;
final String? subtileText; final String? subtileText;
final String buttonText; final String buttonText;
final Widget? child;
final void Function()? onButtonTap; final void Function()? onButtonTap;
const SettingsButtonListTile({ const SettingsButtonListTile({
@ -18,6 +19,7 @@ class SettingsButtonListTile extends StatelessWidget {
this.subtileText, this.subtileText,
this.subtitle, this.subtitle,
required this.buttonText, required this.buttonText,
this.child,
this.onButtonTap, this.onButtonTap,
super.key, super.key,
}); });
@ -48,7 +50,8 @@ class SettingsButtonListTile extends StatelessWidget {
), ),
if (subtitle != null) subtitle!, if (subtitle != null) subtitle!,
const SizedBox(height: 6), const SizedBox(height: 6),
ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)), child ??
ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)),
], ],
), ),
); );

View file

@ -9,6 +9,9 @@ class SettingsSwitchListTile extends StatelessWidget {
final String? subtitle; final String? subtitle;
final IconData? icon; final IconData? icon;
final Function(bool)? onChanged; final Function(bool)? onChanged;
final EdgeInsets? contentPadding;
final TextStyle? titleStyle;
final TextStyle? subtitleStyle;
const SettingsSwitchListTile({ const SettingsSwitchListTile({
required this.valueNotifier, required this.valueNotifier,
@ -17,6 +20,9 @@ class SettingsSwitchListTile extends StatelessWidget {
this.icon, this.icon,
this.enabled = true, this.enabled = true,
this.onChanged, this.onChanged,
this.contentPadding = const EdgeInsets.symmetric(horizontal: 20),
this.titleStyle,
this.subtitleStyle,
super.key, super.key,
}); });
@ -30,7 +36,7 @@ class SettingsSwitchListTile extends StatelessWidget {
} }
return SwitchListTile.adaptive( return SwitchListTile.adaptive(
contentPadding: const EdgeInsets.symmetric(horizontal: 20), contentPadding: contentPadding,
selectedTileColor: enabled ? null : context.themeData.disabledColor, selectedTileColor: enabled ? null : context.themeData.disabledColor,
value: valueNotifier.value, value: valueNotifier.value,
onChanged: onSwitchChanged, onChanged: onSwitchChanged,
@ -45,20 +51,22 @@ class SettingsSwitchListTile extends StatelessWidget {
: null, : null,
title: Text( title: Text(
title, title,
style: context.textTheme.bodyLarge?.copyWith( style: titleStyle ??
fontWeight: FontWeight.w500, context.textTheme.bodyLarge?.copyWith(
color: enabled ? null : context.themeData.disabledColor, fontWeight: FontWeight.w500,
height: 1.5, color: enabled ? null : context.themeData.disabledColor,
), height: 1.5,
),
), ),
subtitle: subtitle != null subtitle: subtitle != null
? Text( ? Text(
subtitle!, subtitle!,
style: context.textTheme.bodyMedium?.copyWith( style: subtitleStyle ??
color: enabled context.textTheme.bodyMedium?.copyWith(
? context.colorScheme.onSurfaceSecondary color: enabled
: context.themeData.disabledColor, ? context.colorScheme.onSurfaceSecondary
), : context.themeData.disabledColor,
),
) )
: null, : null,
); );