From fafb88d31c357d180052143773926d03d3e2256b Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 18 Jul 2025 23:58:53 -0500 Subject: [PATCH] feat(mobile): new upload (#18726) --- mobile/lib/constants/constants.dart | 5 + .../domain/services/local_album.service.dart | 4 + .../repositories/backup.repository.dart | 154 +++++++++ .../repositories/local_album.repository.dart | 28 +- .../repositories/local_asset.repository.dart | 7 + .../repositories/storage.repository.dart | 48 ++- mobile/lib/main.dart | 23 +- .../upload/share_intent_attachment.model.dart | 2 +- .../lib/pages/backup/drift_backup.page.dart | 294 +++++++++++++++++ .../drift_backup_album_selection.page.dart | 307 ++++++++++++++++++ .../pages/common/change_experience.page.dart | 6 + .../pages/share_intent/share_intent.page.dart | 2 +- .../pages/dev/feat_in_development.page.dart | 27 +- .../providers/app_life_cycle.provider.dart | 3 + .../share_intent_upload.provider.dart | 36 +- .../backup/backup_album.provider.dart | 62 ++++ .../backup/drift_backup.provider.dart | 194 +++++++++++ mobile/lib/providers/websocket.provider.dart | 42 ++- .../lib/repositories/upload.repository.dart | 25 +- mobile/lib/routing/router.dart | 10 + mobile/lib/routing/router.gr.dart | 32 ++ mobile/lib/services/drift_backup.service.dart | 286 ++++++++++++++++ mobile/lib/services/upload.service.dart | 83 +++-- mobile/lib/utils/database.utils.dart | 31 ++ mobile/lib/utils/upload.dart | 1 - .../backup/drift_album_info_list_tile.dart | 121 +++++++ .../widgets/common/immich_sliver_app_bar.dart | 2 +- 27 files changed, 1733 insertions(+), 102 deletions(-) create mode 100644 mobile/lib/infrastructure/repositories/backup.repository.dart create mode 100644 mobile/lib/pages/backup/drift_backup.page.dart create mode 100644 mobile/lib/pages/backup/drift_backup_album_selection.page.dart create mode 100644 mobile/lib/providers/backup/backup_album.provider.dart create mode 100644 mobile/lib/providers/backup/drift_backup.provider.dart create mode 100644 mobile/lib/services/drift_backup.service.dart create mode 100644 mobile/lib/utils/database.utils.dart delete mode 100644 mobile/lib/utils/upload.dart create mode 100644 mobile/lib/widgets/backup/drift_album_info_list_tile.dart diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index c37498ea3e..37a3eec073 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -16,6 +16,11 @@ const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB // Secure storage keys const String kSecuredPinCode = "secured_pin_code"; +// background_downloader task groups +const String kManualUploadGroup = 'manual_upload_group'; +const String kBackupGroup = 'backup_group'; +const String kBackupLivePhotoGroup = 'backup_live_photo_group'; + // Timeline constants const int kTimelineNoneSegmentSize = 120; const int kTimelineAssetLoadBatchSize = 256; diff --git a/mobile/lib/domain/services/local_album.service.dart b/mobile/lib/domain/services/local_album.service.dart index 9af12ce595..7ec9231196 100644 --- a/mobile/lib/domain/services/local_album.service.dart +++ b/mobile/lib/domain/services/local_album.service.dart @@ -14,4 +14,8 @@ class LocalAlbumService { Future getThumbnail(String albumId) { return _repository.getThumbnail(albumId); } + + Future update(LocalAlbum album) { + return _repository.upsert(album); + } } diff --git a/mobile/lib/infrastructure/repositories/backup.repository.dart b/mobile/lib/infrastructure/repositories/backup.repository.dart new file mode 100644 index 0000000000..4ce3b07e8b --- /dev/null +++ b/mobile/lib/infrastructure/repositories/backup.repository.dart @@ -0,0 +1,154 @@ +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import "package:immich_mobile/utils/database.utils.dart"; + +final backupRepositoryProvider = Provider( + (ref) => DriftBackupRepository(ref.watch(driftProvider)), +); + +class DriftBackupRepository extends DriftDatabaseRepository { + final Drift _db; + const DriftBackupRepository(this._db) : super(_db); + + _getExcludedSubquery() { + return _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + useColumns: false, + ), + ]) + ..where( + _db.localAlbumEntity.backupSelection + .equalsValue(BackupSelection.excluded), + ); + } + + Future getTotalCount() async { + final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + useColumns: false, + ), + ]) + ..where( + _db.localAlbumEntity.backupSelection + .equalsValue(BackupSelection.selected) & + _db.localAlbumAssetEntity.assetId + .isNotInQuery(_getExcludedSubquery()), + ); + + return query.get().then((rows) => rows.length); + } + + Future getRemainderCount() async { + final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) + ..addColumns( + [_db.localAlbumAssetEntity.assetId], + ) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + useColumns: false, + ), + innerJoin( + _db.localAssetEntity, + _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), + useColumns: false, + ), + leftOuterJoin( + _db.remoteAssetEntity, + _db.localAssetEntity.checksum + .equalsExp(_db.remoteAssetEntity.checksum), + useColumns: false, + ), + ]) + ..where( + _db.localAlbumEntity.backupSelection + .equalsValue(BackupSelection.selected) & + _db.remoteAssetEntity.id.isNull() & + _db.localAlbumAssetEntity.assetId + .isNotInQuery(_getExcludedSubquery()), + ); + + return query.get().then((rows) => rows.length); + } + + Future getBackupCount() async { + final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) + ..addColumns( + [_db.localAlbumAssetEntity.assetId], + ) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + useColumns: false, + ), + innerJoin( + _db.localAssetEntity, + _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), + useColumns: false, + ), + innerJoin( + _db.remoteAssetEntity, + _db.localAssetEntity.checksum + .equalsExp(_db.remoteAssetEntity.checksum), + useColumns: false, + ), + ]) + ..where( + _db.localAlbumEntity.backupSelection + .equalsValue(BackupSelection.selected) & + _db.remoteAssetEntity.id.isNotNull() & + _db.localAlbumAssetEntity.assetId + .isNotInQuery(_getExcludedSubquery()), + ); + + return query.get().then((rows) => rows.length); + } + + Future> getCandidates() async { + final selectedAlbumIds = _db.localAlbumEntity.selectOnly(distinct: true) + ..addColumns([_db.localAlbumEntity.id]) + ..where( + _db.localAlbumEntity.backupSelection + .equalsValue(BackupSelection.selected), + ); + + final query = _db.localAssetEntity.select() + ..where( + (lae) => + existsQuery( + _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..where( + _db.localAlbumAssetEntity.albumId + .isInQuery(selectedAlbumIds) & + _db.localAlbumAssetEntity.assetId.equalsExp(lae.id), + ), + ) & + notExistsQuery( + _db.remoteAssetEntity.selectOnly() + ..addColumns([_db.remoteAssetEntity.checksum]) + ..where( + _db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & + lae.checksum.isNotNull(), + ), + ) & + lae.id.isNotInQuery(_getExcludedSubquery()), + ); + + return query.map((localAsset) => localAsset.toDto()).get(); + } +} diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 44ebe7f7ca..ba9dfd979d 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.d import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/utils/database.utils.dart'; import 'package:platform/platform.dart'; enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum } @@ -381,30 +382,3 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { return results.isNotEmpty ? results.first : null; } } - -extension on LocalAlbumEntityData { - LocalAlbum toDto({int assetCount = 0}) { - return LocalAlbum( - id: id, - name: name, - updatedAt: updatedAt, - assetCount: assetCount, - backupSelection: backupSelection, - ); - } -} - -extension on LocalAssetEntityData { - LocalAsset toDto() { - return LocalAsset( - id: id, - name: name, - checksum: checksum, - type: type, - createdAt: createdAt, - updatedAt: updatedAt, - durationInSeconds: durationInSeconds, - isFavorite: isFavorite, - ); - } -} diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 31a11f7047..8d21c858a2 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -56,4 +56,11 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { } }); } + + Future getById(String id) { + final query = _db.localAssetEntity.select() + ..where((lae) => lae.id.equals(id)); + + return query.map((row) => row.toDto()).getSingleOrNull(); + } } diff --git a/mobile/lib/infrastructure/repositories/storage.repository.dart b/mobile/lib/infrastructure/repositories/storage.repository.dart index 5b511709cd..0cf4f20ba8 100644 --- a/mobile/lib/infrastructure/repositories/storage.repository.dart +++ b/mobile/lib/infrastructure/repositories/storage.repository.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -7,8 +8,9 @@ class StorageRepository { const StorageRepository(); Future getFileForAsset(String assetId) async { - final log = Logger('StorageRepository'); File? file; + final log = Logger('StorageRepository'); + try { final entity = await AssetEntity.fromId(assetId); file = await entity?.originFile; @@ -20,4 +22,48 @@ class StorageRepository { } return file; } + + Future getMotionFileForAsset(LocalAsset asset) async { + File? file; + final log = Logger('StorageRepository'); + + try { + final entity = await AssetEntity.fromId(asset.id); + file = await entity?.originFileWithSubtype; + if (file == null) { + log.warning( + "Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", + ); + } + } catch (error, stackTrace) { + log.warning( + "Error getting motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", + error, + stackTrace, + ); + } + return file; + } + + Future getAssetEntityForAsset(LocalAsset asset) async { + final log = Logger('StorageRepository'); + + AssetEntity? entity; + + try { + entity = await AssetEntity.fromId(asset.id); + if (entity == null) { + log.warning( + "Cannot get AssetEntity for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", + ); + } + } catch (error, stackTrace) { + log.warning( + "Error getting AssetEntity for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", + error, + stackTrace, + ); + } + return entity; + } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index f67767f767..acadf4c887 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -93,6 +93,13 @@ Future initApp() async { initializeTimeZones(); + // Initialize the file downloader + + await FileDownloader().configure( + // maxConcurrent: 5, maxConcurrentByHost: 2, maxConcurrentByGroup: 3 + globalConfig: (Config.holdingQueue, (5, 2, 3)), + ); + await FileDownloader().trackTasksInGroup( downloadGroupLivePhoto, markDownloadedComplete: false, @@ -171,7 +178,21 @@ class ImmichAppState extends ConsumerState } void _configureFileDownloaderNotifications() { - FileDownloader().configureNotification( + FileDownloader().configureNotificationForGroup( + downloadGroupImage, + running: TaskNotification( + 'downloading_media'.tr(), + '${'file_name'.tr()}: {filename}', + ), + complete: TaskNotification( + 'download_finished'.tr(), + '${'file_name'.tr()}: {filename}', + ), + progressBar: true, + ); + + FileDownloader().configureNotificationForGroup( + downloadGroupVideo, running: TaskNotification( 'downloading_media'.tr(), '${'file_name'.tr()}: {filename}', diff --git a/mobile/lib/models/upload/share_intent_attachment.model.dart b/mobile/lib/models/upload/share_intent_attachment.model.dart index 1bdb5b6b48..7e57cf94d2 100644 --- a/mobile/lib/models/upload/share_intent_attachment.model.dart +++ b/mobile/lib/models/upload/share_intent_attachment.model.dart @@ -17,7 +17,7 @@ enum UploadStatus { notFound, failed, canceled, - waitingtoRetry, + waitingToRetry, paused, } diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart new file mode 100644 index 0000000000..34649ca42c --- /dev/null +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -0,0 +1,294 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; + +@RoutePage() +class DriftBackupPage extends HookConsumerWidget { + const DriftBackupPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + useEffect( + () { + ref.read(driftBackupProvider.notifier).getBackupStatus(); + return null; + }, + [], + ); + + Widget buildControlButtons() { + return Padding( + padding: const EdgeInsets.only( + top: 24, + ), + child: Column( + children: [ + ElevatedButton( + onPressed: () => ref.read(driftBackupProvider.notifier).backup(), + child: const Text( + "backup_controller_page_start_backup", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + OutlinedButton( + onPressed: () => ref.read(driftBackupProvider.notifier).cancel(), + child: const Text( + "cancel", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + OutlinedButton( + onPressed: () => + ref.read(driftBackupProvider.notifier).getDataInfo(), + child: const Text( + "Get database info", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ], + ), + ); + } + + return Scaffold( + appBar: AppBar( + elevation: 0, + title: const Text( + "Backup (Experimental)", + ), + leading: IconButton( + onPressed: () { + ref.watch(websocketProvider.notifier).listenUploadEvent(); + context.maybePop(true); + }, + splashRadius: 24, + icon: const Icon( + Icons.arrow_back_ios_rounded, + ), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: IconButton( + onPressed: () => context.pushRoute(const BackupOptionsRoute()), + splashRadius: 24, + icon: const Icon( + Icons.settings_outlined, + ), + ), + ), + ], + ), + body: Stack( + children: [ + Padding( + padding: const EdgeInsets.only( + left: 16.0, + right: 16, + bottom: 32, + ), + child: ListView( + children: [ + const SizedBox(height: 8), + const _BackupAlbumSelectionCard(), + const _TotalCard(), + const _BackupCard(), + const _RemainderCard(), + const Divider(), + buildControlButtons(), + ], + ), + ), + ], + ), + ); + } +} + +class _BackupAlbumSelectionCard extends ConsumerWidget { + const _BackupAlbumSelectionCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + Widget buildSelectedAlbumName() { + String text = "backup_controller_page_backup_selected".tr(); + final albums = ref + .watch(backupAlbumProvider) + .where( + (album) => album.backupSelection == BackupSelection.selected, + ) + .toList(); + + if (albums.isNotEmpty) { + for (var album in albums) { + if (album.name == "Recent" || album.name == "Recents") { + text += "${album.name} (${'all'.tr()}), "; + } else { + text += "${album.name}, "; + } + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + text.trim().substring(0, text.length - 2), + style: context.textTheme.labelLarge?.copyWith( + color: context.primaryColor, + ), + ), + ); + } else { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + "backup_controller_page_none_selected".tr(), + style: context.textTheme.labelLarge?.copyWith( + color: context.primaryColor, + ), + ), + ); + } + } + + Widget buildExcludedAlbumName() { + String text = "backup_controller_page_excluded".tr(); + final albums = ref + .watch(backupAlbumProvider) + .where( + (album) => album.backupSelection == BackupSelection.excluded, + ) + .toList(); + + if (albums.isNotEmpty) { + for (var album in albums) { + text += "${album.name}, "; + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + text.trim().substring(0, text.length - 2), + style: context.textTheme.labelLarge?.copyWith( + color: Colors.red[300], + ), + ), + ); + } else { + return const SizedBox(); + } + } + + return Card( + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(20)), + side: BorderSide( + color: context.colorScheme.outlineVariant, + width: 1, + ), + ), + elevation: 0, + borderOnForeground: false, + child: ListTile( + minVerticalPadding: 18, + title: Text( + "backup_controller_page_albums", + style: context.textTheme.titleMedium, + ).tr(), + subtitle: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "backup_controller_page_to_backup", + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ).tr(), + buildSelectedAlbumName(), + buildExcludedAlbumName(), + ], + ), + ), + trailing: ElevatedButton( + onPressed: () async { + await context.pushRoute(const DriftBackupAlbumSelectionRoute()); + ref.read(driftBackupProvider.notifier).getBackupStatus(); + }, + child: const Text( + "select", + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ), + ); + } +} + +class _TotalCard extends ConsumerWidget { + const _TotalCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final totalCount = + ref.watch(driftBackupProvider.select((p) => p.totalCount)); + + return BackupInfoCard( + title: "total".tr(), + subtitle: "backup_controller_page_total_sub".tr(), + info: totalCount.toString(), + ); + } +} + +class _BackupCard extends ConsumerWidget { + const _BackupCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final backupCount = + ref.watch(driftBackupProvider.select((p) => p.backupCount)); + + return BackupInfoCard( + title: "backup_controller_page_backup".tr(), + subtitle: "backup_controller_page_backup_sub".tr(), + info: backupCount.toString(), + ); + } +} + +class _RemainderCard extends ConsumerWidget { + const _RemainderCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final remainderCount = + ref.watch(driftBackupProvider.select((p) => p.remainderCount)); + return BackupInfoCard( + title: "backup_controller_page_remainder".tr(), + subtitle: "backup_controller_page_remainder_sub".tr(), + info: remainderCount.toString(), + ); + } +} diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart new file mode 100644 index 0000000000..2e7a2d4c2d --- /dev/null +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -0,0 +1,307 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.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_album.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/drift_album_info_list_tile.dart'; +import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; + +@RoutePage() +class DriftBackupAlbumSelectionPage extends HookConsumerWidget { + const DriftBackupAlbumSelectionPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = ref.watch(backupAlbumProvider); + + final selectedBackupAlbums = albums + .where((album) => album.backupSelection == BackupSelection.selected) + .toList(); + final excludedBackupAlbums = albums + .where((album) => album.backupSelection == BackupSelection.excluded) + .toList(); + final enableSyncUploadAlbum = + useAppSettingsState(AppSettingsEnum.syncAlbums); + final isDarkTheme = context.isDarkTheme; + + useEffect( + () { + ref.watch(backupProvider.notifier).getBackupInfo(); + ref.watch(backupAlbumProvider.notifier).getAll(); + return null; + }, + [], + ); + + buildAlbumSelectionList() { + if (albums.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + ((context, index) { + return DriftAlbumInfoListTile( + album: albums[index], + ); + }), + childCount: albums.length, + ), + ), + ); + } + + buildAlbumSelectionGrid() { + if (albums.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.all(12.0), + sliver: SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 300, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + ), + itemCount: albums.length, + itemBuilder: ((context, index) { + return DriftAlbumInfoListTile( + album: albums[index], + ); + }), + ), + ); + } + + buildSelectedAlbumNameChip() { + return selectedBackupAlbums.map((album) { + void removeSelection() { + ref.read(backupAlbumProvider.notifier).deselectAlbum(album); + } + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: GestureDetector( + onTap: removeSelection, + child: Chip( + label: Text( + album.name, + style: TextStyle( + fontSize: 12, + color: isDarkTheme ? Colors.black : Colors.white, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: context.primaryColor, + deleteIconColor: isDarkTheme ? Colors.black : Colors.white, + deleteIcon: const Icon( + Icons.cancel_rounded, + size: 15, + ), + onDeleted: removeSelection, + ), + ), + ); + }).toSet(); + } + + buildExcludedAlbumNameChip() { + return excludedBackupAlbums.map((album) { + void removeSelection() { + ref.read(backupAlbumProvider.notifier).deselectAlbum(album); + } + + return GestureDetector( + onTap: removeSelection, + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Chip( + label: Text( + album.name, + style: TextStyle( + fontSize: 12, + color: context.scaffoldBackgroundColor, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: Colors.red[300], + deleteIconColor: context.scaffoldBackgroundColor, + deleteIcon: const Icon( + Icons.cancel_rounded, + size: 15, + ), + onDeleted: removeSelection, + ), + ), + ); + }).toSet(); + } + + handleSyncAlbumToggle(bool isEnable) async { + if (isEnable) { + await ref.read(albumProvider.notifier).refreshRemoteAlbums(); + for (final album in selectedBackupAlbums) { + await ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } + } + } + + return Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () => context.maybePop(), + icon: const Icon(Icons.arrow_back_ios_rounded), + ), + title: const Text( + "backup_album_selection_page_select_albums", + ).tr(), + elevation: 0, + ), + body: SafeArea( + child: CustomScrollView( + physics: const ClampingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, + ), + child: Text( + "backup_album_selection_page_selection_info", + style: context.textTheme.titleSmall, + ).tr(), + ), + // Selected Album Chips + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Wrap( + children: [ + ...buildSelectedAlbumNameChip(), + ...buildExcludedAlbumNameChip(), + ], + ), + ), + + 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( + title: Text( + "backup_album_selection_page_albums_device".tr( + namedArgs: { + 'count': ref + .watch(backupProvider) + .availableAlbums + .length + .toString(), + }, + ), + style: context.textTheme.titleSmall, + ), + subtitle: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + "backup_album_selection_page_albums_tap", + style: context.textTheme.labelLarge?.copyWith( + color: context.primaryColor, + ), + ).tr(), + ), + trailing: IconButton( + splashRadius: 16, + icon: Icon( + Icons.info, + size: 20, + color: context.primaryColor, + ), + onPressed: () { + // show the dialog + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(10)), + ), + elevation: 5, + title: Text( + 'backup_album_selection_page_selection_info', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: context.primaryColor, + ), + ).tr(), + content: SingleChildScrollView( + child: ListBody( + children: [ + const Text( + 'backup_album_selection_page_assets_scatter', + style: TextStyle( + fontSize: 14, + ), + ).tr(), + ], + ), + ), + ); + }, + ); + }, + ), + ), + + // buildSearchBar(), + ], + ), + ), + SliverLayoutBuilder( + builder: (context, constraints) { + if (constraints.crossAxisExtent > 600) { + return buildAlbumSelectionGrid(); + } else { + return buildAlbumSelectionList(); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/change_experience.page.dart b/mobile/lib/pages/common/change_experience.page.dart index 74c3116962..5d298edb42 100644 --- a/mobile/lib/pages/common/change_experience.page.dart +++ b/mobile/lib/pages/common/change_experience.page.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -42,6 +43,9 @@ class _ChangeExperiencePageState extends ConsumerState { albumNotifier.dispose(); } + ref.read(websocketProvider.notifier).stopListenToOldEvents(); + ref.read(websocketProvider.notifier).startListeningToBetaEvents(); + final permission = await ref .read(galleryPermissionNotifier.notifier) .requestGalleryPermission(); @@ -55,6 +59,8 @@ class _ChangeExperiencePageState extends ConsumerState { } } else { await ref.read(backgroundSyncProvider).cancel(); + ref.read(websocketProvider.notifier).stopListeningToBetaEvents(); + ref.read(websocketProvider.notifier).startListeningToOldEvents(); } if (mounted) { diff --git a/mobile/lib/pages/share_intent/share_intent.page.dart b/mobile/lib/pages/share_intent/share_intent.page.dart index 11c114e4a6..299ffe5497 100644 --- a/mobile/lib/pages/share_intent/share_intent.page.dart +++ b/mobile/lib/pages/share_intent/share_intent.page.dart @@ -268,7 +268,7 @@ class UploadStatusIcon extends StatelessWidget { color: Colors.red, semanticLabel: 'canceled'.tr(), ), - UploadStatus.waitingtoRetry || UploadStatus.paused => Icon( + UploadStatus.waitingToRetry || UploadStatus.paused => Icon( Icons.pause_circle_rounded, color: context.primaryColor, semanticLabel: 'paused'.tr(), diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart index e94329acd2..7ee151f94d 100644 --- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -91,6 +91,10 @@ final _features = [ ), _Feature( name: 'Clear Local Data', + style: const TextStyle( + color: Colors.orange, + fontWeight: FontWeight.bold, + ), icon: Icons.delete_forever_rounded, onTap: (_, ref) async { final db = ref.read(driftProvider); @@ -101,6 +105,10 @@ final _features = [ ), _Feature( name: 'Clear Remote Data', + style: const TextStyle( + color: Colors.orange, + fontWeight: FontWeight.bold, + ), icon: Icons.delete_sweep_rounded, onTap: (_, ref) async { final db = ref.read(driftProvider); @@ -117,17 +125,29 @@ final _features = [ ), _Feature( name: 'Local Media Summary', + style: const TextStyle( + color: Colors.indigo, + fontWeight: FontWeight.bold, + ), icon: Icons.table_chart_rounded, onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()), ), _Feature( name: 'Remote Media Summary', + style: const TextStyle( + color: Colors.indigo, + fontWeight: FontWeight.bold, + ), icon: Icons.summarize_rounded, onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()), ), _Feature( name: 'Reset Sqlite', icon: Icons.table_view_rounded, + style: const TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), onTap: (_, ref) async { final drift = ref.read(driftProvider); // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member @@ -160,7 +180,10 @@ class FeatInDevPage extends StatelessWidget { final feat = _features[index]; return Consumer( builder: (ctx, ref, _) => ListTile( - title: Text(feat.name), + title: Text( + feat.name, + style: feat.style, + ), trailing: Icon(feat.icon), visualDensity: VisualDensity.compact, onTap: () => unawaited(feat.onTap(ctx, ref)), @@ -183,10 +206,12 @@ class _Feature { required this.name, required this.icon, required this.onTap, + this.style, }); final String name; final IconData icon; + final TextStyle? style; final Future Function(BuildContext, WidgetRef _) onTap; } diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 997058d763..5984160241 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -69,6 +69,9 @@ class AppLifeCycleNotifier extends StateNotifier { } await _ref.read(serverInfoProvider.notifier).getServerVersion(); + + // TODO: Need to decide on how we want to handle uploads once the app is resumed + // await FileDownloader().start(); } if (!Store.isBetaTimelineEnabled) { diff --git a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart index ed2c485b13..3c448b112f 100644 --- a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart +++ b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; -import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; @@ -30,7 +29,7 @@ class ShareIntentUploadStateNotifier this._uploadService, this._shareIntentService, ) : super([]) { - _uploadService.onUploadStatus = _uploadStatusCallback; + _uploadService.onUploadStatus = _updateUploadStatus; _uploadService.onTaskProgress = _taskProgressCallback; } @@ -69,8 +68,8 @@ class ShareIntentUploadStateNotifier state = []; } - void _updateUploadStatus(TaskStatusUpdate task, TaskStatus status) async { - if (status == TaskStatus.canceled) { + void _updateUploadStatus(TaskStatusUpdate task) async { + if (task.status == TaskStatus.canceled) { return; } @@ -83,7 +82,7 @@ class ShareIntentUploadStateNotifier TaskStatus.running => UploadStatus.running, TaskStatus.paused => UploadStatus.paused, TaskStatus.notFound => UploadStatus.notFound, - TaskStatus.waitingToRetry => UploadStatus.waitingtoRetry + TaskStatus.waitingToRetry => UploadStatus.waitingToRetry }; state = [ @@ -95,27 +94,6 @@ class ShareIntentUploadStateNotifier ]; } - void _uploadStatusCallback(TaskStatusUpdate update) { - _updateUploadStatus(update, update.status); - - switch (update.status) { - case TaskStatus.complete: - if (update.responseStatusCode == 200) { - if (kDebugMode) { - debugPrint("[COMPLETE] ${update.task.taskId} - DUPLICATE"); - } - } else { - if (kDebugMode) { - debugPrint("[COMPLETE] ${update.task.taskId}"); - } - } - break; - - default: - break; - } - } - void _taskProgressCallback(TaskProgressUpdate update) { // Ignore if the task is canceled or completed if (update.progress == downloadFailed || @@ -134,10 +112,6 @@ class ShareIntentUploadStateNotifier } Future upload(File file) { - return _uploadService.upload(file); - } - - Future cancelUpload(String id) { - return _uploadService.cancelUpload(id); + return _uploadService.buildUploadTask(file, group: kManualUploadGroup); } } diff --git a/mobile/lib/providers/backup/backup_album.provider.dart b/mobile/lib/providers/backup/backup_album.provider.dart new file mode 100644 index 0000000000..b36d3ac57e --- /dev/null +++ b/mobile/lib/providers/backup/backup_album.provider.dart @@ -0,0 +1,62 @@ +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/providers/infrastructure/album.provider.dart'; + +final backupAlbumProvider = + StateNotifierProvider>( + (ref) => BackupAlbumNotifier( + ref.watch(localAlbumServiceProvider), + ), +); + +class BackupAlbumNotifier extends StateNotifier> { + BackupAlbumNotifier(this._localAlbumService) : super([]) { + getAll(); + } + + final LocalAlbumService _localAlbumService; + + Future getAll() async { + state = await _localAlbumService.getAll(); + } + + Future 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 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 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(); + } +} diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart new file mode 100644 index 0000000000..2544d208c4 --- /dev/null +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/services/drift_backup.service.dart'; +import 'package:immich_mobile/services/upload.service.dart'; + +class DriftUploadStatus { + final String taskId; + final String filename; + final double progress; + + const DriftUploadStatus({ + required this.taskId, + required this.filename, + required this.progress, + }); + + DriftUploadStatus copyWith({ + String? taskId, + String? filename, + double? progress, + }) { + return DriftUploadStatus( + taskId: taskId ?? this.taskId, + filename: filename ?? this.filename, + progress: progress ?? this.progress, + ); + } + + @override + String toString() => + 'ExpUploadStatus(taskId: $taskId, filename: $filename, progress: $progress)'; + + @override + bool operator ==(covariant DriftUploadStatus other) { + if (identical(this, other)) return true; + + return other.taskId == taskId && + other.filename == filename && + other.progress == progress; + } + + @override + int get hashCode => taskId.hashCode ^ filename.hashCode ^ progress.hashCode; +} + +class DriftBackupState { + final int totalCount; + final int backupCount; + final int remainderCount; + final Map uploadItems; + + const DriftBackupState({ + required this.totalCount, + required this.backupCount, + required this.remainderCount, + required this.uploadItems, + }); + + DriftBackupState copyWith({ + int? totalCount, + int? backupCount, + int? remainderCount, + Map? uploadItems, + }) { + return DriftBackupState( + totalCount: totalCount ?? this.totalCount, + backupCount: backupCount ?? this.backupCount, + remainderCount: remainderCount ?? this.remainderCount, + uploadItems: uploadItems ?? this.uploadItems, + ); + } + + @override + String toString() { + return 'ExpBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, uploadItems: $uploadItems)'; + } + + @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 && + mapEquals(other.uploadItems, uploadItems); + } + + @override + int get hashCode { + return totalCount.hashCode ^ + backupCount.hashCode ^ + remainderCount.hashCode ^ + uploadItems.hashCode; + } +} + +final driftBackupProvider = + StateNotifierProvider((ref) { + return ExpBackupNotifier( + ref.watch(driftBackupServiceProvider), + ref.watch(uploadServiceProvider), + ); +}); + +class ExpBackupNotifier extends StateNotifier { + ExpBackupNotifier( + this._backupService, + this._uploadService, + ) : super( + const DriftBackupState( + totalCount: 0, + backupCount: 0, + remainderCount: 0, + uploadItems: {}, + ), + ) { + { + _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate); + _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate); + } + } + + final DriftBackupService _backupService; + final UploadService _uploadService; + StreamSubscription? _statusSubscription; + StreamSubscription? _progressSubscription; + + void _handleTaskStatusUpdate(TaskStatusUpdate update) { + switch (update.status) { + case TaskStatus.complete: + state = state.copyWith( + backupCount: state.backupCount + 1, + remainderCount: state.remainderCount - 1, + ); + break; + + default: + break; + } + } + + void _handleTaskProgressUpdate(TaskProgressUpdate update) {} + + Future getBackupStatus() async { + final [totalCount, backupCount, remainderCount] = await Future.wait([ + _backupService.getTotalCount(), + _backupService.getBackupCount(), + _backupService.getRemainderCount(), + ]); + + state = state.copyWith( + totalCount: totalCount, + backupCount: backupCount, + remainderCount: remainderCount, + ); + } + + Future backup() { + return _backupService.backup(); + } + + Future cancel() async { + await _backupService.cancel(); + await getDataInfo(); + } + + Future getDataInfo() async { + final a = await FileDownloader().database.allRecordsWithStatus( + TaskStatus.enqueued, + group: kBackupGroup, + ); + + final b = await FileDownloader().allTasks( + group: kBackupGroup, + ); + + debugPrint( + "Enqueued tasks: ${a.length}, All tasks: ${b.length}", + ); + } + + @override + void dispose() { + _statusSubscription?.cancel(); + _progressSubscription?.cancel(); + super.dispose(); + } +} diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index d9db831776..05fe5a087c 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -176,16 +176,14 @@ class WebsocketNotifier extends StateNotifier { ); }); - socket.on('on_upload_success', _handleOnUploadSuccess); + if (!Store.isBetaTimelineEnabled) { + startListeningToOldEvents(); + } else { + startListeningToBetaEvents(); + } + socket.on('on_config_update', _handleOnConfigUpdate); - socket.on('on_asset_delete', _handleOnAssetDelete); - socket.on('on_asset_trash', _handleOnAssetTrash); - socket.on('on_asset_restore', _handleServerUpdates); - socket.on('on_asset_update', _handleServerUpdates); - socket.on('on_asset_stack_update', _handleServerUpdates); - socket.on('on_asset_hidden', _handleOnAssetHidden); socket.on('on_new_release', _handleReleaseUpdates); - socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); } catch (e) { debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); } @@ -213,6 +211,34 @@ class WebsocketNotifier extends StateNotifier { state.socket?.off(eventName); } + void stopListenToOldEvents() { + state.socket?.off('on_upload_success'); + state.socket?.off('on_asset_delete'); + state.socket?.off('on_asset_trash'); + state.socket?.off('on_asset_restore'); + state.socket?.off('on_asset_update'); + state.socket?.off('on_asset_stack_update'); + state.socket?.off('on_asset_hidden'); + } + + void startListeningToOldEvents() { + state.socket?.on('on_upload_success', _handleOnUploadSuccess); + state.socket?.on('on_asset_delete', _handleOnAssetDelete); + state.socket?.on('on_asset_trash', _handleOnAssetTrash); + state.socket?.on('on_asset_restore', _handleServerUpdates); + state.socket?.on('on_asset_update', _handleServerUpdates); + state.socket?.on('on_asset_stack_update', _handleServerUpdates); + state.socket?.on('on_asset_hidden', _handleOnAssetHidden); + } + + void stopListeningToBetaEvents() { + state.socket?.off('AssetUploadReadyV1'); + } + + void startListeningToBetaEvents() { + state.socket?.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); + } + void listenUploadEvent() { debugPrint("Start listening to event on_upload_success"); state.socket?.on('on_upload_success', _handleOnUploadSuccess); diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index 4f840fa3c6..b98eece656 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -1,6 +1,6 @@ import 'package:background_downloader/background_downloader.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/utils/upload.dart'; +import 'package:immich_mobile/constants/constants.dart'; final uploadRepositoryProvider = Provider((ref) => UploadRepository()); @@ -11,25 +11,30 @@ class UploadRepository { UploadRepository() { FileDownloader().registerCallbacks( - group: uploadGroup, + group: kBackupGroup, + taskStatusCallback: (update) => onUploadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + FileDownloader().registerCallbacks( + group: kBackupLivePhotoGroup, taskStatusCallback: (update) => onUploadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), ); } - Future upload(UploadTask task) { - return FileDownloader().enqueue(task); + void enqueueAll(List tasks) { + FileDownloader().enqueueAll(tasks); } - Future deleteAllTrackingRecords() { - return FileDownloader().database.deleteAllRecords(); + Future deleteAllTrackingRecords(String group) { + return FileDownloader().database.deleteAllRecords(group: group); } - Future cancel(String id) { - return FileDownloader().cancelTaskWithId(id); + Future cancelAll(String group) { + return FileDownloader().cancelAll(group: group); } - Future deleteRecordsWithIds(List ids) { - return FileDownloader().database.deleteRecordsWithIds(ids); + Future reset(String group) { + return FileDownloader().reset(group: group); } } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 3a694df816..d7dd45dbd9 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -22,6 +22,8 @@ import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart' import 'package:immich_mobile/pages/album/album_viewer.page.dart'; import 'package:immich_mobile/pages/albums/albums.page.dart'; import 'package:immich_mobile/pages/backup/album_preview.page.dart'; +import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart'; +import 'package:immich_mobile/pages/backup/drift_backup.page.dart'; import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; import 'package:immich_mobile/pages/backup/backup_options.page.dart'; @@ -385,6 +387,14 @@ class AppRouter extends RootStackRouter { page: RemoteMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: DriftBackupRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + AutoRoute( + page: DriftBackupAlbumSelectionRoute.page, + guards: [_authGuard, _duplicateGuard], + ), AutoRoute( page: LocalTimelineRoute.page, guards: [_authGuard, _duplicateGuard], diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 716d5ef89a..c72dc62765 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -726,6 +726,38 @@ class DriftAssetSelectionTimelineRouteArgs { } } +/// generated route for +/// [DriftBackupAlbumSelectionPage] +class DriftBackupAlbumSelectionRoute extends PageRouteInfo { + const DriftBackupAlbumSelectionRoute({List? children}) + : super(DriftBackupAlbumSelectionRoute.name, initialChildren: children); + + static const String name = 'DriftBackupAlbumSelectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftBackupAlbumSelectionPage(); + }, + ); +} + +/// generated route for +/// [DriftBackupPage] +class DriftBackupRoute extends PageRouteInfo { + const DriftBackupRoute({List? children}) + : super(DriftBackupRoute.name, initialChildren: children); + + static const String name = 'DriftBackupRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftBackupPage(); + }, + ); +} + /// generated route for /// [DriftCreateAlbumPage] class DriftCreateAlbumRoute extends PageRouteInfo { diff --git a/mobile/lib/services/drift_backup.service.dart b/mobile/lib/services/drift_backup.service.dart new file mode 100644 index 0000000000..5966aad304 --- /dev/null +++ b/mobile/lib/services/drift_backup.service.dart @@ -0,0 +1,286 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; +import 'package:immich_mobile/services/upload.service.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +final driftBackupServiceProvider = Provider( + (ref) => DriftBackupService( + ref.watch(backupRepositoryProvider), + ref.watch(storageRepositoryProvider), + ref.watch(uploadServiceProvider), + ref.watch(localAssetRepository), + ), +); + +class DriftBackupService { + DriftBackupService( + this._backupRepository, + this._storageRepository, + this._uploadService, + this._localAssetRepository, + ) { + _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate); + } + + final DriftBackupRepository _backupRepository; + final StorageRepository _storageRepository; + final DriftLocalAssetRepository _localAssetRepository; + final UploadService _uploadService; + final _log = Logger("DriftBackupService"); + + bool shouldCancel = false; + + Future getTotalCount() { + return _backupRepository.getTotalCount(); + } + + Future getRemainderCount() { + return _backupRepository.getRemainderCount(); + } + + Future getBackupCount() { + return _backupRepository.getBackupCount(); + } + + Future backup() async { + shouldCancel = false; + + final candidates = await _backupRepository.getCandidates(); + if (candidates.isEmpty) { + return; + } + + const batchSize = 100; + int count = 0; + for (int i = 0; i < candidates.length; i += batchSize) { + if (shouldCancel) { + break; + } + + final batch = candidates.skip(i).take(batchSize).toList(); + + List tasks = []; + for (final asset in batch) { + final task = await _getUploadTask(asset); + if (task != null) { + tasks.add(task); + } + } + + if (tasks.isNotEmpty && !shouldCancel) { + count += tasks.length; + _uploadService.enqueueTasks(tasks); + debugPrint( + "Enqueued $count/${candidates.length} tasks for backup", + ); + } + } + } + + void _handleTaskStatusUpdate(TaskStatusUpdate update) { + switch (update.status) { + case TaskStatus.complete: + _handleLivePhoto(update); + break; + + default: + break; + } + } + + Future _handleLivePhoto(TaskStatusUpdate update) async { + try { + if (update.task.metaData.isEmpty || update.task.metaData == '') { + return; + } + + final metadata = UploadTaskMetadata.fromJson(update.task.metaData); + if (!metadata.isLivePhotos) { + return; + } + + if (update.responseBody == null || update.responseBody!.isEmpty) { + return; + } + final response = jsonDecode(update.responseBody!); + + final localAsset = + await _localAssetRepository.getById(metadata.localAssetId); + if (localAsset == null) { + return; + } + + final uploadTask = await _getLivePhotoUploadTask( + localAsset, + response['id'] as String, + ); + + if (uploadTask == null) { + return; + } + + _uploadService.enqueueTasks([uploadTask]); + } catch (error, stackTrace) { + _log.severe("Error handling live photo upload task", error, stackTrace); + debugPrint("Error handling live photo upload task: $error $stackTrace"); + } + } + + Future _getUploadTask(LocalAsset asset) async { + final entity = await _storageRepository.getAssetEntityForAsset(asset); + if (entity == null) { + return null; + } + + File? file; + + /// iOS LivePhoto has two files: a photo and a video. + /// They are uploaded separately, with video file being upload first, then returned with the assetId + /// The assetId is then used as a metadata for the photo file upload task. + /// + /// We implement two separate upload groups for this, the normal one for the video file + /// and the higher priority group for the photo file because the video file is already uploaded. + /// + /// The cancel operation will only cancel the video group (normal group), the photo group will not + /// be touched, as the video file is already uploaded. + + if (entity.isLivePhoto) { + file = await _storageRepository.getMotionFileForAsset(asset); + } else { + file = await _storageRepository.getFileForAsset(asset.id); + } + + if (file == null) { + return null; + } + + final originalFileName = entity.isLivePhoto + ? p.setExtension( + asset.name, + p.extension(file.path), + ) + : asset.name; + + String metadata = UploadTaskMetadata( + localAssetId: asset.id, + isLivePhotos: entity.isLivePhoto, + livePhotoVideoId: '', + ).toJson(); + + return _uploadService.buildUploadTask( + file, + originalFileName: originalFileName, + deviceAssetId: asset.id, + metadata: metadata, + group: kBackupGroup, + ); + } + + Future _getLivePhotoUploadTask( + LocalAsset asset, + String livePhotoVideoId, + ) async { + final entity = await _storageRepository.getAssetEntityForAsset(asset); + if (entity == null) { + return null; + } + + final file = await _storageRepository.getFileForAsset(asset.id); + if (file == null) { + return null; + } + + final fields = { + 'livePhotoVideoId': livePhotoVideoId, + }; + + return _uploadService.buildUploadTask( + file, + originalFileName: asset.name, + deviceAssetId: asset.id, + fields: fields, + group: kBackupLivePhotoGroup, + priority: 0, + ); + } + + Future cancel() async { + shouldCancel = true; + await _uploadService.cancelAllForGroup(kBackupGroup); + } +} + +class UploadTaskMetadata { + final String localAssetId; + final bool isLivePhotos; + final String livePhotoVideoId; + + const UploadTaskMetadata({ + required this.localAssetId, + required this.isLivePhotos, + required this.livePhotoVideoId, + }); + + UploadTaskMetadata copyWith({ + String? localAssetId, + bool? isLivePhotos, + String? livePhotoVideoId, + }) { + return UploadTaskMetadata( + localAssetId: localAssetId ?? this.localAssetId, + isLivePhotos: isLivePhotos ?? this.isLivePhotos, + livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, + ); + } + + Map toMap() { + return { + 'localAssetId': localAssetId, + 'isLivePhotos': isLivePhotos, + 'livePhotoVideoId': livePhotoVideoId, + }; + } + + factory UploadTaskMetadata.fromMap(Map map) { + return UploadTaskMetadata( + localAssetId: map['localAssetId'] as String, + isLivePhotos: map['isLivePhotos'] as bool, + livePhotoVideoId: map['livePhotoVideoId'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory UploadTaskMetadata.fromJson(String source) => + UploadTaskMetadata.fromMap(json.decode(source) as Map); + + @override + String toString() => + 'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)'; + + @override + bool operator ==(covariant UploadTaskMetadata other) { + if (identical(this, other)) return true; + + return other.localAssetId == localAssetId && + other.isLivePhotos == isLivePhotos && + other.livePhotoVideoId == livePhotoVideoId; + } + + @override + int get hashCode => + localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode; +} diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 18f90ab844..60aab4a16c 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; @@ -6,22 +7,28 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/utils/upload.dart'; import 'package:path/path.dart'; -// import 'package:logging/logging.dart'; -final uploadServiceProvider = Provider( - (ref) => UploadService( - ref.watch(uploadRepositoryProvider), - ), -); +final uploadServiceProvider = Provider((ref) { + final service = UploadService(ref.watch(uploadRepositoryProvider)); + ref.onDispose(service.dispose); + return service; +}); class UploadService { final UploadRepository _uploadRepository; - // final Logger _log = Logger("UploadService"); void Function(TaskStatusUpdate)? onUploadStatus; void Function(TaskProgressUpdate)? onTaskProgress; + final StreamController _taskStatusController = + StreamController.broadcast(); + final StreamController _taskProgressController = + StreamController.broadcast(); + + Stream get taskStatusStream => _taskStatusController.stream; + Stream get taskProgressStream => + _taskProgressController.stream; + UploadService( this._uploadRepository, ) { @@ -31,29 +38,65 @@ class UploadService { void _onTaskProgressCallback(TaskProgressUpdate update) { onTaskProgress?.call(update); + if (!_taskProgressController.isClosed) { + _taskProgressController.add(update); + } } void _onUploadCallback(TaskStatusUpdate update) { onUploadStatus?.call(update); + if (!_taskStatusController.isClosed) { + _taskStatusController.add(update); + } + } + + void dispose() { + _taskStatusController.close(); + _taskProgressController.close(); } Future cancelUpload(String id) { return FileDownloader().cancelTaskWithId(id); } - Future upload(File file) async { - final task = await _buildUploadTask( - hash(file.path).toString(), - file, - ); - - await _uploadRepository.upload(task); + Future cancelAllForGroup(String group) async { + await _uploadRepository.cancelAll(group); + await _uploadRepository.reset(group); + await _uploadRepository.deleteAllTrackingRecords(group); } - Future _buildUploadTask( + void enqueueTasks(List tasks) { + _uploadRepository.enqueueAll(tasks); + } + + Future buildUploadTask( + File file, { + required String group, + Map? fields, + String? originalFileName, + String? deviceAssetId, + String? metadata, + int? priority, + }) async { + return _buildTask( + deviceAssetId ?? hash(file.path).toString(), + file, + fields: fields, + originalFileName: originalFileName, + metadata: metadata, + group: group, + priority: priority, + ); + } + + Future _buildTask( String id, File file, { + required String group, Map? fields, + String? originalFileName, + String? metadata, + int? priority, }) async { final serverEndpoint = Store.get(StoreKey.serverEndpoint); final url = Uri.parse('$serverEndpoint/assets').toString(); @@ -65,9 +108,8 @@ class UploadService { final stats = await file.stat(); final fileCreatedAt = stats.changed; final fileModifiedAt = stats.modified; - final fieldsMap = { - 'filename': filename, + 'filename': originalFileName ?? filename, 'deviceAssetId': id, 'deviceId': deviceId, 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), @@ -79,6 +121,7 @@ class UploadService { return UploadTask( taskId: id, + displayName: originalFileName ?? filename, httpRequestMethod: 'POST', url: url, headers: headers, @@ -87,7 +130,9 @@ class UploadService { baseDirectory: baseDirectory, directory: directory, fileField: 'assetData', - group: uploadGroup, + metaData: metadata ?? '', + group: group, + priority: priority ?? 5, updates: Updates.statusAndProgress, ); } diff --git a/mobile/lib/utils/database.utils.dart b/mobile/lib/utils/database.utils.dart new file mode 100644 index 0000000000..446b92db19 --- /dev/null +++ b/mobile/lib/utils/database.utils.dart @@ -0,0 +1,31 @@ +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/infrastructure/entities/local_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; + +extension LocalAlbumEntityDataHelper on LocalAlbumEntityData { + LocalAlbum toDto({int assetCount = 0}) { + return LocalAlbum( + id: id, + name: name, + updatedAt: updatedAt, + assetCount: assetCount, + backupSelection: backupSelection, + ); + } +} + +extension LocalAssetEntityDataHelper on LocalAssetEntityData { + LocalAsset toDto() { + return LocalAsset( + id: id, + name: name, + checksum: checksum, + type: type, + createdAt: createdAt, + updatedAt: updatedAt, + durationInSeconds: durationInSeconds, + isFavorite: isFavorite, + ); + } +} diff --git a/mobile/lib/utils/upload.dart b/mobile/lib/utils/upload.dart deleted file mode 100644 index a0b77f1d93..0000000000 --- a/mobile/lib/utils/upload.dart +++ /dev/null @@ -1 +0,0 @@ -const uploadGroup = 'upload_group'; diff --git a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart new file mode 100644 index 0000000000..42178c972e --- /dev/null +++ b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart @@ -0,0 +1,121 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.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_album.provider.dart'; +import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +class DriftAlbumInfoListTile extends HookConsumerWidget { + final LocalAlbum album; + + const DriftAlbumInfoListTile({super.key, required this.album}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bool isSelected = album.backupSelection == BackupSelection.selected; + final bool isExcluded = album.backupSelection == BackupSelection.excluded; + + final syncAlbum = ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.syncAlbums); + + buildTileColor() { + if (isSelected) { + return context.isDarkTheme + ? context.primaryColor.withAlpha(100) + : context.primaryColor.withAlpha(25); + } else if (isExcluded) { + return context.isDarkTheme + ? Colors.red[300]?.withAlpha(150) + : Colors.red[100]?.withAlpha(150); + } else { + return Colors.transparent; + } + } + + buildIcon() { + if (isSelected) { + return Icon( + Icons.check_circle_rounded, + color: context.colorScheme.primary, + ); + } + + if (isExcluded) { + return Icon( + Icons.remove_circle_rounded, + color: context.colorScheme.error, + ); + } + + return Icon( + Icons.circle, + color: context.colorScheme.surfaceContainerHighest, + ); + } + + return GestureDetector( + onDoubleTap: () { + ref.watch(hapticFeedbackProvider.notifier).selectionClick(); + + if (isExcluded) { + ref.read(backupAlbumProvider.notifier).deselectAlbum(album); + } else { + if (album.id == 'isAll' || album.name == 'Recents') { + ImmichToast.show( + context: context, + msg: 'Cannot exclude album contains all assets', + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + return; + } + + ref.read(backupAlbumProvider.notifier).excludeAlbum(album); + } + }, + child: ListTile( + tileColor: buildTileColor(), + contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + onTap: () { + ref.read(hapticFeedbackProvider.notifier).selectionClick(); + if (isSelected) { + ref.read(backupAlbumProvider.notifier).deselectAlbum(album); + } else { + ref.read(backupAlbumProvider.notifier).selectAlbum(album); + if (syncAlbum) { + ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } + } + }, + leading: buildIcon(), + title: Text( + album.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text(album.assetCount.toString()), + trailing: IconButton( + onPressed: () { + context.pushRoute(LocalTimelineRoute(album: album)); + }, + icon: Icon( + Icons.image_outlined, + color: context.primaryColor, + size: 24, + ), + splashRadius: 25, + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index b58a1ad6f9..2b020d20ee 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -205,7 +205,7 @@ class _BackupIndicator extends ConsumerWidget { final badgeBackground = context.colorScheme.surfaceContainer; return InkWell( - onTap: () => context.pushRoute(const BackupControllerRoute()), + onTap: () => context.pushRoute(const DriftBackupRoute()), borderRadius: const BorderRadius.all(Radius.circular(12)), child: Badge( label: Container(