diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 7eda8947d2..63922986a5 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -226,5 +226,8 @@ "version_announcement_overlay_text_1": "Hi friend, there is a new release of", "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", - "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" -} \ No newline at end of file + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings" +} diff --git a/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift b/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift index 1dcf39d1e2..622f13967a 100644 --- a/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift +++ b/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift @@ -90,12 +90,18 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { let defaults = UserDefaults.standard let lastRunTime = defaults.value(forKey: "last_background_fetch_run_time") result(lastRunTime) + break case "lastBackgroundProcessingTime": let defaults = UserDefaults.standard let lastRunTime = defaults.value(forKey: "last_background_processing_run_time") result(lastRunTime) + break case "numberOfBackgroundProcesses": handleNumberOfProcesses(call: call, result: result) + break + case "backgroundAppRefreshEnabled": + handleBackgroundRefreshStatus(call: call, result: result) + break default: result(FlutterMethodNotImplemented) break @@ -138,11 +144,10 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { // This is not used yet and will need to be implemented defaults.set(notificationTitle, forKey: "notification_title") - // Schedule the background services if instant - if (instant ?? true) { - BackgroundServicePlugin.scheduleBackgroundSync() - BackgroundServicePlugin.scheduleBackgroundFetch() - } + // Schedule the background services + BackgroundServicePlugin.scheduleBackgroundSync() + BackgroundServicePlugin.scheduleBackgroundFetch() + result(true) } @@ -209,15 +214,31 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { result(true) } + // Checks the status of the Background App Refresh from the system + // Returns true if the service is enabled for Immich, and false otherwise + func handleBackgroundRefreshStatus(call: FlutterMethodCall, result: FlutterResult) { + switch UIApplication.shared.backgroundRefreshStatus { + case .available: + result(true) + break + case .denied: + result(false) + break + case .restricted: + result(false) + break + default: + result(false) + break + } + } + + // Schedules a short-running background sync to sync only a few photos static func scheduleBackgroundFetch() { - // We will only schedule this task to run if the user has explicitely allowed us to backup while - // not connected to power - let defaults = UserDefaults.standard - if defaults.value(forKey: "require_charging") as? Bool == true { - return - } - + // We will schedule this task to run no matter the charging or wifi requirents from the end user + // 1. They can set Background App Refresh to Off / Wi-Fi / Wi-Fi & Cellular Data from Settings + // 2. We will check the battery connectivity when we begin running the background activity let backgroundFetch = BGAppRefreshTaskRequest(identifier: BackgroundServicePlugin.backgroundFetchTaskID) // Use 5 minutes from now as earliest begin date @@ -255,10 +276,26 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { // This function runs when the system kicks off the BGAppRefreshTask from the Background Task Scheduler static func handleBackgroundFetch(task: BGAppRefreshTask) { + // Schedule the next sync task so we can run this again later + scheduleBackgroundFetch() + // Log the time of last background processing to now let defaults = UserDefaults.standard defaults.set(Date().timeIntervalSince1970, forKey: "last_background_fetch_run_time") + // If we have required charging, we should check the charging status + let requireCharging = defaults.value(forKey: "require_charging") as? Bool + if (requireCharging ?? false) { + UIDevice.current.isBatteryMonitoringEnabled = true + if (UIDevice.current.batteryState == .unplugged) { + // The device is unplugged and we have required charging + // Therefore, we will simply complete the task without + // running it. + task.setTaskCompleted(success: true) + return + } + } + // Schedule the next sync task so we can run this again later scheduleBackgroundFetch() @@ -268,13 +305,13 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { // This function runs when the system kicks off the BGProcessingTask from the Background Task Scheduler static func handleBackgroundProcessing(task: BGProcessingTask) { + // Schedule the next sync task so we run this again later + scheduleBackgroundSync() + // Log the time of last background processing to now let defaults = UserDefaults.standard defaults.set(Date().timeIntervalSince1970, forKey: "last_background_processing_run_time") - // Schedule the next sync task so we run this again later - scheduleBackgroundSync() - // We won't specify a max time for the background sync service, so this can run for longer BackgroundServicePlugin.runBackgroundSync(task, maxSeconds: nil) } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 1239cbb4d4..c23e2c6a07 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/modules/backup/background_service/background.servi import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; +import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/settings/providers/permission.provider.dart'; @@ -144,6 +145,8 @@ class ImmichAppState extends ConsumerState .watch(notificationPermissionProvider.notifier) .getNotificationPermission(); + ref.read(iOSBackgroundSettingsProvider.notifier).refresh(); + break; case AppLifecycleState.inactive: diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index 17e9e42c31..3502455b1e 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -574,6 +574,10 @@ class BackgroundService { Future getIOSBackupNumberOfProcesses() async { return await _foregroundChannel.invokeMethod('numberOfBackgroundProcesses'); } + + Future getIOSBackgroundAppRefreshEnabled() async { + return await _foregroundChannel.invokeMethod('backgroundAppRefreshEnabled'); + } } enum IosBackgroundTask { fetch, processing } diff --git a/mobile/lib/modules/backup/providers/ios_background_settings.provider.dart b/mobile/lib/modules/backup/providers/ios_background_settings.provider.dart new file mode 100644 index 0000000000..b914fd2c6b --- /dev/null +++ b/mobile/lib/modules/backup/providers/ios_background_settings.provider.dart @@ -0,0 +1,57 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; + +class IOSBackgroundSettings { + final bool appRefreshEnabled; + final int numberOfBackgroundTasksQueued; + final DateTime? timeOfLastFetch; + final DateTime? timeOfLastProcessing; + + IOSBackgroundSettings({ + required this.appRefreshEnabled, + required this.numberOfBackgroundTasksQueued, + this.timeOfLastFetch, + this.timeOfLastProcessing, + }); +} + +class IOSBackgroundSettingsNotifier extends StateNotifier { + final BackgroundService _service; + IOSBackgroundSettingsNotifier(this._service) : super(null); + + IOSBackgroundSettings? get settings => state; + + Future refresh() async { + final lastFetchTime = await _service.getIOSBackupLastRun(IosBackgroundTask.fetch); + final lastProcessingTime = await _service.getIOSBackupLastRun(IosBackgroundTask.processing); + int numberOfProcesses = await _service.getIOSBackupNumberOfProcesses(); + final appRefreshEnabled = await _service.getIOSBackgroundAppRefreshEnabled(); + + // If this is enabled and there are no background processes, + // the user just enabled app refresh in Settings. + // But we don't have any background services running, since it was disabled + // before. + if (await _service.isBackgroundBackupEnabled() && + numberOfProcesses == 0) { + // We need to restart the background service + await _service.enableService(); + numberOfProcesses = await _service.getIOSBackupNumberOfProcesses(); + } + + final settings = IOSBackgroundSettings( + appRefreshEnabled: appRefreshEnabled, + numberOfBackgroundTasksQueued: numberOfProcesses, + timeOfLastFetch: lastFetchTime, + timeOfLastProcessing: lastProcessingTime, + ); + + state = settings; + return settings; + } + +} + +final iOSBackgroundSettingsProvider = StateNotifierProvider( + (ref) => IOSBackgroundSettingsNotifier(ref.watch(backgroundServiceProvider)), +); + diff --git a/mobile/lib/modules/backup/ui/ios_debug_info_tile.dart b/mobile/lib/modules/backup/ui/ios_debug_info_tile.dart index 7dc3dbd129..9ab92104ed 100644 --- a/mobile/lib/modules/backup/ui/ios_debug_info_tile.dart +++ b/mobile/lib/modules/backup/ui/ios_debug_info_tile.dart @@ -1,78 +1,61 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; +import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart'; import 'package:intl/intl.dart'; /// This is a simple debug widget which should be removed later on when we are /// more confident about background sync class IosDebugInfoTile extends HookConsumerWidget { - const IosDebugInfoTile({super.key}); + final IOSBackgroundSettings settings; + const IosDebugInfoTile({ + super.key, + required this.settings, + }); @override Widget build(BuildContext context, WidgetRef ref) { - final futures = [ - ref - .read(backgroundServiceProvider) - .getIOSBackupLastRun(IosBackgroundTask.fetch), - ref - .read(backgroundServiceProvider) - .getIOSBackupLastRun(IosBackgroundTask.processing), - ref.read(backgroundServiceProvider).getIOSBackupNumberOfProcesses(), - ]; - return FutureBuilder>( - future: Future.wait(futures), - builder: (context, snapshot) { - String? title; - String? subtitle; - if (snapshot.hasData) { - final results = snapshot.data as List; - final fetch = results[0] as DateTime?; - final processing = results[1] as DateTime?; - final processes = results[2] as int; + final fetch = settings.timeOfLastFetch; + final processing = settings.timeOfLastProcessing; + final processes = settings.numberOfBackgroundTasksQueued; - final processOrProcesses = processes == 1 ? 'process' : 'processes'; - final numberOrZero = processes == 0 ? 'No' : processes.toString(); - title = '$numberOrZero background $processOrProcesses queued'; + final processOrProcesses = processes == 1 ? 'process' : 'processes'; + final numberOrZero = processes == 0 ? 'No' : processes.toString(); + final title = '$numberOrZero background $processOrProcesses queued'; - final df = DateFormat.yMd().add_jm(); - if (fetch == null && processing == null) { - subtitle = 'No background sync job has run yet'; - } else if (fetch != null && processing == null) { - subtitle = 'Fetch ran ${df.format(fetch)}'; - } else if (processing != null && fetch == null) { - subtitle = 'Processing ran ${df.format(processing)}'; - } else { - final fetchOrProcessing = - fetch!.isAfter(processing!) ? fetch : processing; - subtitle = 'Last sync ${df.format(fetchOrProcessing)}'; - } - } + final df = DateFormat.yMd().add_jm(); + final String subtitle; + if (fetch == null && processing == null) { + subtitle = 'No background sync job has run yet'; + } else if (fetch != null && processing == null) { + subtitle = 'Fetch ran ${df.format(fetch)}'; + } else if (processing != null && fetch == null) { + subtitle = 'Processing ran ${df.format(processing)}'; + } else { + final fetchOrProcessing = + fetch!.isAfter(processing!) ? fetch : processing; + subtitle = 'Last sync ${df.format(fetchOrProcessing)}'; + } - return AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: ListTile( - key: ValueKey(title), - title: Text( - title ?? '', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: Theme.of(context).primaryColor, - ), - ), - subtitle: Text( - subtitle ?? '', - style: const TextStyle( - fontSize: 14, - ), - ), - leading: Icon( - Icons.bug_report, - color: Theme.of(context).primaryColor, - ), - ), - ); - }, + return ListTile( + key: ValueKey(title), + title: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Theme.of(context).primaryColor, + ), + ), + subtitle: Text( + subtitle, + style: const TextStyle( + fontSize: 14, + ), + ), + leading: Icon( + Icons.bug_report, + color: Theme.of(context).primaryColor, + ), ); } } diff --git a/mobile/lib/modules/backup/views/backup_controller_page.dart b/mobile/lib/modules/backup/views/backup_controller_page.dart index 73858b8344..6efb091ad1 100644 --- a/mobile/lib/modules/backup/views/backup_controller_page.dart +++ b/mobile/lib/modules/backup/views/backup_controller_page.dart @@ -5,7 +5,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart'; +import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart'; import 'package:immich_mobile/modules/backup/ui/current_backup_asset_info_box.dart'; import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; @@ -15,6 +17,7 @@ import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:url_launcher/url_launcher.dart'; class BackupControllerPage extends HookConsumerWidget { @@ -24,6 +27,10 @@ class BackupControllerPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { BackUpState backupState = ref.watch(backupProvider); AuthenticationState authenticationState = ref.watch(authenticationProvider); + final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings; + + final appRefreshDisabled = Platform.isIOS && + settings?.appRefreshEnabled != true; bool hasExclusiveAccess = backupState.backupProgress != BackUpProgressEnum.inBackground; bool shouldBackup = backupState.allUniqueAssets.length - @@ -40,6 +47,13 @@ class BackupControllerPage extends HookConsumerWidget { ref.watch(backupProvider.notifier).getBackupInfo(); } + // Update the background settings information just to make sure we + // have the latest, since the platform channel will not update + // automatically + if (Platform.isIOS) { + ref.watch(iOSBackgroundSettingsProvider.notifier).refresh(); + } + ref .watch(websocketProvider.notifier) .stopListenToEvent('on_upload_success'); @@ -362,14 +376,65 @@ class BackupControllerPage extends HookConsumerWidget { ], ), ), - if (isBackgroundEnabled) - IosDebugInfoTile( - key: ValueKey(isChargingRequired), + if (isBackgroundEnabled && Platform.isIOS) + FutureBuilder( + future: ref + .read(backgroundServiceProvider) + .getIOSBackgroundAppRefreshEnabled(), + builder: (context, snapshot) { + final enabled = snapshot.data as bool?; + // If it's not enabled, show them some kind of alert that says + // background refresh is not enabled + if (enabled != null && !enabled) { + + } + // If it's enabled, no need to bother them + return Container(); + }, ), + if (isBackgroundEnabled && settings != null) + IosDebugInfoTile( + settings: settings, + ), ], ); } + Widget buildBackgroundAppRefreshWarning() { + return ListTile( + isThreeLine: true, + leading: const Icon(Icons.task_outlined,), + title: const Text( + 'backup_controller_page_background_app_refresh_disabled_title', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ).tr(), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: const Text( + 'backup_controller_page_background_app_refresh_disabled_content', + ).tr(), + ), + ElevatedButton( + onPressed: () => openAppSettings(), + child: const Text( + 'backup_controller_page_background_app_refresh_enable_button_text', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ).tr(), + ), + ], + ), + ); + } + Widget buildSelectedAlbumName() { var text = "backup_controller_page_backup_selected".tr(); var albums = ref.watch(backupProvider).selectedBackupAlbums; @@ -613,7 +678,15 @@ class BackupControllerPage extends HookConsumerWidget { const Divider(), buildAutoBackupController(), const Divider(), - buildBackgroundBackupController(), + AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + child: Platform.isIOS + ? ( + appRefreshDisabled + ? buildBackgroundAppRefreshWarning() + : buildBackgroundBackupController() + ) : buildBackgroundBackupController(), + ), const Divider(), buildStorageInformation(), const Divider(), @@ -624,4 +697,6 @@ class BackupControllerPage extends HookConsumerWidget { ), ); } + + }