diff --git a/i18n/en.json b/i18n/en.json index c66d1d3443..ac5a0a8cc0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -937,6 +937,7 @@ "download_waiting_to_retry": "Waiting to retry", "downloading": "Downloading", "downloading_asset_filename": "Downloading asset {filename}", + "downloading_from_icloud": "Downloading from iCloud", "downloading_media": "Downloading media", "drop_files_to_upload": "Drop files anywhere to upload", "duplicates": "Duplicates", @@ -1122,6 +1123,7 @@ "unable_to_update_workflow": "Unable to update workflow", "unable_to_upload_file": "Unable to upload file" }, + "errors_text": "Errors", "exclusion_pattern": "Exclusion pattern", "exif": "Exif", "exif_bottom_sheet_description": "Add Description...", @@ -2236,7 +2238,6 @@ "updated_at": "Updated", "updated_password": "Updated password", "upload": "Upload", - "upload_action_prompt": "{count} queued for upload", "upload_concurrency": "Upload concurrency", "upload_details": "Upload Details", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 4e4cb2ed13..108fb7e2aa 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -55,6 +55,7 @@ import UIKit NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!) ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl()) + ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl()) } public static func cancelPlugins(with engine: FlutterEngine) { diff --git a/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift b/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift index 0261cb26fb..f104314fae 100644 --- a/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift +++ b/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift @@ -1,6 +1,60 @@ +import Network class ConnectivityApiImpl: ConnectivityApi { + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "ConnectivityMonitor") + private var currentPath: NWPath? + + init() { + monitor.pathUpdateHandler = { [weak self] path in + self?.currentPath = path + } + monitor.start(queue: queue) + // Get initial state synchronously + currentPath = monitor.currentPath + } + + deinit { + monitor.cancel() + } + func getCapabilities() throws -> [NetworkCapability] { - [] + guard let path = currentPath else { + return [] + } + + guard path.status == .satisfied else { + return [] + } + + var capabilities: [NetworkCapability] = [] + + if path.usesInterfaceType(.wifi) { + capabilities.append(.wifi) + } + + if path.usesInterfaceType(.cellular) { + capabilities.append(.cellular) + } + + // Check for VPN - iOS reports VPN as .other interface type in many cases + // or through the path's expensive property when on cellular with VPN + if path.usesInterfaceType(.other) { + capabilities.append(.vpn) + } + + // Determine if connection is unmetered: + // - Must be on WiFi (not cellular) + // - Must not be expensive (rules out personal hotspot) + // - Must not be constrained (Low Data Mode) + // Note: VPN over cellular should still be considered metered + let isOnCellular = path.usesInterfaceType(.cellular) + let isOnWifi = path.usesInterfaceType(.wifi) + + if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained { + capabilities.append(.unmetered) + } + + return capabilities } } diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 8a237f801a..9019db664d 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -9,7 +9,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; @@ -20,13 +19,13 @@ import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; -import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart'; @@ -243,13 +242,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { } if (Platform.isIOS) { - return _ref?.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); + return _ref?.read(driftBackupProvider.notifier).startBackupWithURLSession(currentUser.id); } - final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? []; return _ref - ?.read(uploadServiceProvider) - .startBackupWithHttpClient(currentUser.id, networkCapabilities.isUnmetered, _cancellationToken); + ?.read(foregroundUploadServiceProvider) + .uploadCandidates(currentUser.id, _cancellationToken, useSequentialUpload: true); }, (error, stack) { dPrint(() => "Error in backup zone $error, $stack"); diff --git a/mobile/lib/infrastructure/repositories/storage.repository.dart b/mobile/lib/infrastructure/repositories/storage.repository.dart index 9532025d58..eaa6ce79f7 100644 --- a/mobile/lib/infrastructure/repositories/storage.repository.dart +++ b/mobile/lib/infrastructure/repositories/storage.repository.dart @@ -6,7 +6,9 @@ import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart'; class StorageRepository { - const StorageRepository(); + final log = Logger('StorageRepository'); + + StorageRepository(); Future getFileForAsset(String assetId) async { File? file; @@ -82,6 +84,51 @@ class StorageRepository { return entity; } + Future isAssetAvailableLocally(String assetId) async { + try { + final entity = await AssetEntity.fromId(assetId); + if (entity == null) { + log.warning("Cannot get AssetEntity for asset $assetId"); + return false; + } + + return await entity.isLocallyAvailable(isOrigin: true); + } catch (error, stackTrace) { + log.warning("Error checking if asset is locally available $assetId", error, stackTrace); + return false; + } + } + + Future loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async { + try { + final entity = await AssetEntity.fromId(assetId); + if (entity == null) { + log.warning("Cannot get AssetEntity for asset $assetId"); + return null; + } + + return await entity.loadFile(progressHandler: progressHandler); + } catch (error, stackTrace) { + log.warning("Error loading file from cloud for asset $assetId", error, stackTrace); + return null; + } + } + + Future loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async { + try { + final entity = await AssetEntity.fromId(assetId); + if (entity == null) { + log.warning("Cannot get AssetEntity for asset $assetId"); + return null; + } + + return await entity.loadFile(withSubtype: true, progressHandler: progressHandler); + } catch (error, stackTrace) { + log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace); + return null; + } + } + Future clearCache() async { final log = Logger('StorageRepository'); diff --git a/mobile/lib/models/upload/share_intent_attachment.model.dart b/mobile/lib/models/upload/share_intent_attachment.model.dart index ae05e4c492..e5388fce2c 100644 --- a/mobile/lib/models/upload/share_intent_attachment.model.dart +++ b/mobile/lib/models/upload/share_intent_attachment.model.dart @@ -7,7 +7,7 @@ import 'package:path/path.dart'; enum ShareIntentAttachmentType { image, video } -enum UploadStatus { enqueued, running, complete, notFound, failed, canceled, waitingToRetry, paused } +enum UploadStatus { enqueued, running, complete, failed } class ShareIntentAttachment { final String path; diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index 47052ea436..440544f989 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -93,11 +93,11 @@ class _DriftBackupPageState extends ConsumerState { Logger("DriftBackupPage").warning("Remote sync did not complete successfully, skipping backup"); return; } - await backupNotifier.startBackup(currentUser.id); + await backupNotifier.startForegroundBackup(currentUser.id); } Future stopBackup() async { - await backupNotifier.cancel(); + await backupNotifier.stopForegroundBackup(); } return Scaffold( diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart index 5fe1dfb6a1..93ab659032 100644 --- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -113,10 +113,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState backgroundSync.hashAssets())); if (isBackupEnabled) { unawaited( - backupNotifier.cancel().whenComplete( + backupNotifier.stopForegroundBackup().whenComplete( () => backgroundSync.syncRemote().then((success) { if (success) { - return backupNotifier.startBackup(user.id); + return backupNotifier.startForegroundBackup(user.id); } else { Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup'); } diff --git a/mobile/lib/pages/backup/drift_backup_options.page.dart b/mobile/lib/pages/backup/drift_backup_options.page.dart index 1e5c326478..f43c8b6a8e 100644 --- a/mobile/lib/pages/backup/drift_backup_options.page.dart +++ b/mobile/lib/pages/backup/drift_backup_options.page.dart @@ -60,10 +60,10 @@ class DriftBackupOptionsPage extends ConsumerWidget { final backupNotifier = ref.read(driftBackupProvider.notifier); final backgroundSync = ref.read(backgroundSyncProvider); unawaited( - backupNotifier.cancel().whenComplete( + backupNotifier.stopForegroundBackup().whenComplete( () => backgroundSync.syncRemote().then((success) { if (success) { - return backupNotifier.startBackup(currentUser.id); + return backupNotifier.startForegroundBackup(currentUser.id); } else { Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup'); } diff --git a/mobile/lib/pages/backup/drift_upload_detail.page.dart b/mobile/lib/pages/backup/drift_upload_detail.page.dart index 612b6a8111..71249d1c4b 100644 --- a/mobile/lib/pages/backup/drift_upload_detail.page.dart +++ b/mobile/lib/pages/backup/drift_upload_detail.page.dart @@ -11,12 +11,70 @@ import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:path/path.dart' as path; @RoutePage() -class DriftUploadDetailPage extends ConsumerWidget { +class DriftUploadDetailPage extends ConsumerStatefulWidget { const DriftUploadDetailPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _DriftUploadDetailPageState(); +} + +class _DriftUploadDetailPageState extends ConsumerState { + final Set _seenTaskIds = {}; + final Set _failedTaskIds = {}; + + final Map _taskSlotAssignments = {}; + static const int _maxSlots = 3; + + /// Assigns uploading items to fixed slots to prevent jumping when items complete + List _assignItemsToSlots(List uploadingItems) { + final slots = List.filled(_maxSlots, null); + final currentTaskIds = uploadingItems.map((e) => e.taskId).toSet(); + + _taskSlotAssignments.removeWhere((taskId, _) => !currentTaskIds.contains(taskId)); + + for (final item in uploadingItems) { + final existingSlot = _taskSlotAssignments[item.taskId]; + if (existingSlot != null && existingSlot < _maxSlots) { + slots[existingSlot] = item; + } + } + + for (final item in uploadingItems) { + if (_taskSlotAssignments.containsKey(item.taskId)) continue; + + for (int i = 0; i < _maxSlots; i++) { + if (slots[i] == null) { + slots[i] = item; + _taskSlotAssignments[item.taskId] = i; + break; + } + } + } + + return slots; + } + + @override + Widget build(BuildContext context) { final uploadItems = ref.watch(driftBackupProvider.select((state) => state.uploadItems)); + final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress)); + + for (final item in uploadItems.values) { + if (item.isFailed == true) { + _failedTaskIds.add(item.taskId); + } + } + + for (final item in uploadItems.values) { + if (item.progress >= 1.0 && item.isFailed != true && !_failedTaskIds.contains(item.taskId)) { + if (!_seenTaskIds.contains(item.taskId)) { + _seenTaskIds.add(item.taskId); + } + } + } + + final uploadingItems = uploadItems.values.where((item) => item.progress < 1.0 && item.isFailed != true).toList(); + final failedItems = uploadItems.values.where((item) => item.isFailed == true).toList(); return Scaffold( appBar: AppBar( @@ -25,98 +83,326 @@ class DriftUploadDetailPage extends ConsumerWidget { elevation: 0, scrolledUnderElevation: 1, ), - body: uploadItems.isEmpty ? _buildEmptyState(context) : _buildUploadList(uploadItems), + body: _buildTwoSectionLayout(context, uploadingItems, failedItems, iCloudProgress), ); } - Widget _buildEmptyState(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.cloud_off_rounded, size: 80, color: context.colorScheme.onSurface.withValues(alpha: 0.3)), - const SizedBox(height: 16), - Text( - "no_uploads_in_progress".t(context: context), - style: context.textTheme.titleMedium?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.6)), + Widget _buildTwoSectionLayout( + BuildContext context, + List uploadingItems, + List failedItems, + Map iCloudProgress, + ) { + return CustomScrollView( + slivers: [ + // iCloud Downloads Section + if (iCloudProgress.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _buildSectionHeader( + context, + title: "Downloading from iCloud", + count: iCloudProgress.length, + color: context.colorScheme.tertiary, + ), ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final entry = iCloudProgress.entries.elementAt(index); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildICloudDownloadCard(context, entry.key, entry.value), + ); + }, childCount: iCloudProgress.length), + ), + ), + ], + + // Uploading Section + SliverToBoxAdapter( + child: _buildSectionHeader( + context, + title: "uploading".t(context: context), + count: uploadingItems.length, + color: context.colorScheme.primary, + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + // Use slot-based assignment to prevent items from jumping + final slots = _assignItemsToSlots(uploadingItems); + final item = slots[index]; + if (item != null) { + return _buildCurrentUploadCard(context, item); + } else { + return _buildPlaceholderCard(context); + } + }, childCount: 3), + ), + ), + + // Errors Section + if (failedItems.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _buildSectionHeader( + context, + title: "errors_text".t(context: context), + count: failedItems.length, + color: context.colorScheme.error, + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = failedItems[index]; + return Padding(padding: const EdgeInsets.only(bottom: 8), child: _buildErrorCard(context, item)); + }, childCount: failedItems.length), + ), + ), + ], + + // Bottom padding + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + } + + Widget _buildSectionHeader(BuildContext context, {required String title, int? count, required Color color}) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600, color: color), + ), + const SizedBox(width: 8), + count != null + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: Text( + count.toString(), + style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, color: color), + ), + ) + : const SizedBox.shrink(), ], ), ); } - Widget _buildUploadList(Map uploadItems) { - return ListView.separated( - addAutomaticKeepAlives: true, - padding: const EdgeInsets.all(16), - itemCount: uploadItems.length, - separatorBuilder: (context, index) => const SizedBox(height: 4), - itemBuilder: (context, index) { - final item = uploadItems.values.elementAt(index); - return _buildUploadCard(context, item); - }, - ); - } - - Widget _buildUploadCard(BuildContext context, DriftUploadStatus item) { - final isCompleted = item.progress >= 1.0; - final double progressPercentage = (item.progress * 100).clamp(0, 100); + Widget _buildICloudDownloadCard(BuildContext context, String assetId, double progress) { + final double progressPercentage = (progress * 100).clamp(0, 100); return Card( elevation: 0, - color: item.isFailed != null ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainer, + color: context.colorScheme.tertiaryContainer.withValues(alpha: 0.5), shape: RoundedRectangleBorder( - borderRadius: const BorderRadius.all(Radius.circular(16)), - side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1), + borderRadius: const BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: context.colorScheme.tertiary.withValues(alpha: 0.3), width: 1), ), - child: InkWell( - onTap: () => _showFileDetailDialog(context, item), - borderRadius: const BorderRadius.all(Radius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: context.colorScheme.tertiary.withValues(alpha: 0.2), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Icon(Icons.cloud_download_rounded, size: 24, color: context.colorScheme.tertiary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - Text( - path.basename(item.filename), - style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (item.error != null) - Text( - item.error!, - style: context.textTheme.bodySmall?.copyWith( - color: context.colorScheme.onErrorContainer.withValues(alpha: 0.6), - ), - ), - Text( - "backup_upload_details_page_more_details".t(context: context), - style: context.textTheme.bodySmall?.copyWith( - color: context.colorScheme.onSurface.withValues(alpha: 0.6), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), + Text( + "downloading_from_icloud".t(context: context), + style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - _buildProgressIndicator( - context, - item.progress, - progressPercentage, - isCompleted, - item.networkSpeedAsString, + const SizedBox(height: 4), + Text( + assetId, + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: LinearProgressIndicator( + value: progress, + backgroundColor: context.colorScheme.tertiary.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation(context.colorScheme.tertiary), + minHeight: 4, + ), ), ], ), + ), + const SizedBox(width: 12), + SizedBox( + width: 48, + child: Text( + "${progressPercentage.toStringAsFixed(0)}%", + textAlign: TextAlign.right, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: context.colorScheme.tertiary, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCurrentUploadCard(BuildContext context, DriftUploadStatus item) { + final double progressPercentage = (item.progress * 100).clamp(0, 100); + final isFailed = item.isFailed == true; + + return Card( + elevation: 0, + color: isFailed + ? context.colorScheme.errorContainer + : context.colorScheme.primaryContainer.withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + side: BorderSide( + color: isFailed + ? context.colorScheme.error.withValues(alpha: 0.3) + : context.colorScheme.primary.withValues(alpha: 0.3), + width: 1, + ), + ), + child: InkWell( + onTap: () => _showFileDetailDialog(context, item), + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + height: 64, + child: Row( + children: [ + _CurrentUploadThumbnail(taskId: item.taskId), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + path.basename(item.filename), + style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + isFailed + ? item.error ?? "unable_to_upload_file".t(context: context) + : "${formatHumanReadableBytes(item.fileSize, 1)} • ${item.networkSpeedAsString}", + style: context.textTheme.labelLarge?.copyWith( + color: isFailed + ? context.colorScheme.error + : context.colorScheme.onSurface.withValues(alpha: 0.6), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (!isFailed) ...[ + const SizedBox(height: 8), + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: LinearProgressIndicator( + value: item.progress, + backgroundColor: context.colorScheme.primary.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation(context.colorScheme.primary), + minHeight: 4, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 48, + child: isFailed + ? Icon(Icons.error_rounded, color: context.colorScheme.error, size: 28) + : Text( + "${progressPercentage.toStringAsFixed(0)}%", + textAlign: TextAlign.right, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: context.colorScheme.primary, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildErrorCard(BuildContext context, DriftUploadStatus item) { + return Card( + elevation: 0, + color: context.colorScheme.errorContainer, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: context.colorScheme.error.withValues(alpha: 0.3), width: 1), + ), + child: InkWell( + onTap: () => _showFileDetailDialog(context, item), + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + _CurrentUploadThumbnail(taskId: item.taskId), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + path.basename(item.filename), + style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + item.error ?? "unable_to_upload_file".t(context: context), + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.error), + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 12), + Icon(Icons.error_rounded, color: context.colorScheme.error, size: 28), ], ), ), @@ -124,49 +410,84 @@ class DriftUploadDetailPage extends ConsumerWidget { ); } - Widget _buildProgressIndicator( - BuildContext context, - double progress, - double percentage, - bool isCompleted, - String networkSpeedAsString, - ) { - return Column( - children: [ - Stack( - alignment: AlignmentDirectional.center, - children: [ - SizedBox( - width: 36, - height: 36, - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: progress), - duration: const Duration(milliseconds: 300), - builder: (context, value, _) => CircularProgressIndicator( - backgroundColor: context.colorScheme.outline.withValues(alpha: 0.2), - strokeWidth: 3, - value: value, - color: isCompleted ? context.colorScheme.primary : context.colorScheme.secondary, + Widget _buildPlaceholderCard(BuildContext context) { + return Card( + elevation: 0, + color: context.colorScheme.surfaceContainerLow.withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1, style: BorderStyle.solid), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + height: 64, + child: Row( + children: [ + SizedBox( + width: 48, + height: 48, + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.outline.withValues(alpha: 0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Icon( + Icons.hourglass_empty_rounded, + size: 24, + color: context.colorScheme.outline.withValues(alpha: 0.3), + ), ), ), - ), - if (isCompleted) - Icon(Icons.check_circle_rounded, size: 28, color: context.colorScheme.primary) - else - Text( - percentage.toStringAsFixed(0), - style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, fontSize: 10), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 14, + width: 120, + decoration: BoxDecoration( + color: context.colorScheme.outline.withValues(alpha: 0.1), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + const SizedBox(height: 6), + Container( + height: 10, + width: 80, + decoration: BoxDecoration( + color: context.colorScheme.outline.withValues(alpha: 0.08), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + const SizedBox(height: 8), + Container( + height: 4, + decoration: BoxDecoration( + color: context.colorScheme.outline.withValues(alpha: 0.1), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + ], + ), ), - ], - ), - Text( - networkSpeedAsString, - style: context.textTheme.labelSmall?.copyWith( - color: context.colorScheme.onSurface.withValues(alpha: 0.6), - fontSize: 10, + const SizedBox(width: 12), + SizedBox( + width: 48, + child: Text( + "0%", + textAlign: TextAlign.right, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: context.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + ), + ], ), ), - ], + ), ); } @@ -178,9 +499,44 @@ class DriftUploadDetailPage extends ConsumerWidget { } } +class _CurrentUploadThumbnail extends ConsumerWidget { + final String taskId; + const _CurrentUploadThumbnail({required this.taskId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return FutureBuilder( + future: _getAsset(ref), + builder: (context, snapshot) { + return SizedBox( + width: 48, + height: 48, + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.primary.withValues(alpha: 0.2), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + clipBehavior: Clip.antiAlias, + child: snapshot.data != null + ? Thumbnail.fromAsset(asset: snapshot.data!, size: const Size(48, 48), fit: BoxFit.cover) + : Icon(Icons.image, size: 24, color: context.colorScheme.primary), + ), + ); + }, + ); + } + + Future _getAsset(WidgetRef ref) async { + try { + return await ref.read(localAssetRepository).getById(taskId); + } catch (e) { + return null; + } + } +} + class FileDetailDialog extends ConsumerWidget { final DriftUploadStatus uploadStatus; - const FileDetailDialog({super.key, required this.uploadStatus}); @override @@ -212,14 +568,12 @@ class FileDetailDialog extends ConsumerWidget { if (snapshot.connectionState == ConnectionState.waiting) { return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator())); } - final asset = snapshot.data; return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - // Thumbnail at the top center Center( child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(12)), @@ -237,7 +591,7 @@ class FileDetailDialog extends ConsumerWidget { ), ), const SizedBox(height: 24), - if (asset != null) ...[ + if (asset != null) _buildInfoSection(context, [ _buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)), _buildInfoRow(context, "local_id".t(context: context), asset.id), @@ -254,7 +608,6 @@ class FileDetailDialog extends ConsumerWidget { if (asset.checksum != null) _buildInfoRow(context, "checksum".t(context: context), asset.checksum!), ]), - ], ], ), ); @@ -282,7 +635,7 @@ class FileDetailDialog extends ConsumerWidget { borderRadius: const BorderRadius.all(Radius.circular(12)), border: Border.all(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1), ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [...children]), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), ); } @@ -303,12 +656,7 @@ class FileDetailDialog extends ConsumerWidget { ), ), Expanded( - child: Text( - value, - style: context.textTheme.labelMedium?.copyWith(), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), + child: Text(value, style: context.textTheme.labelMedium, maxLines: 3, overflow: TextOverflow.ellipsis), ), ], ), @@ -317,8 +665,7 @@ class FileDetailDialog extends ConsumerWidget { Future _getAssetDetails(WidgetRef ref, String localAssetId) async { try { - final repository = ref.read(localAssetRepository); - return await repository.getById(localAssetId); + return await ref.read(localAssetRepository).getById(localAssetId); } catch (e) { return null; } diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 6c024600c9..ac23e6ddce 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -130,7 +130,7 @@ class SplashScreenPageState extends ConsumerState { if (isEnableBackup) { final currentUser = Store.tryGet(StoreKey.currentUser); if (currentUser != null) { - unawaited(notifier.handleBackupResume(currentUser.id)); + unawaited(notifier.startForegroundBackup(currentUser.id)); } } } diff --git a/mobile/lib/pages/share_intent/share_intent.page.dart b/mobile/lib/pages/share_intent/share_intent.page.dart index 9d2dbe80c2..2be51fbfc9 100644 --- a/mobile/lib/pages/share_intent/share_intent.page.dart +++ b/mobile/lib/pages/share_intent/share_intent.page.dart @@ -1,7 +1,6 @@ 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' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -12,7 +11,7 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/url_helper.dart'; @RoutePage() -class ShareIntentPage extends HookConsumerWidget { +class ShareIntentPage extends ConsumerWidget { const ShareIntentPage({super.key, required this.attachments}); final List attachments; @@ -21,12 +20,13 @@ class ShareIntentPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final currentEndpoint = getServerUrl() ?? '--'; final candidates = ref.watch(shareIntentUploadProvider); - final isUploaded = useState(false); - useOnAppLifecycleStateChange((previous, current) { - if (current == AppLifecycleState.resumed) { - isUploaded.value = false; - } - }); + + final isUploading = candidates.any((candidate) => candidate.status == UploadStatus.running); + final isUploaded = + candidates.isNotEmpty && + candidates.every( + (candidate) => candidate.status == UploadStatus.complete || candidate.status == UploadStatus.failed, + ); void removeAttachment(ShareIntentAttachment attachment) { ref.read(shareIntentUploadProvider.notifier).removeAttachment(attachment); @@ -37,11 +37,8 @@ class ShareIntentPage extends HookConsumerWidget { } void upload() async { - for (final attachment in candidates) { - await ref.read(shareIntentUploadProvider.notifier).upload(attachment.file); - } - - isUploaded.value = true; + final files = candidates.map((candidate) => candidate.file).toList(); + await ref.read(shareIntentUploadProvider.notifier).uploadAll(files); } bool isSelected(ShareIntentAttachment attachment) { @@ -84,7 +81,7 @@ class ShareIntentPage extends HookConsumerWidget { padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16), child: LargeLeadingTile( onTap: () => toggleSelection(attachment), - disabled: isUploaded.value, + disabled: isUploading || isUploaded, selected: isSelected(attachment), leading: Stack( children: [ @@ -131,8 +128,8 @@ class ShareIntentPage extends HookConsumerWidget { child: SizedBox( height: 48, child: ElevatedButton( - onPressed: isUploaded.value ? null : upload, - child: isUploaded.value ? UploadingText(candidates: candidates) : const Text('upload').tr(), + onPressed: (isUploading || isUploaded) ? null : upload, + child: (isUploading || isUploaded) ? UploadingText(candidates: candidates) : const Text('upload').tr(), ), ), ), @@ -204,14 +201,7 @@ class UploadStatusIcon extends StatelessWidget { ], ), UploadStatus.complete => Icon(Icons.check_circle_rounded, color: Colors.green, semanticLabel: 'completed'.tr()), - UploadStatus.notFound || UploadStatus.failed => Icon(Icons.error_rounded, color: Colors.red, semanticLabel: 'failed'.tr()), - UploadStatus.canceled => Icon(Icons.cancel_rounded, color: Colors.red, semanticLabel: 'canceled'.tr()), - UploadStatus.waitingToRetry || UploadStatus.paused => Icon( - Icons.pause_circle_rounded, - color: context.primaryColor, - semanticLabel: 'paused'.tr(), - ), }; return statusIcon; diff --git a/mobile/lib/presentation/pages/editing/drift_edit.page.dart b/mobile/lib/presentation/pages/editing/drift_edit.page.dart index f9903b6b94..7e49348e19 100644 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_edit.page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:ui'; import 'package:auto_route/auto_route.dart'; +import 'package:cancellation_token_http/http.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -12,7 +13,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; @@ -78,7 +79,7 @@ class DriftEditImagePage extends ConsumerWidget { return; } - await ref.read(uploadServiceProvider).manualBackup([localAsset]); + await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken()); } catch (e) { ImmichToast.show( durationInSecond: 6, diff --git a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart index 98ef831f9c..d69c5bced3 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -1,12 +1,17 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_ui/immich_ui.dart'; class UploadActionButton extends ConsumerWidget { final ActionSource source; @@ -20,19 +25,38 @@ class UploadActionButton extends ConsumerWidget { return; } - final result = await ref.read(actionProvider.notifier).upload(source); + final isTimeline = source == ActionSource.timeline; + List? assets; - final successMessage = 'upload_action_prompt'.t(context: context, args: {'count': result.count.toString()}); + if (source == ActionSource.timeline) { + assets = ref.read(multiSelectProvider).selectedAssets.whereType().toList(); + if (assets.isEmpty) { + return; + } + ref.read(multiSelectProvider.notifier).reset(); + } else { + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => const _UploadProgressDialog(), + ), + ); + } - if (context.mounted) { + final result = await ref.read(actionProvider.notifier).upload(source, assets: assets); + + if (!isTimeline && context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + + if (context.mounted && !result.success) { ImmichToast.show( context: context, - msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + msg: 'scaffold_body_error_occurred'.t(context: context), gravity: ToastGravity.BOTTOM, - toastType: result.success ? ToastType.success : ToastType.error, + toastType: ToastType.error, ); - - ref.read(multiSelectProvider.notifier).reset(); } } @@ -47,3 +71,42 @@ class UploadActionButton extends ConsumerWidget { ); } } + +class _UploadProgressDialog extends ConsumerWidget { + const _UploadProgressDialog(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final progressMap = ref.watch(assetUploadProgressProvider); + + // Calculate overall progress from all assets + final values = progressMap.values.where((v) => v >= 0).toList(); + final progress = values.isEmpty ? 0.0 : values.reduce((a, b) => a + b) / values.length; + final hasError = progressMap.values.any((v) => v < 0); + final percentage = (progress * 100).toInt(); + + return AlertDialog( + title: Text('uploading'.t(context: context)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasError) + const Icon(Icons.error_outline, color: Colors.red, size: 48) + else + CircularProgressIndicator(value: progress > 0 ? progress : null), + const SizedBox(height: 16), + Text(hasError ? 'Error' : '$percentage%'), + ], + ), + actions: [ + ImmichTextButton( + onPressed: () { + ref.read(manualUploadCancelTokenProvider)?.cancel(); + Navigator.of(context).pop(); + }, + labelText: 'cancel'.t(context: context), + ), + ], + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 8727f40a1a..538a9bde20 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -96,7 +96,7 @@ class NativeVideoViewer extends HookConsumerWidget { try { if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; - final file = await const StorageRepository().getFileForAsset(id); + final file = await StorageRepository().getFileForAsset(id); if (!context.mounted) { return null; } diff --git a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart index ae4cfbd1c6..6361475f26 100644 --- a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart +++ b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; @@ -57,17 +56,13 @@ class BackupToggleButtonState extends ConsumerState with Sin @override Widget build(BuildContext context) { - final enqueueCount = ref.watch(driftBackupProvider.select((state) => state.enqueueCount)); - - final enqueueTotalCount = ref.watch(driftBackupProvider.select((state) => state.enqueueTotalCount)); - - final isCanceling = ref.watch(driftBackupProvider.select((state) => state.isCanceling)); - final uploadTasks = ref.watch(driftBackupProvider.select((state) => state.uploadItems)); final isSyncing = ref.watch(driftBackupProvider.select((state) => state.isSyncing)); - final isProcessing = uploadTasks.isNotEmpty || isSyncing; + final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress)); + + final isProcessing = uploadTasks.isNotEmpty || isSyncing || iCloudProgress.isNotEmpty; return AnimatedBuilder( animation: _animationController, @@ -115,7 +110,7 @@ class BackupToggleButtonState extends ConsumerState with Sin borderRadius: const BorderRadius.all(Radius.circular(20.5)), child: InkWell( borderRadius: const BorderRadius.all(Radius.circular(20.5)), - onTap: () => isCanceling ? null : _onToggle(!_isEnabled), + onTap: () => _onToggle(!_isEnabled), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row( @@ -154,35 +149,10 @@ class BackupToggleButtonState extends ConsumerState with Sin ), ], ), - if (enqueueCount != enqueueTotalCount) - Text( - "queue_status".t( - context: context, - args: {'count': enqueueCount.toString(), 'total': enqueueTotalCount.toString()}, - ), - style: context.textTheme.labelLarge?.copyWith( - color: context.colorScheme.onSurfaceSecondary, - ), - ), - if (isCanceling) - Row( - children: [ - Text("canceling".t(), style: context.textTheme.labelLarge), - const SizedBox(width: 4), - SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - backgroundColor: context.colorScheme.onSurface.withValues(alpha: 0.2), - ), - ), - ], - ), ], ), ), - Switch.adaptive(value: _isEnabled, onChanged: (value) => isCanceling ? null : _onToggle(value)), + Switch.adaptive(value: _isEnabled, onChanged: (value) => _onToggle(value)), ], ), ), diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 6540c15085..bdaf67ab7e 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -62,6 +63,10 @@ class _ThumbnailTileState extends ConsumerState { _showSelectionContainer = true; } + final uploadProgress = asset is LocalAsset + ? ref.watch(assetUploadProgressProvider.select((map) => map[asset.id])) + : null; + return Stack( children: [ Container( @@ -168,6 +173,7 @@ class _ThumbnailTileState extends ConsumerState { ), ), ), + if (uploadProgress != null) _UploadProgressOverlay(progress: uploadProgress), ], ), ), @@ -293,3 +299,46 @@ class _AssetTypeIcons extends StatelessWidget { ); } } + +class _UploadProgressOverlay extends StatelessWidget { + final double progress; + + const _UploadProgressOverlay({required this.progress}); + + @override + Widget build(BuildContext context) { + final isError = progress < 0; + final percentage = isError ? 0 : (progress * 100).toInt(); + + return Positioned.fill( + child: Container( + color: isError ? Colors.red.withValues(alpha: 0.6) : Colors.black54, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isError) + const Icon(Icons.error_outline, color: Colors.white, size: 36) + else + SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + value: progress, + strokeWidth: 3, + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation(Colors.white), + ), + ), + const SizedBox(height: 4), + Text( + isError ? 'Error' : '$percentage%', + style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 20ae8d20a3..604f1c8d0d 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -181,7 +181,7 @@ class AppLifeCycleNotifier extends StateNotifier { final currentUser = Store.tryGet(StoreKey.currentUser); if (currentUser != null) { await _safeRun( - _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id), + _ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id), "handleBackupResume", ); } @@ -238,6 +238,8 @@ class AppLifeCycleNotifier extends StateNotifier { if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) { _ref.read(backupProvider.notifier).cancelBackup(); } + } else { + await _ref.read(driftBackupProvider.notifier).stopForegroundBackup(); } _ref.read(websocketProvider.notifier).disconnect(); 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 881fdc359f..66a8deb466 100644 --- a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart +++ b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart @@ -1,37 +1,28 @@ import 'dart:io'; -import 'package:background_downloader/background_downloader.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/constants.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/share_intent_service.dart'; -import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:logging/logging.dart'; -import 'package:path/path.dart'; +import 'package:path/path.dart' as p; final shareIntentUploadProvider = StateNotifierProvider>( ((ref) => ShareIntentUploadStateNotifier( ref.watch(appRouterProvider), - ref.watch(uploadServiceProvider), - ref.watch(shareIntentServiceProvider), + ref.read(foregroundUploadServiceProvider), + ref.read(shareIntentServiceProvider), )), ); class ShareIntentUploadStateNotifier extends StateNotifier> { final AppRouter router; - final UploadService _uploadService; + final ForegroundUploadService _foregroundUploadService; final ShareIntentService _shareIntentService; final Logger _logger = Logger('ShareIntentUploadStateNotifier'); - ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) { - _uploadService.taskStatusStream.listen(_updateUploadStatus); - _uploadService.taskProgressStream.listen(_taskProgressCallback); - } + ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]); void init() { _shareIntentService.onSharedMedia = onSharedMedia; @@ -67,97 +58,44 @@ class ShareIntentUploadStateNotifier extends StateNotifier uploadAll(List files) async { + for (final file in files) { + final fileId = p.hash(file.path).toString(); + _updateStatus(fileId, UploadStatus.running); } - final taskId = task.task.taskId; - final uploadStatus = switch (task.status) { - TaskStatus.complete => UploadStatus.complete, - TaskStatus.failed => UploadStatus.failed, - TaskStatus.canceled => UploadStatus.canceled, - TaskStatus.enqueued => UploadStatus.enqueued, - TaskStatus.running => UploadStatus.running, - TaskStatus.paused => UploadStatus.paused, - TaskStatus.notFound => UploadStatus.notFound, - TaskStatus.waitingToRetry => UploadStatus.waitingToRetry, - }; - - state = [ - for (final attachment in state) - if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment, - ]; - - if (task.status == TaskStatus.failed) { - String? error; - final exception = task.exception; - if (exception != null && exception is TaskHttpException) { - final message = tryJsonDecode(exception.description)?['message'] as String?; - if (message != null) { - final responseCode = exception.httpResponseCode; - error = "${exception.exceptionType}, response code $responseCode: $message"; - } - } - error ??= task.exception?.toString(); - - _logger.warning("Upload failed for asset: ${task.task.filename}, error: $error"); - } - } - - void _taskProgressCallback(TaskProgressUpdate update) { - // Ignore if the task is canceled or completed - if (update.progress == downloadFailed || update.progress == downloadCompleted) { - return; - } - - final taskId = update.task.taskId; - state = [ - for (final attachment in state) - if (attachment.id == taskId.toInt()) attachment.copyWith(uploadProgress: update.progress) else attachment, - ]; - } - - Future upload(File file) async { - final task = await _buildUploadTask(hash(file.path).toString(), file); - - await _uploadService.enqueueTasks([task]); - } - - Future _buildUploadTask(String id, File file, {Map? fields}) async { - final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final url = Uri.parse('$serverEndpoint/assets').toString(); - final headers = ApiService.getRequestHeaders(); - final deviceId = Store.get(StoreKey.deviceId); - - final (baseDirectory, directory, filename) = await Task.split(filePath: file.path); - final stats = await file.stat(); - final fileCreatedAt = stats.changed; - final fileModifiedAt = stats.modified; - - final fieldsMap = { - 'filename': filename, - 'deviceAssetId': id, - 'deviceId': deviceId, - 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), - 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), - 'isFavorite': 'false', - 'duration': '0', - if (fields != null) ...fields, - }; - - return UploadTask( - taskId: id, - httpRequestMethod: 'POST', - url: url, - headers: headers, - filename: filename, - fields: fieldsMap, - baseDirectory: baseDirectory, - directory: directory, - fileField: 'assetData', - group: kManualUploadGroup, - updates: Updates.statusAndProgress, + await _foregroundUploadService.uploadShareIntent( + files, + onProgress: (fileId, bytes, totalBytes) { + final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; + _updateProgress(fileId, progress); + }, + onSuccess: (fileId) { + _updateStatus(fileId, UploadStatus.complete, progress: 1.0); + }, + onError: (fileId, errorMessage) { + _logger.warning("Upload failed for file: $fileId, error: $errorMessage"); + _updateStatus(fileId, UploadStatus.failed); + }, ); } + + void _updateStatus(String fileId, UploadStatus status, {double? progress}) { + final id = int.parse(fileId); + state = [ + for (final attachment in state) + if (attachment.id == id) + attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress) + else + attachment, + ]; + } + + void _updateProgress(String fileId, double progress) { + final id = int.parse(fileId); + state = [ + for (final attachment in state) + if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment, + ]; + } } diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 91f245afc4..49dc10240b 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -11,8 +11,9 @@ import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; -import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/widget.service.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/hash.dart'; @@ -34,6 +35,7 @@ class AuthNotifier extends StateNotifier { final AuthService _authService; final ApiService _apiService; final UserService _userService; + final SecureStorageService _secureStorageService; final WidgetService _widgetService; final Ref _ref; @@ -45,6 +47,7 @@ class AuthNotifier extends StateNotifier { this._authService, this._apiService, this._userService, + this._secureStorageService, this._widgetService, this._ref, @@ -87,7 +90,8 @@ class AuthNotifier extends StateNotifier { await _widgetService.clearCredentials(); await _authService.logout(); - await _ref.read(uploadServiceProvider).cancelBackup(); + await _ref.read(backgroundUploadServiceProvider).cancel(); + _ref.read(foregroundUploadServiceProvider).cancel(); } finally { await _cleanUp(); } diff --git a/mobile/lib/providers/backup/asset_upload_progress.provider.dart b/mobile/lib/providers/backup/asset_upload_progress.provider.dart new file mode 100644 index 0000000000..e8aba430da --- /dev/null +++ b/mobile/lib/providers/backup/asset_upload_progress.provider.dart @@ -0,0 +1,33 @@ +import 'package:cancellation_token_http/http.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// Tracks per-asset upload progress. +/// Key: local asset ID, Value: upload progress 0.0 to 1.0, or -1.0 for error +class AssetUploadProgressNotifier extends Notifier> { + static const double errorValue = -1.0; + + @override + Map build() => {}; + + void setProgress(String localAssetId, double progress) { + state = {...state, localAssetId: progress}; + } + + void setError(String localAssetId) { + state = {...state, localAssetId: errorValue}; + } + + void remove(String localAssetId) { + state = Map.from(state)..remove(localAssetId); + } + + void clear() { + state = {}; + } +} + +final assetUploadProgressProvider = NotifierProvider>( + AssetUploadProgressNotifier.new, +); + +final manualUploadCancelTokenProvider = StateProvider((ref) => null); diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index ec427613f1..e2d548595c 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -1,19 +1,18 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:async'; -import 'package:background_downloader/background_downloader.dart'; +import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; + import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/extensions/string_extensions.dart'; -import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; +import 'package:immich_mobile/utils/upload_speed_calculator.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/upload.service.dart'; -import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:logging/logging.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; class EnqueueStatus { final int enqueueCount; @@ -106,26 +105,24 @@ class DriftBackupState { final int remainderCount; final int processingCount; - final int enqueueCount; - final int enqueueTotalCount; - final bool isSyncing; - final bool isCanceling; final BackupError error; final Map uploadItems; + final CancellationToken? cancelToken; + + final Map iCloudDownloadProgress; const DriftBackupState({ required this.totalCount, required this.backupCount, required this.remainderCount, required this.processingCount, - required this.enqueueCount, - required this.enqueueTotalCount, - required this.isCanceling, required this.isSyncing, - required this.uploadItems, this.error = BackupError.none, + required this.uploadItems, + this.cancelToken, + this.iCloudDownloadProgress = const {}, }); DriftBackupState copyWith({ @@ -133,30 +130,28 @@ class DriftBackupState { int? backupCount, int? remainderCount, int? processingCount, - int? enqueueCount, - int? enqueueTotalCount, - bool? isCanceling, bool? isSyncing, - Map? uploadItems, BackupError? error, + Map? uploadItems, + CancellationToken? cancelToken, + Map? iCloudDownloadProgress, }) { return DriftBackupState( totalCount: totalCount ?? this.totalCount, backupCount: backupCount ?? this.backupCount, remainderCount: remainderCount ?? this.remainderCount, processingCount: processingCount ?? this.processingCount, - enqueueCount: enqueueCount ?? this.enqueueCount, - enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount, - isCanceling: isCanceling ?? this.isCanceling, isSyncing: isSyncing ?? this.isSyncing, - uploadItems: uploadItems ?? this.uploadItems, error: error ?? this.error, + uploadItems: uploadItems ?? this.uploadItems, + cancelToken: cancelToken ?? this.cancelToken, + iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, ); } @override String toString() { - return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, isSyncing: $isSyncing, uploadItems: $uploadItems, error: $error)'; + return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)'; } @override @@ -168,12 +163,11 @@ class DriftBackupState { other.backupCount == backupCount && other.remainderCount == remainderCount && other.processingCount == processingCount && - other.enqueueCount == enqueueCount && - other.enqueueTotalCount == enqueueTotalCount && - other.isCanceling == isCanceling && other.isSyncing == isSyncing && + other.error == error && + mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) && mapEquals(other.uploadItems, uploadItems) && - other.error == error; + other.cancelToken == cancelToken; } @override @@ -182,44 +176,40 @@ class DriftBackupState { backupCount.hashCode ^ remainderCount.hashCode ^ processingCount.hashCode ^ - enqueueCount.hashCode ^ - enqueueTotalCount.hashCode ^ - isCanceling.hashCode ^ isSyncing.hashCode ^ + error.hashCode ^ uploadItems.hashCode ^ - error.hashCode; + cancelToken.hashCode ^ + iCloudDownloadProgress.hashCode; } } final driftBackupProvider = StateNotifierProvider((ref) { - return DriftBackupNotifier(ref.watch(uploadServiceProvider)); + return DriftBackupNotifier( + ref.watch(foregroundUploadServiceProvider), + ref.watch(backgroundUploadServiceProvider), + UploadSpeedManager(), + ); }); class DriftBackupNotifier extends StateNotifier { - DriftBackupNotifier(this._uploadService) + DriftBackupNotifier(this._foregroundUploadService, this._backgroundUploadService, this._uploadSpeedManager) : super( const DriftBackupState( totalCount: 0, backupCount: 0, remainderCount: 0, processingCount: 0, - enqueueCount: 0, - enqueueTotalCount: 0, - isCanceling: false, isSyncing: false, uploadItems: {}, error: BackupError.none, ), - ) { - { - _statusSubscription = _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate); - _progressSubscription = _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate); - } - } + ); + + final ForegroundUploadService _foregroundUploadService; + final BackgroundUploadService _backgroundUploadService; + final UploadSpeedManager _uploadSpeedManager; - final UploadService _uploadService; - StreamSubscription? _statusSubscription; - StreamSubscription? _progressSubscription; final _logger = Logger("DriftBackupNotifier"); /// Remove upload item from state @@ -235,120 +225,12 @@ class DriftBackupNotifier extends StateNotifier { } } - void _handleTaskStatusUpdate(TaskStatusUpdate update) { - if (!mounted) { - _logger.warning("Skip _handleTaskStatusUpdate: notifier disposed"); - return; - } - final taskId = update.task.taskId; - - switch (update.status) { - case TaskStatus.complete: - if (update.task.group == kBackupGroup) { - if (update.responseStatusCode == 201) { - state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1); - } - } - - // Remove the completed task from the upload items - if (state.uploadItems.containsKey(taskId)) { - Future.delayed(const Duration(milliseconds: 1000), () { - _removeUploadItem(taskId); - }); - } - - case TaskStatus.failed: - // Ignore retry errors to avoid confusing users - if (update.exception?.description == 'Delayed or retried enqueue failed') { - _removeUploadItem(taskId); - return; - } - - final currentItem = state.uploadItems[taskId]; - if (currentItem == null) { - return; - } - - String? error; - final exception = update.exception; - if (exception != null && exception is TaskHttpException) { - final message = tryJsonDecode(exception.description)?['message'] as String?; - if (message != null) { - final responseCode = exception.httpResponseCode; - error = "${exception.exceptionType}, response code $responseCode: $message"; - } - } - error ??= update.exception?.toString(); - - state = state.copyWith( - uploadItems: { - ...state.uploadItems, - taskId: currentItem.copyWith(isFailed: true, error: error), - }, - ); - _logger.fine("Upload failed for taskId: $taskId, exception: ${update.exception}"); - break; - - case TaskStatus.canceled: - _removeUploadItem(update.task.taskId); - break; - - default: - break; - } - } - - void _handleTaskProgressUpdate(TaskProgressUpdate update) { - if (!mounted) { - _logger.warning("Skip _handleTaskProgressUpdate: notifier disposed"); - return; - } - final taskId = update.task.taskId; - final filename = update.task.displayName; - final progress = update.progress; - final currentItem = state.uploadItems[taskId]; - if (currentItem != null) { - if (progress == kUploadStatusCanceled) { - _removeUploadItem(update.task.taskId); - return; - } - - state = state.copyWith( - uploadItems: { - ...state.uploadItems, - taskId: update.hasExpectedFileSize - ? currentItem.copyWith( - progress: progress, - fileSize: update.expectedFileSize, - networkSpeedAsString: update.networkSpeedAsString, - ) - : currentItem.copyWith(progress: progress), - }, - ); - - return; - } - - state = state.copyWith( - uploadItems: { - ...state.uploadItems, - taskId: DriftUploadStatus( - taskId: taskId, - filename: filename, - progress: progress, - fileSize: update.expectedFileSize, - networkSpeedAsString: update.networkSpeedAsString, - ), - }, - ); - } - Future getBackupStatus(String userId) async { if (!mounted) { _logger.warning("Skip getBackupStatus (pre-call): notifier disposed"); return; } - final counts = await _uploadService.getBackupCounts(userId); + final counts = await _foregroundUploadService.getBackupCounts(userId); if (!mounted) { _logger.warning("Skip getBackupStatus (post-call): notifier disposed"); return; @@ -374,47 +256,126 @@ class DriftBackupNotifier extends StateNotifier { state = state.copyWith(isSyncing: isSyncing); } - Future startBackup(String userId) { + Future startForegroundBackup(String userId) async { state = state.copyWith(error: BackupError.none); - return _uploadService.startBackup(userId, _updateEnqueueCount); + + final cancelToken = CancellationToken(); + state = state.copyWith(cancelToken: cancelToken); + + return _foregroundUploadService.uploadCandidates( + userId, + cancelToken, + callbacks: UploadCallbacks( + onProgress: _handleForegroundBackupProgress, + onSuccess: _handleForegroundBackupSuccess, + onError: _handleForegroundBackupError, + onICloudProgress: _handleICloudProgress, + ), + ); } - void _updateEnqueueCount(EnqueueStatus status) { - state = state.copyWith(enqueueCount: status.enqueueCount, enqueueTotalCount: status.totalCount); + Future stopForegroundBackup() async { + state.cancelToken?.cancel(); + _uploadSpeedManager.clear(); + state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {}); } - Future cancel() async { - if (!mounted) { - _logger.warning("Skip cancel (pre-call): notifier disposed"); - return; + void _handleICloudProgress(String localAssetId, double progress) { + state = state.copyWith(iCloudDownloadProgress: {...state.iCloudDownloadProgress, localAssetId: progress}); + + if (progress >= 1.0) { + Future.delayed(const Duration(milliseconds: 250), () { + final updatedProgress = Map.from(state.iCloudDownloadProgress); + updatedProgress.remove(localAssetId); + state = state.copyWith(iCloudDownloadProgress: updatedProgress); + }); } - dPrint(() => "Canceling backup tasks..."); - state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none); + } - final activeTaskCount = await _uploadService.cancelBackup(); - if (!mounted) { - _logger.warning("Skip cancel (post-call): notifier disposed"); + void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) { + if (state.cancelToken == null) { return; } - if (activeTaskCount > 0) { - dPrint(() => "$activeTaskCount tasks left, continuing to cancel..."); - await cancel(); + final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; + final networkSpeedAsString = _uploadSpeedManager.updateProgress(localAssetId, bytes, totalBytes); + final currentItem = state.uploadItems[localAssetId]; + if (currentItem != null) { + state = state.copyWith( + uploadItems: { + ...state.uploadItems, + localAssetId: currentItem.copyWith( + filename: filename, + progress: progress, + fileSize: totalBytes, + networkSpeedAsString: networkSpeedAsString, + ), + }, + ); } else { - dPrint(() => "All tasks canceled successfully."); - // Clear all upload items when cancellation is complete - state = state.copyWith(isCanceling: false, uploadItems: {}); + state = state.copyWith( + uploadItems: { + ...state.uploadItems, + localAssetId: DriftUploadStatus( + taskId: localAssetId, + filename: filename, + progress: progress, + fileSize: totalBytes, + networkSpeedAsString: networkSpeedAsString, + ), + }, + ); } } - Future handleBackupResume(String userId) async { + void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) { + state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1); + _uploadSpeedManager.removeTask(localAssetId); + + Future.delayed(const Duration(milliseconds: 1000), () { + _removeUploadItem(localAssetId); + }); + } + + void _handleForegroundBackupError(String localAssetId, String errorMessage) { + _logger.severe("Upload failed for $localAssetId: $errorMessage"); + + final currentItem = state.uploadItems[localAssetId]; + if (currentItem != null) { + state = state.copyWith( + uploadItems: { + ...state.uploadItems, + localAssetId: currentItem.copyWith(isFailed: true, error: errorMessage), + }, + ); + } else { + state = state.copyWith( + uploadItems: { + ...state.uploadItems, + localAssetId: DriftUploadStatus( + taskId: localAssetId, + filename: 'Unknown', + progress: 0, + fileSize: 0, + networkSpeedAsString: '', + isFailed: true, + error: errorMessage, + ), + }, + ); + } + + _uploadSpeedManager.removeTask(localAssetId); + } + + Future startBackupWithURLSession(String userId) async { if (!mounted) { _logger.warning("Skip handleBackupResume (pre-call): notifier disposed"); return; } _logger.info("Resuming backup tasks..."); state = state.copyWith(error: BackupError.none); - final tasks = await _uploadService.getActiveTasks(kBackupGroup); + final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup); if (!mounted) { _logger.warning("Skip handleBackupResume (post-call): notifier disposed"); return; @@ -422,20 +383,12 @@ class DriftBackupNotifier extends StateNotifier { _logger.info("Found ${tasks.length} tasks"); if (tasks.isEmpty) { - // Start a new backup queue - _logger.info("Start a new backup queue"); - return startBackup(userId); + _logger.info("Start backup with URLSession"); + return _backgroundUploadService.uploadBackupCandidates(userId); } _logger.info("Tasks to resume: ${tasks.length}"); - return _uploadService.resumeBackup(); - } - - @override - void dispose() { - _statusSubscription?.cancel(); - _progressSubscription?.cancel(); - super.dispose(); + return _backgroundUploadService.resume(); } } @@ -445,7 +398,7 @@ final driftBackupCandidateProvider = FutureProvider.autoDispose return []; } - return ref.read(backupRepositoryProvider).getCandidates(user.id, onlyHashed: false); + return ref.read(foregroundUploadServiceProvider).getBackupCandidates(user.id, onlyHashed: false); }); final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family, String>(( diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index d4d850d8c1..48ce88799a 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:background_downloader/background_downloader.dart'; +import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -13,10 +14,11 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/services/action.service.dart'; import 'package:immich_mobile/services/download.service.dart'; import 'package:immich_mobile/services/timeline.service.dart'; -import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -40,7 +42,7 @@ class ActionResult { class ActionNotifier extends Notifier { final Logger _logger = Logger('ActionNotifier'); late ActionService _service; - late UploadService _uploadService; + late ForegroundUploadService _foregroundUploadService; late DownloadService _downloadService; late AssetService _assetService; @@ -48,7 +50,7 @@ class ActionNotifier extends Notifier { @override void build() { - _uploadService = ref.watch(uploadServiceProvider); + _foregroundUploadService = ref.watch(foregroundUploadServiceProvider); _service = ref.watch(actionServiceProvider); _assetService = ref.watch(assetServiceProvider); _downloadService = ref.watch(downloadServiceProvider); @@ -411,14 +413,44 @@ class ActionNotifier extends Notifier { } } - Future upload(ActionSource source) async { - final assets = _getAssets(source).whereType().toList(); + Future upload(ActionSource source, {List? assets}) async { + final assetsToUpload = assets ?? _getAssets(source).whereType().toList(); + + final progressNotifier = ref.read(assetUploadProgressProvider.notifier); + final cancelToken = CancellationToken(); + ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; + + // Initialize progress for all assets + for (final asset in assetsToUpload) { + progressNotifier.setProgress(asset.id, 0.0); + } + try { - await _uploadService.manualBackup(assets); - return ActionResult(count: assets.length, success: true); + await _foregroundUploadService.uploadManual( + assetsToUpload, + cancelToken, + callbacks: UploadCallbacks( + onProgress: (localAssetId, filename, bytes, totalBytes) { + final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; + progressNotifier.setProgress(localAssetId, progress); + }, + onSuccess: (localAssetId, remoteAssetId) { + progressNotifier.remove(localAssetId); + }, + onError: (localAssetId, errorMessage) { + progressNotifier.setError(localAssetId); + }, + ), + ); + return ActionResult(count: assetsToUpload.length, success: true); } catch (error, stack) { _logger.severe('Failed manually upload assets', error, stack); - return ActionResult(count: assets.length, success: false, error: error.toString()); + return ActionResult(count: assetsToUpload.length, success: false, error: error.toString()); + } finally { + ref.read(manualUploadCancelTokenProvider.notifier).state = null; + Future.delayed(const Duration(seconds: 2), () { + progressNotifier.clear(); + }); } } } diff --git a/mobile/lib/providers/infrastructure/storage.provider.dart b/mobile/lib/providers/infrastructure/storage.provider.dart index ccca964027..82d1209c97 100644 --- a/mobile/lib/providers/infrastructure/storage.provider.dart +++ b/mobile/lib/providers/infrastructure/storage.provider.dart @@ -1,4 +1,4 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; -final storageRepositoryProvider = Provider((ref) => const StorageRepository()); +final storageRepositoryProvider = Provider((ref) => StorageRepository()); diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index 38f2c22cf2..aff84683c3 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -20,6 +21,7 @@ class UploadTaskWithFile { final uploadRepositoryProvider = Provider((ref) => UploadRepository()); class UploadRepository { + final Logger logger = Logger('UploadRepository'); void Function(TaskStatusUpdate)? onUploadStatus; void Function(TaskProgressUpdate)? onTaskProgress; @@ -92,52 +94,114 @@ class UploadRepository { ); } - Future backupWithDartClient(Iterable tasks, CancellationToken cancelToken) async { - final httpClient = Client(); + Future uploadFile({ + required File file, + required String originalFileName, + required Map headers, + required Map fields, + required Client httpClient, + required CancellationToken cancelToken, + required void Function(int bytes, int totalBytes) onProgress, + required String logContext, + }) async { final String savedEndpoint = Store.get(StoreKey.serverEndpoint); - Logger logger = Logger('UploadRepository'); - for (final candidate in tasks) { - if (cancelToken.isCancelled) { - logger.warning("Backup was cancelled by the user"); - break; + try { + final fileStream = file.openRead(); + final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName); + + final baseRequest = _CustomMultipartRequest('POST', Uri.parse('$savedEndpoint/assets'), onProgress: onProgress); + + baseRequest.headers.addAll(headers); + baseRequest.fields.addAll(fields); + baseRequest.files.add(assetRawUploadData); + + final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); + final responseBodyString = await response.stream.bytesToString(); + + if (![200, 201].contains(response.statusCode)) { + String? errorMessage; + + if (response.statusCode == 413) { + errorMessage = 'Error(413) File is too large to upload'; + return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage); + } + + try { + final error = jsonDecode(responseBodyString); + errorMessage = error['message'] ?? error['error']; + } catch (_) { + errorMessage = responseBodyString.isNotEmpty + ? responseBodyString + : 'Upload failed with status ${response.statusCode}'; + } + + return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage); } try { - final fileStream = candidate.file.openRead(); - final assetRawUploadData = MultipartFile( - "assetData", - fileStream, - candidate.file.lengthSync(), - filename: candidate.task.filename, - ); - - final baseRequest = MultipartRequest('POST', Uri.parse('$savedEndpoint/assets')); - - baseRequest.headers.addAll(candidate.task.headers); - baseRequest.fields.addAll(candidate.task.fields); - baseRequest.files.add(assetRawUploadData); - - final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); - - final responseBody = jsonDecode(await response.stream.bytesToString()); - - if (![200, 201].contains(response.statusCode)) { - final error = responseBody; - - logger.warning( - "Error(${error['statusCode']}) uploading ${candidate.task.filename} | Created on ${candidate.task.fields["fileCreatedAt"]} | ${error['error']}", - ); - - continue; - } - } on CancelledException { - logger.warning("Backup was cancelled by the user"); - break; - } catch (error, stackTrace) { - logger.warning("Error backup asset: ${error.toString()}: $stackTrace"); - continue; + final responseBody = jsonDecode(responseBodyString); + return UploadResult.success(remoteAssetId: responseBody['id'] as String); + } catch (e) { + return UploadResult.error(errorMessage: 'Failed to parse server response'); } + } on CancelledException { + logger.warning("Upload $logContext was cancelled"); + return UploadResult.cancelled(); + } catch (error, stackTrace) { + logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace"); + return UploadResult.error(errorMessage: error.toString()); } } } + +class UploadResult { + final bool isSuccess; + final bool isCancelled; + final String? remoteAssetId; + final String? errorMessage; + final int? statusCode; + + const UploadResult({ + required this.isSuccess, + required this.isCancelled, + this.remoteAssetId, + this.errorMessage, + this.statusCode, + }); + + factory UploadResult.success({required String remoteAssetId}) { + return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId); + } + + factory UploadResult.error({String? errorMessage, int? statusCode}) { + return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode); + } + + factory UploadResult.cancelled() { + return const UploadResult(isSuccess: false, isCancelled: true); + } +} + +class _CustomMultipartRequest extends MultipartRequest { + _CustomMultipartRequest(super.method, super.url, {required this.onProgress}); + + final void Function(int bytes, int totalBytes) onProgress; + + @override + ByteStream finalize() { + final byteStream = super.finalize(); + final total = contentLength; + var bytes = 0; + + final t = StreamTransformer.fromHandlers( + handleData: (List data, EventSink> sink) { + bytes += data.length; + onProgress.call(bytes, total); + sink.add(data); + }, + ); + final stream = byteStream.transform(t); + return ByteStream(stream); + } +} diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/background_upload.service.dart similarity index 75% rename from mobile/lib/services/upload.service.dart rename to mobile/lib/services/background_upload.service.dart index f4ee73ad41..fe1e4ac13b 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; @@ -15,12 +14,9 @@ import 'package:immich_mobile/extensions/platform_extensions.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/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; -import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -29,43 +25,98 @@ import 'package:immich_mobile/utils/debug_print.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; -final uploadServiceProvider = Provider((ref) { - final service = UploadService( +final backgroundUploadServiceProvider = Provider((ref) { + final service = BackgroundUploadService( ref.watch(uploadRepositoryProvider), - ref.watch(backupRepositoryProvider), ref.watch(storageRepositoryProvider), ref.watch(localAssetRepository), + ref.watch(backupRepositoryProvider), ref.watch(appSettingsServiceProvider), ref.watch(assetMediaRepositoryProvider), - ref.watch(serverInfoProvider), ); ref.onDispose(service.dispose); return service; }); -class UploadService { - UploadService( +/// Metadata for upload tasks to track live photo handling +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; +} + +/// Service for handling background uploads using iOS URLSession (background_downloader) +/// +/// This service handles asynchronous background uploads that can continue +/// even when the app is suspended. Primarily used for iOS background backup. +class BackgroundUploadService { + BackgroundUploadService( this._uploadRepository, - this._backupRepository, this._storageRepository, this._localAssetRepository, + this._backupRepository, this._appSettingsService, this._assetMediaRepository, - this._serverInfo, ) { _uploadRepository.onUploadStatus = _onUploadCallback; _uploadRepository.onTaskProgress = _onTaskProgressCallback; } final UploadRepository _uploadRepository; - final DriftBackupRepository _backupRepository; final StorageRepository _storageRepository; final DriftLocalAssetRepository _localAssetRepository; + final DriftBackupRepository _backupRepository; final AppSettingsService _appSettingsService; final AssetMediaRepository _assetMediaRepository; - final ServerInfo _serverInfo; - final Logger _logger = Logger('UploadService'); + final Logger _logger = Logger('BackgroundUploadService'); final StreamController _taskStatusController = StreamController.broadcast(); final StreamController _taskProgressController = StreamController.broadcast(); @@ -93,116 +144,49 @@ class UploadService { _taskProgressController.close(); } + /// Enqueue tasks to the background upload queue Future> enqueueTasks(List tasks) { return _uploadRepository.enqueueBackgroundAll(tasks); } + /// Get a list of tasks that are ENQUEUED or RUNNING Future> getActiveTasks(String group) { return _uploadRepository.getActiveTasks(group); } - Future<({int total, int remainder, int processing})> getBackupCounts(String userId) { - return _backupRepository.getAllCounts(userId); - } - - Future manualBackup(List localAssets) async { + /// Start background upload using iOS URLSession + /// + /// Finds backup candidates, builds upload tasks, and enqueues them + /// for background processing. + Future uploadBackupCandidates(String userId) async { await _storageRepository.clearCache(); + shouldAbortQueuingTasks = false; + + final candidates = await _backupRepository.getCandidates(userId); + if (candidates.isEmpty) { + return; + } + + const batchSize = 100; + final batch = candidates.take(batchSize).toList(); List tasks = []; - for (final asset in localAssets) { - final task = await getUploadTask( - asset, - group: kManualUploadGroup, - priority: 1, // High priority after upload motion photo part - ); + + for (final asset in batch) { + final task = await getUploadTask(asset); if (task != null) { tasks.add(task); } } - if (tasks.isNotEmpty) { + if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { await enqueueTasks(tasks); } } - /// Find backup candidates - /// Build the upload tasks - /// Enqueue the tasks - Future startBackup(String userId, void Function(EnqueueStatus status) onEnqueueTasks) async { - await _storageRepository.clearCache(); - - shouldAbortQueuingTasks = false; - - final candidates = await _backupRepository.getCandidates(userId); - if (candidates.isEmpty) { - return; - } - - const batchSize = 100; - int count = 0; - for (int i = 0; i < candidates.length; i += batchSize) { - if (shouldAbortQueuingTasks) { - 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 && !shouldAbortQueuingTasks) { - count += tasks.length; - await enqueueTasks(tasks); - - onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length)); - } - } - } - - Future startBackupWithHttpClient(String userId, bool hasWifi, CancellationToken token) async { - await _storageRepository.clearCache(); - - shouldAbortQueuingTasks = false; - - final candidates = await _backupRepository.getCandidates(userId); - if (candidates.isEmpty) { - return; - } - - const batchSize = 100; - for (int i = 0; i < candidates.length; i += batchSize) { - if (shouldAbortQueuingTasks || token.isCancelled) { - break; - } - - final batch = candidates.skip(i).take(batchSize).toList(); - List tasks = []; - for (final asset in batch) { - final requireWifi = _shouldRequireWiFi(asset); - if (requireWifi && !hasWifi) { - _logger.warning('Skipping upload for ${asset.id} because it requires WiFi'); - continue; - } - - final task = await _getUploadTaskWithFile(asset); - if (task != null) { - tasks.add(task); - } - } - - if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { - await _uploadRepository.backupWithDartClient(tasks, token); - } - } - } - - /// Cancel all ongoing uploads and reset the upload queue + /// Cancel all ongoing background uploads and reset the upload queue /// - /// Return the number of left over tasks in the queue - Future cancelBackup() async { + /// Returns the number of tasks left in the queue + Future cancel() async { shouldAbortQueuingTasks = true; await _storageRepository.clearCache(); @@ -213,7 +197,8 @@ class UploadService { return activeTasks.length; } - Future resumeBackup() { + /// Resume background backup processing + Future resume() { return _uploadRepository.start(); } @@ -271,42 +256,6 @@ class UploadService { } } - Future _getUploadTaskWithFile(LocalAsset asset) 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 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 UploadTaskWithFile( - file: file, - task: await buildUploadTask( - file, - createdAt: asset.createdAt, - modifiedAt: asset.updatedAt, - originalFileName: originalFileName, - deviceAssetId: asset.id, - metadata: metadata, - group: "group", - priority: 0, - isFavorite: asset.isFavorite, - requiresWiFi: false, - ), - ); - } - @visibleForTesting Future getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async { final entity = await _storageRepository.getAssetEntityForAsset(asset); @@ -443,8 +392,7 @@ class UploadService { 'isFavorite': isFavorite?.toString() ?? 'false', 'duration': '0', if (fields != null) ...fields, - // Include cloudId and eTag in metadata if available and server version supports it - if (CurrentPlatform.isIOS && cloudId != null && _serverInfo.serverVersion.isAtLeast(major: 2, minor: 4)) + if (CurrentPlatform.isIOS && cloudId != null) 'metadata': jsonEncode([ RemoteAssetMetadataItem( key: RemoteAssetMetadataKey.mobileApp, @@ -479,56 +427,3 @@ class UploadService { ); } } - -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/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart new file mode 100644 index 0000000000..b979096e1c --- /dev/null +++ b/mobile/lib/services/foreground_upload.service.dart @@ -0,0 +1,461 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:cancellation_token_http/http.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/extensions/network_capability_extensions.dart'; +import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/platform/connectivity_api.g.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; +import 'package:immich_mobile/repositories/upload.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; +import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; + +/// Callbacks for upload progress and status updates +class UploadCallbacks { + final void Function(String id, String filename, int bytes, int totalBytes)? onProgress; + final void Function(String localId, String remoteId)? onSuccess; + final void Function(String id, String errorMessage)? onError; + final void Function(String id, double progress)? onICloudProgress; + + const UploadCallbacks({this.onProgress, this.onSuccess, this.onError, this.onICloudProgress}); +} + +final foregroundUploadServiceProvider = Provider((ref) { + return ForegroundUploadService( + ref.watch(uploadRepositoryProvider), + ref.watch(storageRepositoryProvider), + ref.watch(backupRepositoryProvider), + ref.watch(connectivityApiProvider), + ref.watch(appSettingsServiceProvider), + ); +}); + +/// Service for handling foreground HTTP uploads +/// +/// This service handles synchronous uploads using HTTP client with +/// concurrent worker pools. Used for manual backups, auto backups +/// (foreground mode), and share intent uploads. +class ForegroundUploadService { + ForegroundUploadService( + this._uploadRepository, + this._storageRepository, + this._backupRepository, + this._connectivityApi, + this._appSettingsService, + ); + + final UploadRepository _uploadRepository; + final StorageRepository _storageRepository; + final DriftBackupRepository _backupRepository; + final ConnectivityApi _connectivityApi; + final AppSettingsService _appSettingsService; + final Logger _logger = Logger('ForegroundUploadService'); + + bool shouldAbortUpload = false; + + Future<({int total, int remainder, int processing})> getBackupCounts(String userId) { + return _backupRepository.getAllCounts(userId); + } + + Future> getBackupCandidates(String userId, {bool onlyHashed = true}) { + return _backupRepository.getCandidates(userId, onlyHashed: onlyHashed); + } + + /// Bulk upload of backup candidates from selected albums + Future uploadCandidates( + String userId, + CancellationToken cancelToken, { + UploadCallbacks callbacks = const UploadCallbacks(), + bool useSequentialUpload = false, + }) async { + final candidates = await _backupRepository.getCandidates(userId); + if (candidates.isEmpty) { + return; + } + + final networkCapabilities = await _connectivityApi.getCapabilities(); + final hasWifi = networkCapabilities.isUnmetered; + _logger.info('Network capabilities: $networkCapabilities, hasWifi/isUnmetered: $hasWifi'); + + if (useSequentialUpload) { + await _uploadSequentially(items: candidates, cancelToken: cancelToken, hasWifi: hasWifi, callbacks: callbacks); + } else { + await _executeWithWorkerPool( + items: candidates, + cancelToken: cancelToken, + shouldSkip: (asset) { + final requireWifi = _shouldRequireWiFi(asset); + return requireWifi && !hasWifi; + }, + processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), + ); + } + } + + /// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues + Future _uploadSequentially({ + required List items, + required CancellationToken cancelToken, + required bool hasWifi, + required UploadCallbacks callbacks, + }) async { + final httpClient = Client(); + await _storageRepository.clearCache(); + shouldAbortUpload = false; + + try { + for (final asset in items) { + if (shouldAbortUpload || cancelToken.isCancelled) { + break; + } + + final requireWifi = _shouldRequireWiFi(asset); + if (requireWifi && !hasWifi) { + _logger.warning('Skipping upload for ${asset.id} because it requires WiFi'); + continue; + } + + await _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks); + } + } finally { + httpClient.close(); + } + } + + /// Manually upload picked local assets + Future uploadManual( + List localAssets, + CancellationToken cancelToken, { + UploadCallbacks callbacks = const UploadCallbacks(), + }) async { + if (localAssets.isEmpty) { + return; + } + + await _executeWithWorkerPool( + items: localAssets, + cancelToken: cancelToken, + processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), + ); + } + + /// Upload files from shared intent + Future uploadShareIntent( + List files, { + CancellationToken? cancelToken, + void Function(String fileId, int bytes, int totalBytes)? onProgress, + void Function(String fileId)? onSuccess, + void Function(String fileId, String errorMessage)? onError, + }) async { + if (files.isEmpty) { + return; + } + + final effectiveCancelToken = cancelToken ?? CancellationToken(); + + await _executeWithWorkerPool( + items: files, + cancelToken: effectiveCancelToken, + processItem: (file, httpClient) async { + final fileId = p.hash(file.path).toString(); + + final result = await _uploadSingleFile( + file, + deviceAssetId: fileId, + httpClient: httpClient, + cancelToken: effectiveCancelToken, + onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes), + ); + + if (result.isSuccess) { + onSuccess?.call(fileId); + } else if (!result.isCancelled && result.errorMessage != null) { + onError?.call(fileId, result.errorMessage!); + } + }, + ); + } + + void cancel() { + shouldAbortUpload = true; + } + + /// Generic worker pool for concurrent uploads + /// + /// [items] - List of items to process + /// [cancelToken] - Token to cancel the operation + /// [processItem] - Function to process each item with an HTTP client + /// [shouldSkip] - Optional function to skip items (e.g., WiFi requirement check) + /// [concurrentWorkers] - Number of concurrent workers (default: 3) + Future _executeWithWorkerPool({ + required List items, + required CancellationToken cancelToken, + required Future Function(T item, Client httpClient) processItem, + bool Function(T item)? shouldSkip, + int concurrentWorkers = 3, + }) async { + final httpClients = List.generate(concurrentWorkers, (_) => Client()); + + await _storageRepository.clearCache(); + shouldAbortUpload = false; + + try { + int currentIndex = 0; + + Future worker(Client httpClient) async { + while (true) { + if (shouldAbortUpload || cancelToken.isCancelled) { + break; + } + + final index = currentIndex; + if (index >= items.length) { + break; + } + currentIndex++; + + final item = items[index]; + + if (shouldSkip?.call(item) ?? false) { + continue; + } + + await processItem(item, httpClient); + } + } + + final workerFutures = >[]; + for (int i = 0; i < concurrentWorkers; i++) { + workerFutures.add(worker(httpClients[i])); + } + + await Future.wait(workerFutures); + } finally { + for (final client in httpClients) { + client.close(); + } + } + } + + Future _uploadSingleAsset( + LocalAsset asset, + Client httpClient, + CancellationToken cancelToken, { + required UploadCallbacks callbacks, + }) async { + File? file; + File? livePhotoFile; + + try { + final entity = await _storageRepository.getAssetEntityForAsset(asset); + if (entity == null) { + return; + } + + final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id); + + if (!isAvailableLocally && CurrentPlatform.isIOS) { + _logger.info("Loading iCloud asset ${asset.id} - ${asset.name}"); + + // Create progress handler for iCloud download + PMProgressHandler? progressHandler; + StreamSubscription? progressSubscription; + + progressHandler = PMProgressHandler(); + progressSubscription = progressHandler.stream.listen((event) { + callbacks.onICloudProgress?.call(asset.localId!, event.progress); + }); + + try { + file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler); + if (entity.isLivePhoto) { + livePhotoFile = await _storageRepository.loadMotionFileFromCloud( + asset.id, + progressHandler: progressHandler, + ); + } + } finally { + await progressSubscription.cancel(); + } + } else { + // Get files locally + file = await _storageRepository.getFileForAsset(asset.id); + if (file == null) { + return; + } + + // For live photos, get the motion video file + if (entity.isLivePhoto) { + livePhotoFile = await _storageRepository.getMotionFileForAsset(asset); + if (livePhotoFile == null) { + _logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}"); + } + } + } + + if (file == null) { + _logger.warning("Failed to obtain file for asset ${asset.id} - ${asset.name}"); + return; + } + + final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name; + final deviceId = Store.get(StoreKey.deviceId); + + final headers = ApiService.getRequestHeaders(); + final fields = { + 'deviceAssetId': asset.localId!, + 'deviceId': deviceId, + 'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(), + 'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(), + 'isFavorite': asset.isFavorite.toString(), + 'duration': asset.duration.toString(), + if (CurrentPlatform.isIOS && asset.cloudId != null) + 'metadata': jsonEncode([ + RemoteAssetMetadataItem( + key: RemoteAssetMetadataKey.mobileApp, + value: RemoteAssetMobileAppMetadata( + cloudId: asset.cloudId, + createdAt: asset.createdAt.toIso8601String(), + adjustmentTime: asset.adjustmentTime?.toIso8601String(), + latitude: asset.latitude?.toString(), + longitude: asset.longitude?.toString(), + ), + ), + ]), + }; + + // Upload live photo video first if available + String? livePhotoVideoId; + if (entity.isLivePhoto && livePhotoFile != null) { + final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path)); + + final livePhotoResult = await _uploadRepository.uploadFile( + file: livePhotoFile, + originalFileName: livePhotoTitle, + headers: headers, + fields: fields, + httpClient: httpClient, + cancelToken: cancelToken, + onProgress: (bytes, totalBytes) => + callbacks.onProgress?.call(asset.localId!, livePhotoTitle, bytes, totalBytes), + logContext: 'livePhotoVideo[${asset.localId}]', + ); + + if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) { + livePhotoVideoId = livePhotoResult.remoteAssetId; + } + } + + if (livePhotoVideoId != null) { + fields['livePhotoVideoId'] = livePhotoVideoId; + } + + final result = await _uploadRepository.uploadFile( + file: file, + originalFileName: originalFileName, + headers: headers, + fields: fields, + httpClient: httpClient, + cancelToken: cancelToken, + onProgress: (bytes, totalBytes) => + callbacks.onProgress?.call(asset.localId!, originalFileName, bytes, totalBytes), + logContext: 'asset[${asset.localId}]', + ); + + if (result.isSuccess && result.remoteAssetId != null) { + callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!); + } else if (result.isCancelled) { + _logger.warning(() => "Backup was cancelled by the user"); + shouldAbortUpload = true; + } else if (result.errorMessage != null) { + _logger.severe( + () => + "Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}", + ); + + callbacks.onError?.call(asset.localId!, result.errorMessage!); + + if (result.errorMessage == "Quota has been exceeded!") { + shouldAbortUpload = true; + } + } + } catch (error, stackTrace) { + _logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace); + callbacks.onError?.call(asset.localId!, error.toString()); + } finally { + if (Platform.isIOS) { + try { + await file?.delete(); + await livePhotoFile?.delete(); + } catch (error, stackTrace) { + _logger.severe(() => "ERROR deleting file: ${error.toString()}", stackTrace); + } + } + } + } + + Future _uploadSingleFile( + File file, { + required String deviceAssetId, + required Client httpClient, + required CancellationToken cancelToken, + void Function(int bytes, int totalBytes)? onProgress, + }) async { + try { + final stats = await file.stat(); + final fileCreatedAt = stats.changed; + final fileModifiedAt = stats.modified; + final filename = p.basename(file.path); + + final headers = ApiService.getRequestHeaders(); + final deviceId = Store.get(StoreKey.deviceId); + + final fields = { + 'deviceAssetId': deviceAssetId, + 'deviceId': deviceId, + 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), + 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), + 'isFavorite': 'false', + 'duration': '0', + }; + + return await _uploadRepository.uploadFile( + file: file, + originalFileName: filename, + headers: headers, + fields: fields, + httpClient: httpClient, + cancelToken: cancelToken, + onProgress: onProgress ?? (_, __) {}, + logContext: 'shareIntent[$deviceAssetId]', + ); + } catch (e) { + return UploadResult.error(errorMessage: e.toString()); + } + } + + bool _shouldRequireWiFi(LocalAsset asset) { + bool requiresWiFi = true; + + if (asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)) { + requiresWiFi = false; + } else if (!asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)) { + requiresWiFi = false; + } + + return requiresWiFi; + } +} diff --git a/mobile/lib/utils/upload_speed_calculator.dart b/mobile/lib/utils/upload_speed_calculator.dart new file mode 100644 index 0000000000..a2153e6e3d --- /dev/null +++ b/mobile/lib/utils/upload_speed_calculator.dart @@ -0,0 +1,182 @@ +/// A class to calculate upload speed based on progress updates. +/// +/// Tracks bytes transferred over time and calculates average speed +/// using a sliding window approach to smooth out fluctuations. +class UploadSpeedCalculator { + /// Creates an UploadSpeedCalculator with the given window size. + /// + /// [windowSize] determines how many recent samples to use for + /// calculating the average speed. Default is 5 samples. + UploadSpeedCalculator({this.windowSize = 5}); + + /// The number of samples to keep in the sliding window. + final int windowSize; + + /// List of recent speed samples (bytes per second). + final List _speedSamples = []; + + /// The timestamp of the last progress update. + DateTime? _lastUpdateTime; + + /// The bytes transferred at the last progress update. + int _lastBytes = 0; + + /// The total file size being uploaded. + int _totalBytes = 0; + + /// Resets the calculator for a new upload. + void reset() { + _speedSamples.clear(); + _lastUpdateTime = null; + _lastBytes = 0; + _totalBytes = 0; + } + + /// Updates the calculator with the current progress. + /// + /// [currentBytes] is the number of bytes transferred so far. + /// [totalBytes] is the total size of the file being uploaded. + /// + /// Returns the calculated speed in MB/s, or -1 if not enough data. + double update(int currentBytes, int totalBytes) { + final now = DateTime.now(); + _totalBytes = totalBytes; + + if (_lastUpdateTime == null) { + _lastUpdateTime = now; + _lastBytes = currentBytes; + return -1; + } + + final elapsed = now.difference(_lastUpdateTime!); + + // Only calculate if at least 100ms has passed to avoid division by very small numbers + if (elapsed.inMilliseconds < 100) { + return _currentSpeed; + } + + final bytesTransferred = currentBytes - _lastBytes; + final elapsedSeconds = elapsed.inMilliseconds / 1000.0; + + // Calculate bytes per second, then convert to MB/s + final bytesPerSecond = bytesTransferred / elapsedSeconds; + final mbPerSecond = bytesPerSecond / (1024 * 1024); + + // Add to sliding window + _speedSamples.add(mbPerSecond); + if (_speedSamples.length > windowSize) { + _speedSamples.removeAt(0); + } + + _lastUpdateTime = now; + _lastBytes = currentBytes; + + return _currentSpeed; + } + + /// Returns the current calculated speed in MB/s. + /// + /// Returns -1 if no valid speed has been calculated yet. + double get _currentSpeed { + if (_speedSamples.isEmpty) { + return -1; + } + // Calculate average of all samples in the window + final sum = _speedSamples.fold(0.0, (prev, speed) => prev + speed); + return sum / _speedSamples.length; + } + + /// Returns the current speed in MB/s, or -1 if not available. + double get speed => _currentSpeed; + + /// Returns a human-readable string representation of the current speed. + /// + /// Returns '-- MB/s' if N/A, otherwise in MB/s or kB/s format. + String get speedAsString { + final s = _currentSpeed; + return switch (s) { + <= 0 => '-- MB/s', + >= 1 => '${s.round()} MB/s', + _ => '${(s * 1000).round()} kB/s', + }; + } + + /// Returns the estimated time remaining as a Duration. + /// + /// Returns Duration with negative seconds if not calculable. + Duration get timeRemaining { + final s = _currentSpeed; + if (s <= 0 || _totalBytes <= 0 || _lastBytes >= _totalBytes) { + return const Duration(seconds: -1); + } + + final remainingBytes = _totalBytes - _lastBytes; + final bytesPerSecond = s * 1024 * 1024; + final secondsRemaining = remainingBytes / bytesPerSecond; + + return Duration(seconds: secondsRemaining.round()); + } + + /// Returns a human-readable string representation of time remaining. + /// + /// Returns '--:--' if N/A, otherwise HH:MM:SS or MM:SS format. + String get timeRemainingAsString { + final remaining = timeRemaining; + return switch (remaining.inSeconds) { + <= 0 => '--:--', + < 3600 => + '${remaining.inMinutes.toString().padLeft(2, "0")}' + ':${remaining.inSeconds.remainder(60).toString().padLeft(2, "0")}', + _ => + '${remaining.inHours}' + ':${remaining.inMinutes.remainder(60).toString().padLeft(2, "0")}' + ':${remaining.inSeconds.remainder(60).toString().padLeft(2, "0")}', + }; + } +} + +/// Manager for tracking upload speeds for multiple concurrent uploads. +/// +/// Each upload is identified by a unique task ID. +class UploadSpeedManager { + /// Map of task IDs to their speed calculators. + final Map _calculators = {}; + + /// Gets or creates a speed calculator for the given task ID. + UploadSpeedCalculator getCalculator(String taskId) { + return _calculators.putIfAbsent(taskId, () => UploadSpeedCalculator()); + } + + /// Updates progress for a specific task and returns the speed string. + /// + /// [taskId] is the unique identifier for the upload task. + /// [currentBytes] is the number of bytes transferred so far. + /// [totalBytes] is the total size of the file being uploaded. + /// + /// Returns the human-readable speed string. + String updateProgress(String taskId, int currentBytes, int totalBytes) { + final calculator = getCalculator(taskId); + calculator.update(currentBytes, totalBytes); + return calculator.speedAsString; + } + + /// Gets the current speed string for a specific task. + String getSpeedAsString(String taskId) { + return _calculators[taskId]?.speedAsString ?? '-- MB/s'; + } + + /// Gets the time remaining string for a specific task. + String getTimeRemainingAsString(String taskId) { + return _calculators[taskId]?.timeRemainingAsString ?? '--:--'; + } + + /// Removes a task from tracking. + void removeTask(String taskId) { + _calculators.remove(taskId); + } + + /// Clears all tracked tasks. + void clear() { + _calculators.clear(); + } +} diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart index 0bab675889..56b4802f88 100644 --- a/mobile/test/domain/service.mock.dart +++ b/mobile/test/domain/service.mock.dart @@ -3,7 +3,7 @@ import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:mocktail/mocktail.dart'; class MockStoreService extends Mock implements StoreService {} @@ -16,5 +16,5 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {} class MockAppSettingsService extends Mock implements AppSettingsService {} -class MockUploadService extends Mock implements UploadService {} +class MockBackgroundUploadService extends Mock implements BackgroundUploadService {} diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart index 1bad780ca7..7c7de3cd0e 100644 --- a/mobile/test/services/auth.service_test.dart +++ b/mobile/test/services/auth.service_test.dart @@ -21,7 +21,6 @@ void main() { late MockApiService apiService; late MockNetworkService networkService; late MockBackgroundSyncManager backgroundSyncManager; - late MockUploadService uploadService; late MockAppSettingService appSettingsService; late Isar db; @@ -31,7 +30,6 @@ void main() { apiService = MockApiService(); networkService = MockNetworkService(); backgroundSyncManager = MockBackgroundSyncManager(); - uploadService = MockUploadService(); appSettingsService = MockAppSettingService(); sut = AuthService( @@ -118,7 +116,6 @@ void main() { when(() => authApiRepository.logout()).thenAnswer((_) async => {}); when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {}); when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null)); - when(() => uploadService.cancelBackup()).thenAnswer((_) => Future.value(1)); when( () => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false), ).thenAnswer((_) => Future.value(null)); @@ -133,7 +130,6 @@ void main() { when(() => authApiRepository.logout()).thenThrow(Exception('Server error')); when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {}); when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null)); - when(() => uploadService.cancelBackup()).thenAnswer((_) => Future.value(1)); when( () => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false), ).thenAnswer((_) => Future.value(null)); diff --git a/mobile/test/services/upload.service_test.dart b/mobile/test/services/background_upload.service_test.dart similarity index 82% rename from mobile/test/services/upload.service_test.dart rename to mobile/test/services/background_upload.service_test.dart index 86acf104e7..d0374c3987 100644 --- a/mobile/test/services/upload.service_test.dart +++ b/mobile/test/services/background_upload.service_test.dart @@ -12,13 +12,8 @@ import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; -import 'package:immich_mobile/models/server_info/server_config.model.dart'; -import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; -import 'package:immich_mobile/models/server_info/server_features.model.dart'; -import 'package:immich_mobile/models/server_info/server_info.model.dart'; -import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:mocktail/mocktail.dart'; import '../domain/service.mock.dart'; @@ -27,33 +22,12 @@ import '../infrastructure/repository.mock.dart'; import '../mocks/asset_entity.mock.dart'; import '../repository.mocks.dart'; -// Test ServerInfo stub -const _serverInfo = ServerInfo( - serverVersion: ServerVersion(major: 2, minor: 4, patch: 0), - latestVersion: ServerVersion(major: 2, minor: 4, patch: 0), - serverFeatures: ServerFeatures(trash: true, map: true, oauthEnabled: false, passwordLogin: true, ocr: false), - serverConfig: ServerConfig( - trashDays: 30, - oauthButtonText: 'Login with OAuth', - externalDomain: '', - mapDarkStyleUrl: '', - mapLightStyleUrl: '', - ), - serverDiskInfo: ServerDiskInfo( - diskAvailable: '100GB', - diskSize: '500GB', - diskUse: '400GB', - diskUsagePercentage: 80.0, - ), - versionStatus: VersionStatus.upToDate, -); - void main() { - late UploadService sut; + late BackgroundUploadService sut; late MockUploadRepository mockUploadRepository; - late MockDriftBackupRepository mockBackupRepository; late MockStorageRepository mockStorageRepository; late MockDriftLocalAssetRepository mockLocalAssetRepository; + late MockDriftBackupRepository mockBackupRepository; late MockAppSettingsService mockAppSettingsService; late MockAssetMediaRepository mockAssetMediaRepository; late Drift db; @@ -75,23 +49,22 @@ void main() { setUp(() { mockUploadRepository = MockUploadRepository(); - mockBackupRepository = MockDriftBackupRepository(); mockStorageRepository = MockStorageRepository(); mockLocalAssetRepository = MockDriftLocalAssetRepository(); + mockBackupRepository = MockDriftBackupRepository(); mockAppSettingsService = MockAppSettingsService(); mockAssetMediaRepository = MockAssetMediaRepository(); when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)).thenReturn(false); when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)).thenReturn(false); - sut = UploadService( + sut = BackgroundUploadService( mockUploadRepository, - mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, + mockBackupRepository, mockAppSettingsService, mockAssetMediaRepository, - _serverInfo, ); mockUploadRepository.onUploadStatus = (_) {}; @@ -201,14 +174,13 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); - final sutWithV24 = UploadService( + final sutWithV24 = BackgroundUploadService( mockUploadRepository, - mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, + mockBackupRepository, mockAppSettingsService, mockAssetMediaRepository, - _serverInfo, ); addTearDown(() => sutWithV24.dispose()); @@ -247,61 +219,17 @@ void main() { expect(metadata[0]['value']['longitude'], isNotNull); }); - test('should NOT include metadata on iOS when server version is below 2.4', () async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - - final sutWithV23 = UploadService( - mockUploadRepository, - mockBackupRepository, - mockStorageRepository, - mockLocalAssetRepository, - mockAppSettingsService, - mockAssetMediaRepository, - _serverInfo.copyWith( - serverVersion: const ServerVersion(major: 2, minor: 3, patch: 0), - latestVersion: const ServerVersion(major: 2, minor: 3, patch: 0), - ), - ); - addTearDown(() => sutWithV23.dispose()); - - final assetWithCloudId = LocalAsset( - id: 'test-asset-id', - name: 'test.jpg', - type: AssetType.image, - createdAt: DateTime(2025, 1, 1), - updatedAt: DateTime(2025, 1, 2), - cloudId: 'cloud-id-123', - latitude: 37.7749, - longitude: -122.4194, - ); - - final mockEntity = MockAssetEntity(); - final mockFile = File('/path/to/test.jpg'); - - when(() => mockEntity.isLivePhoto).thenReturn(false); - when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); - when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); - when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); - - final task = await sutWithV23.getUploadTask(assetWithCloudId); - - expect(task, isNotNull); - expect(task!.fields.containsKey('metadata'), isFalse); - }); - test('should NOT include metadata on Android regardless of server version', () async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); - final sutAndroid = UploadService( + final sutAndroid = BackgroundUploadService( mockUploadRepository, - mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, + mockBackupRepository, mockAppSettingsService, mockAssetMediaRepository, - _serverInfo, ); addTearDown(() => sutAndroid.dispose()); @@ -334,14 +262,13 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); - final sutWithV24 = UploadService( + final sutWithV24 = BackgroundUploadService( mockUploadRepository, - mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, + mockBackupRepository, mockAppSettingsService, mockAssetMediaRepository, - _serverInfo, ); addTearDown(() => sutWithV24.dispose()); @@ -374,14 +301,13 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); - final sutWithV24 = UploadService( + final sutWithV24 = BackgroundUploadService( mockUploadRepository, - mockBackupRepository, mockStorageRepository, mockLocalAssetRepository, + mockBackupRepository, mockAppSettingsService, mockAssetMediaRepository, - _serverInfo, ); addTearDown(() => sutWithV24.dispose());