diff --git a/i18n/en.json b/i18n/en.json index c83aac618e..efe028b27b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -639,6 +639,8 @@ "cannot_update_the_description": "Cannot update the description", "cast": "Cast", "cast_description": "Configure available cast destinations", + "cellular_data_for_photos": "Cellular data for photos", + "cellular_data_for_videos": "Cellular data for videos", "change_date": "Change date", "change_description": "Change description", "change_display_order": "Change display order", diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 087297ab71..bcacbc0119 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -129,6 +129,8 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Sync; sourceTree = ""; }; @@ -505,14 +507,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -541,14 +539,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 6dcd81774a..5517ff55d3 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -75,8 +75,8 @@ enum StoreKey { betaPromptShown._(1001), betaTimeline._(1002), enableBackup._(1003), - useWifiForUploadVideos._(1004), - useWifiForUploadPhotos._(1005); + useCellularForUploadVideos._(1004), + useCellularForUploadPhotos._(1005); const StoreKey._(this.id); final int id; diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index 5140c62a0d..151ce5fc27 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -3,6 +3,8 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.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'; @@ -93,7 +95,11 @@ class _DriftBackupPageState extends ConsumerState { const _BackupCard(), const _RemainderCard(), const Divider(), + const SizedBox(height: 4), + const _CellularBackupStatus(), + const SizedBox(height: 4), BackupToggleButton(onStart: () async => await startBackup(), onStop: () async => await stopBackup()), + TextButton.icon( icon: const Icon(Icons.info_outline_rounded), onPressed: () => context.pushRoute(const DriftUploadDetailRoute()), @@ -109,6 +115,64 @@ class _DriftBackupPageState extends ConsumerState { } } +class _CellularBackupStatus extends ConsumerWidget { + const _CellularBackupStatus(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cellularReqForVideos = Store.watch(StoreKey.useCellularForUploadVideos); + final cellularReqForPhotos = Store.watch(StoreKey.useCellularForUploadPhotos); + + return GestureDetector( + onTap: () => context.pushRoute(const DriftBackupOptionsRoute()), + child: Row( + children: [ + StreamBuilder( + stream: cellularReqForVideos, + initialData: Store.tryGet(StoreKey.useCellularForUploadVideos) ?? false, + builder: (context, snapshot) { + return Expanded( + child: ListTile( + visualDensity: VisualDensity.compact, + leading: Icon( + snapshot.data ?? false ? Icons.check_circle : Icons.cancel_outlined, + size: 16, + color: snapshot.data ?? false ? Colors.green : context.colorScheme.onSurfaceVariant, + ), + title: Text( + "cellular_data_for_videos".t(context: context), + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ), + ); + }, + ), + StreamBuilder( + stream: cellularReqForPhotos, + initialData: Store.tryGet(StoreKey.useCellularForUploadPhotos) ?? false, + builder: (context, snapshot) { + return Expanded( + child: ListTile( + visualDensity: VisualDensity.compact, + leading: Icon( + snapshot.data ?? false ? Icons.check_circle : Icons.cancel_outlined, + size: 16, + color: snapshot.data ?? false ? Colors.green : context.colorScheme.onSurfaceVariant, + ), + title: Text( + "cellular_data_for_photos".t(context: context), + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ), + ); + }, + ), + ], + ), + ); + } +} + class _BackupAlbumSelectionCard extends ConsumerWidget { const _BackupAlbumSelectionCard(); diff --git a/mobile/lib/pages/backup/drift_backup_options.page.dart b/mobile/lib/pages/backup/drift_backup_options.page.dart index 92f911ae1e..6ca594dfff 100644 --- a/mobile/lib/pages/backup/drift_backup_options.page.dart +++ b/mobile/lib/pages/backup/drift_backup_options.page.dart @@ -17,15 +17,15 @@ class DriftBackupOptionsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { bool hasPopped = false; - final previousWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false; - final previousWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false; + final previousWifiReqForVideos = Store.tryGet(StoreKey.useCellularForUploadVideos) ?? false; + final previousWifiReqForPhotos = Store.tryGet(StoreKey.useCellularForUploadPhotos) ?? false; return PopScope( onPopInvokedWithResult: (didPop, result) async { // There is an issue with Flutter where the pop event // can be triggered multiple times, so we guard it with _hasPopped - final currentWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false; - final currentWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false; + final currentWifiReqForVideos = Store.tryGet(StoreKey.useCellularForUploadVideos) ?? false; + final currentWifiReqForPhotos = Store.tryGet(StoreKey.useCellularForUploadPhotos) ?? false; if (currentWifiReqForVideos == previousWifiReqForVideos && currentWifiReqForPhotos == previousWifiReqForPhotos) { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index d98b14408f..15f905dc20 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -48,8 +48,8 @@ enum AppSettingsEnum { photoManagerCustomFilter(StoreKey.photoManagerCustomFilter, null, true), betaTimeline(StoreKey.betaTimeline, null, false), enableBackup(StoreKey.enableBackup, null, false), - useCellularForUploadVideos(StoreKey.useWifiForUploadVideos, null, false), - useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false), + useCellularForUploadVideos(StoreKey.useCellularForUploadVideos, null, false), + useCellularForUploadPhotos(StoreKey.useCellularForUploadPhotos, null, false), readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 635604b096..f6436ae6b3 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -19,6 +19,7 @@ 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; final uploadServiceProvider = Provider((ref) { @@ -57,6 +58,7 @@ class UploadService { Stream get taskStatusStream => _taskStatusController.stream; Stream get taskProgressStream => _taskProgressController.stream; + final Logger _log = Logger('UploadService'); bool shouldAbortQueuingTasks = false; @@ -127,11 +129,16 @@ class UploadService { final candidates = await _backupRepository.getCandidates(userId); if (candidates.isEmpty) { + _log.info("No backup candidates found for user $userId"); return; } + _log.info("Starting backup for ${candidates.length} candidates"); + onEnqueueTasks(EnqueueStatus(enqueueCount: 0, totalCount: candidates.length)); + const batchSize = 100; int count = 0; + int skippedAssets = 0; for (int i = 0; i < candidates.length; i += batchSize) { if (shouldAbortQueuingTasks) { break; @@ -144,16 +151,22 @@ class UploadService { final task = await _getUploadTask(asset); if (task != null) { tasks.add(task); + } else { + skippedAssets++; + _log.warning("Skipped asset ${asset.id} (${asset.name}) - unable to create upload task"); } } - if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { - count += tasks.length; - await enqueueTasks(tasks); + count += tasks.length; + onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length)); - onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length)); + if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { + _log.info("Enqueuing ${tasks.length} upload tasks"); + await enqueueTasks(tasks); } } + + _log.info("Upload queueing completed: $count tasks enqueued, $skippedAssets assets skipped"); } // Enqueue All does not work from the background on Android yet. This method is a temporary workaround @@ -165,9 +178,14 @@ class UploadService { final candidates = await _backupRepository.getCandidates(userId); if (candidates.isEmpty) { + debugPrint("No backup candidates found for serial backup"); return; } + debugPrint("Starting serial backup for ${candidates.length} candidates"); + int skippedAssets = 0; + int enqueuedTasks = 0; + for (final asset in candidates) { if (shouldAbortQueuingTasks) { break; @@ -176,8 +194,13 @@ class UploadService { final task = await _getUploadTask(asset); if (task != null) { await _uploadRepository.enqueueBackground(task); + enqueuedTasks++; + } else { + skippedAssets++; } } + + debugPrint("Serial backup completed: $enqueuedTasks tasks enqueued, $skippedAssets assets skipped"); } /// Cancel all ongoing uploads and reset the upload queue @@ -245,6 +268,7 @@ class UploadService { Future _getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async { final entity = await _storageRepository.getAssetEntityForAsset(asset); if (entity == null) { + _log.warning("Cannot get AssetEntity for asset ${asset.id} (${asset.name}) created on ${asset.createdAt}"); return null; } @@ -267,6 +291,9 @@ class UploadService { } if (file == null) { + _log.warning( + "Cannot get file for asset ${asset.id} (${asset.name}) created on ${asset.createdAt} - file may have been deleted or moved", + ); return null; } diff --git a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart index 553eb939c2..838b94cc5f 100644 --- a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart @@ -22,7 +22,7 @@ class _UseWifiForUploadVideosButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final valueStream = Store.watch(StoreKey.useWifiForUploadVideos); + final valueStream = Store.watch(StoreKey.useCellularForUploadVideos); return ListTile( title: Text( @@ -32,7 +32,7 @@ class _UseWifiForUploadVideosButton extends ConsumerWidget { subtitle: Text("network_requirement_videos_upload".t(context: context), style: context.textTheme.labelLarge), trailing: StreamBuilder( stream: valueStream, - initialData: Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false, + initialData: Store.tryGet(StoreKey.useCellularForUploadVideos) ?? false, builder: (context, snapshot) { final value = snapshot.data ?? false; return Switch( @@ -54,7 +54,7 @@ class _UseWifiForUploadPhotosButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final valueStream = Store.watch(StoreKey.useWifiForUploadPhotos); + final valueStream = Store.watch(StoreKey.useCellularForUploadPhotos); return ListTile( title: Text( @@ -64,7 +64,7 @@ class _UseWifiForUploadPhotosButton extends ConsumerWidget { subtitle: Text("network_requirement_photos_upload".t(context: context), style: context.textTheme.labelLarge), trailing: StreamBuilder( stream: valueStream, - initialData: Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false, + initialData: Store.tryGet(StoreKey.useCellularForUploadPhotos) ?? false, builder: (context, snapshot) { final value = snapshot.data ?? false; return Switch(