From 10e9c278eefd78ee71eed668145c71fadee1e39f Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 30 Jul 2025 11:43:20 -0500 Subject: [PATCH] feat: network requirement option for upload (#20302) * wifi toggle * feat: network requirement option for upload * chore: put back holding queue previous config numbers * options * backup option page * pr feedback --- i18n/en.json | 5 ++ mobile/lib/domain/models/store.model.dart | 4 +- mobile/lib/main.dart | 3 +- .../lib/pages/backup/drift_backup.page.dart | 9 ++ .../backup/drift_backup_options.page.dart | 68 +++++++++++++++ mobile/lib/pages/common/settings.page.dart | 7 +- .../backup/drift_backup.provider.dart | 8 +- mobile/lib/routing/router.dart | 4 +- mobile/lib/routing/router.gr.dart | 16 ++++ mobile/lib/services/app_settings.service.dart | 4 +- mobile/lib/services/upload.service.dart | 24 +++++- .../drift_backup_settings.dart | 82 +++++++++++++++++++ 12 files changed, 220 insertions(+), 14 deletions(-) create mode 100644 mobile/lib/pages/backup/drift_backup_options.page.dart create mode 100644 mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart diff --git a/i18n/en.json b/i18n/en.json index c445110996..94f6920745 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -580,8 +580,10 @@ "backup_manual_in_progress": "Upload already in progress. Try after sometime", "backup_manual_success": "Success", "backup_manual_title": "Upload status", + "backup_options": "Backup Options", "backup_options_page_title": "Backup options", "backup_setting_subtitle": "Manage background and foreground upload settings", + "backup_settings_subtitle": "Manage upload settings", "backward": "Backward", "beta_sync": "Beta Sync Status", "beta_sync_subtitle": "Manage the new sync system", @@ -1312,6 +1314,9 @@ "my_albums": "My albums", "name": "Name", "name_or_nickname": "Name or nickname", + "network_requirement_photos_upload": "Use cellular data to backup photos", + "network_requirement_videos_upload": "Use cellular data to backup videos", + "network_requirements_updated": "Network requirements changed, resetting backup queue", "networking_settings": "Networking", "networking_subtitle": "Manage the server endpoint settings", "never": "Never", diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 305b3f3387..e4e316b814 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -71,7 +71,9 @@ enum StoreKey { photoManagerCustomFilter._(1000), betaPromptShown._(1001), betaTimeline._(1002), - enableBackup._(1003); + enableBackup._(1003), + useWifiForUploadVideos._(1004), + useWifiForUploadPhotos._(1005); const StoreKey._(this.id); final int id; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 63220295ff..d1f415a304 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -91,10 +91,9 @@ Future initApp() async { initializeTimeZones(); // Initialize the file downloader - await FileDownloader().configure( // maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3 - globalConfig: (Config.holdingQueue, (1000, 1000, 1000)), + globalConfig: (Config.holdingQueue, (6, 6, 3)), ); await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false); diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index 6d31c75946..70e8190014 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -65,6 +65,15 @@ class _DriftBackupPageState extends ConsumerState { splashRadius: 24, icon: const Icon(Icons.arrow_back_ios_rounded), ), + actions: [ + IconButton( + onPressed: () { + context.pushRoute(const DriftBackupOptionsRoute()); + }, + icon: const Icon(Icons.settings_outlined), + tooltip: "backup_options".t(context: context), + ), + ], ), body: Stack( children: [ diff --git a/mobile/lib/pages/backup/drift_backup_options.page.dart b/mobile/lib/pages/backup/drift_backup_options.page.dart new file mode 100644 index 0000000000..92f911ae1e --- /dev/null +++ b/mobile/lib/pages/backup/drift_backup_options.page.dart @@ -0,0 +1,68 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.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'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart'; + +@RoutePage() +class DriftBackupOptionsPage extends ConsumerWidget { + const DriftBackupOptionsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + bool hasPopped = false; + final previousWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false; + final previousWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? 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; + + if (currentWifiReqForVideos == previousWifiReqForVideos && + currentWifiReqForPhotos == previousWifiReqForPhotos) { + return; + } + + if (didPop && !hasPopped) { + hasPopped = true; + + final currentUser = ref.read(currentUserProvider); + if (currentUser == null) { + return; + } + + await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); + final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); + if (!isBackupEnabled) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("network_requirements_updated".t(context: context)), + duration: const Duration(seconds: 4), + ), + ); + + final backupNotifier = ref.read(driftBackupProvider.notifier); + backupNotifier.cancel().then((_) { + backupNotifier.startBackup(currentUser.id); + }); + } + }, + child: Scaffold( + appBar: AppBar(title: Text("backup_options".t(context: context))), + body: const DriftBackupSettings(), + ), + ); + } +} diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index d7ecb7e582..7bc8cd2b3a 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -2,6 +2,7 @@ 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: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/routing/router.dart'; @@ -9,6 +10,7 @@ import 'package:immich_mobile/widgets/settings/advanced_settings.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_settings.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart'; +import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart'; import 'package:immich_mobile/widgets/settings/beta_sync_settings/beta_sync_settings.dart'; import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart'; import 'package:immich_mobile/widgets/settings/language_settings.dart'; @@ -21,7 +23,7 @@ enum SettingSection { beta('beta_sync', Icons.sync_outlined, "beta_sync_subtitle"), advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"), assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"), - backup('backup', Icons.cloud_upload_outlined, "backup_setting_subtitle"), + backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"), languages('language', Icons.language, "setting_languages_subtitle"), networking('networking_settings', Icons.wifi, "networking_subtitle"), notifications('notifications', Icons.notifications_none_rounded, "setting_notifications_subtitle"), @@ -36,7 +38,8 @@ enum SettingSection { SettingSection.beta => const _BetaLandscapeToggle(), SettingSection.advanced => const AdvancedSettings(), SettingSection.assetViewer => const AssetViewerSettings(), - SettingSection.backup => const BackupSettings(), + SettingSection.backup => + Store.tryGet(StoreKey.betaTimeline) ?? false ? const DriftBackupSettings() : const BackupSettings(), SettingSection.languages => const LanguageSettings(), SettingSection.networking => const NetworkingSettings(), SettingSection.notifications => const NotificationSetting(), diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index aca10e60a7..39949fd526 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -187,12 +187,12 @@ class DriftBackupState { } } -final driftBackupProvider = StateNotifierProvider((ref) { - return ExpBackupNotifier(ref.watch(uploadServiceProvider)); +final driftBackupProvider = StateNotifierProvider((ref) { + return DriftBackupNotifier(ref.watch(uploadServiceProvider)); }); -class ExpBackupNotifier extends StateNotifier { - ExpBackupNotifier(this._uploadService) +class DriftBackupNotifier extends StateNotifier { + DriftBackupNotifier(this._uploadService) : super( const DriftBackupState( totalCount: 0, diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 54d4d080d4..4fe1673893 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -28,6 +28,7 @@ import 'package:immich_mobile/pages/backup/drift_backup.page.dart'; import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; import 'package:immich_mobile/pages/backup/backup_options.page.dart'; +import 'package:immich_mobile/pages/backup/drift_backup_options.page.dart'; import 'package:immich_mobile/pages/backup/drift_upload_detail.page.dart'; import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; import 'package:immich_mobile/pages/common/activities.page.dart'; @@ -322,13 +323,12 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftPlaceDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftUserSelectionRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: ChangeExperienceRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: DriftPartnerRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftUploadDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: BetaSyncSettingsRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]), + AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 63e8c6ecfe..e8f0dd8b1f 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -764,6 +764,22 @@ class DriftBackupAlbumSelectionRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftBackupOptionsPage] +class DriftBackupOptionsRoute extends PageRouteInfo { + const DriftBackupOptionsRoute({List? children}) + : super(DriftBackupOptionsRoute.name, initialChildren: children); + + static const String name = 'DriftBackupOptionsRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftBackupOptionsPage(); + }, + ); +} + /// generated route for /// [DriftBackupPage] class DriftBackupRoute extends PageRouteInfo { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index e705912aa5..8a4b0c6719 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -47,7 +47,9 @@ enum AppSettingsEnum { autoEndpointSwitching(StoreKey.autoEndpointSwitching, null, false), photoManagerCustomFilter(StoreKey.photoManagerCustomFilter, null, true), betaTimeline(StoreKey.betaTimeline, null, false), - enableBackup(StoreKey.enableBackup, null, false); + enableBackup(StoreKey.enableBackup, null, false), + useCellularForUploadVideos(StoreKey.useWifiForUploadVideos, null, false), + useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, 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 c41d2b2e5f..dba3817b2c 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -12,11 +12,13 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/providers/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/repositories/upload.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:path/path.dart' as p; final uploadServiceProvider = Provider((ref) { @@ -25,6 +27,7 @@ final uploadServiceProvider = Provider((ref) { ref.watch(backupRepositoryProvider), ref.watch(storageRepositoryProvider), ref.watch(localAssetRepository), + ref.watch(appSettingsServiceProvider), ); ref.onDispose(service.dispose); @@ -32,7 +35,13 @@ final uploadServiceProvider = Provider((ref) { }); class UploadService { - UploadService(this._uploadRepository, this._backupRepository, this._storageRepository, this._localAssetRepository) { + UploadService( + this._uploadRepository, + this._backupRepository, + this._storageRepository, + this._localAssetRepository, + this._appSettingsService, + ) { _uploadRepository.onUploadStatus = _onUploadCallback; _uploadRepository.onTaskProgress = _onTaskProgressCallback; } @@ -41,6 +50,7 @@ class UploadService { final DriftBackupRepository _backupRepository; final StorageRepository _storageRepository; final DriftLocalAssetRepository _localAssetRepository; + final AppSettingsService _appSettingsService; final StreamController _taskStatusController = StreamController.broadcast(); final StreamController _taskProgressController = StreamController.broadcast(); @@ -240,6 +250,14 @@ class UploadService { livePhotoVideoId: '', ).toJson(); + bool requiresWiFi = true; + + if (asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)) { + requiresWiFi = false; + } else if (!asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)) { + requiresWiFi = false; + } + return buildUploadTask( file, originalFileName: originalFileName, @@ -248,6 +266,7 @@ class UploadService { group: group, priority: priority, isFavorite: asset.isFavorite, + requiresWiFi: requiresWiFi, ); } @@ -284,12 +303,12 @@ class UploadService { String? metadata, int? priority, bool? isFavorite, + bool requiresWiFi = true, }) 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; @@ -318,6 +337,7 @@ class UploadService { fileField: 'assetData', metaData: metadata ?? '', group: group, + requiresWiFi: requiresWiFi, priority: priority ?? 5, updates: Updates.statusAndProgress, retries: 3, diff --git a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart new file mode 100644 index 0000000000..553eb939c2 --- /dev/null +++ b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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/translate_extensions.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; + +class DriftBackupSettings extends StatelessWidget { + const DriftBackupSettings({super.key}); + + @override + Widget build(BuildContext context) { + return const SettingsSubPageScaffold(settings: [_UseWifiForUploadVideosButton(), _UseWifiForUploadPhotosButton()]); + } +} + +class _UseWifiForUploadVideosButton extends ConsumerWidget { + const _UseWifiForUploadVideosButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final valueStream = Store.watch(StoreKey.useWifiForUploadVideos); + + return ListTile( + title: Text( + "videos".t(context: context), + style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor), + ), + subtitle: Text("network_requirement_videos_upload".t(context: context), style: context.textTheme.labelLarge), + trailing: StreamBuilder( + stream: valueStream, + initialData: Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false, + builder: (context, snapshot) { + final value = snapshot.data ?? false; + return Switch( + value: value, + onChanged: (bool newValue) async { + await ref + .read(appSettingsServiceProvider) + .setSetting(AppSettingsEnum.useCellularForUploadVideos, newValue); + }, + ); + }, + ), + ); + } +} + +class _UseWifiForUploadPhotosButton extends ConsumerWidget { + const _UseWifiForUploadPhotosButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final valueStream = Store.watch(StoreKey.useWifiForUploadPhotos); + + return ListTile( + title: Text( + "photos".t(context: context), + style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor), + ), + subtitle: Text("network_requirement_photos_upload".t(context: context), style: context.textTheme.labelLarge), + trailing: StreamBuilder( + stream: valueStream, + initialData: Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false, + builder: (context, snapshot) { + final value = snapshot.data ?? false; + return Switch( + value: value, + onChanged: (bool newValue) async { + await ref + .read(appSettingsServiceProvider) + .setSetting(AppSettingsEnum.useCellularForUploadPhotos, newValue); + }, + ); + }, + ), + ); + } +}