1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(mobile): Background app refresh status (#1839)

* adds background app refresh message

* fixes ios background settings provider

* styling

* capitalization

* changed to watch

* uses settings notifier now

* forgot to commit this file

* changed to watch and added more clarification

---------

Co-authored-by: Marty Fuhry <marty@fuhry.farm>
This commit is contained in:
martyfuhry 2023-02-23 13:33:53 -05:00 committed by GitHub
parent 8bcb2558b6
commit 2b988e1d5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 245 additions and 83 deletions

View File

@ -226,5 +226,8 @@
"version_announcement_overlay_text_1": "Hi friend, there is a new release of", "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_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_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" "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"
}

View File

@ -90,12 +90,18 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let lastRunTime = defaults.value(forKey: "last_background_fetch_run_time") let lastRunTime = defaults.value(forKey: "last_background_fetch_run_time")
result(lastRunTime) result(lastRunTime)
break
case "lastBackgroundProcessingTime": case "lastBackgroundProcessingTime":
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let lastRunTime = defaults.value(forKey: "last_background_processing_run_time") let lastRunTime = defaults.value(forKey: "last_background_processing_run_time")
result(lastRunTime) result(lastRunTime)
break
case "numberOfBackgroundProcesses": case "numberOfBackgroundProcesses":
handleNumberOfProcesses(call: call, result: result) handleNumberOfProcesses(call: call, result: result)
break
case "backgroundAppRefreshEnabled":
handleBackgroundRefreshStatus(call: call, result: result)
break
default: default:
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
break break
@ -138,11 +144,10 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin {
// This is not used yet and will need to be implemented // This is not used yet and will need to be implemented
defaults.set(notificationTitle, forKey: "notification_title") defaults.set(notificationTitle, forKey: "notification_title")
// Schedule the background services if instant // Schedule the background services
if (instant ?? true) { BackgroundServicePlugin.scheduleBackgroundSync()
BackgroundServicePlugin.scheduleBackgroundSync() BackgroundServicePlugin.scheduleBackgroundFetch()
BackgroundServicePlugin.scheduleBackgroundFetch()
}
result(true) result(true)
} }
@ -209,15 +214,31 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin {
result(true) 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 // Schedules a short-running background sync to sync only a few photos
static func scheduleBackgroundFetch() { static func scheduleBackgroundFetch() {
// We will only schedule this task to run if the user has explicitely allowed us to backup while // We will schedule this task to run no matter the charging or wifi requirents from the end user
// not connected to power // 1. They can set Background App Refresh to Off / Wi-Fi / Wi-Fi & Cellular Data from Settings
let defaults = UserDefaults.standard // 2. We will check the battery connectivity when we begin running the background activity
if defaults.value(forKey: "require_charging") as? Bool == true {
return
}
let backgroundFetch = BGAppRefreshTaskRequest(identifier: BackgroundServicePlugin.backgroundFetchTaskID) let backgroundFetch = BGAppRefreshTaskRequest(identifier: BackgroundServicePlugin.backgroundFetchTaskID)
// Use 5 minutes from now as earliest begin date // 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 // This function runs when the system kicks off the BGAppRefreshTask from the Background Task Scheduler
static func handleBackgroundFetch(task: BGAppRefreshTask) { 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 // Log the time of last background processing to now
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
defaults.set(Date().timeIntervalSince1970, forKey: "last_background_fetch_run_time") 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 // Schedule the next sync task so we can run this again later
scheduleBackgroundFetch() scheduleBackgroundFetch()
@ -268,13 +305,13 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin {
// This function runs when the system kicks off the BGProcessingTask from the Background Task Scheduler // This function runs when the system kicks off the BGProcessingTask from the Background Task Scheduler
static func handleBackgroundProcessing(task: BGProcessingTask) { 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 // Log the time of last background processing to now
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
defaults.set(Date().timeIntervalSince1970, forKey: "last_background_processing_run_time") 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 // We won't specify a max time for the background sync service, so this can run for longer
BackgroundServicePlugin.runBackgroundSync(task, maxSeconds: nil) BackgroundServicePlugin.runBackgroundSync(task, maxSeconds: nil)
} }

View File

@ -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_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.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/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/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/settings/providers/permission.provider.dart'; import 'package:immich_mobile/modules/settings/providers/permission.provider.dart';
@ -144,6 +145,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
.watch(notificationPermissionProvider.notifier) .watch(notificationPermissionProvider.notifier)
.getNotificationPermission(); .getNotificationPermission();
ref.read(iOSBackgroundSettingsProvider.notifier).refresh();
break; break;
case AppLifecycleState.inactive: case AppLifecycleState.inactive:

View File

@ -574,6 +574,10 @@ class BackgroundService {
Future<int> getIOSBackupNumberOfProcesses() async { Future<int> getIOSBackupNumberOfProcesses() async {
return await _foregroundChannel.invokeMethod('numberOfBackgroundProcesses'); return await _foregroundChannel.invokeMethod('numberOfBackgroundProcesses');
} }
Future<bool> getIOSBackgroundAppRefreshEnabled() async {
return await _foregroundChannel.invokeMethod('backgroundAppRefreshEnabled');
}
} }
enum IosBackgroundTask { fetch, processing } enum IosBackgroundTask { fetch, processing }

View File

@ -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<IOSBackgroundSettings?> {
final BackgroundService _service;
IOSBackgroundSettingsNotifier(this._service) : super(null);
IOSBackgroundSettings? get settings => state;
Future<IOSBackgroundSettings> 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<IOSBackgroundSettingsNotifier, IOSBackgroundSettings?>(
(ref) => IOSBackgroundSettingsNotifier(ref.watch(backgroundServiceProvider)),
);

View File

@ -1,78 +1,61 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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'; import 'package:intl/intl.dart';
/// This is a simple debug widget which should be removed later on when we are /// This is a simple debug widget which should be removed later on when we are
/// more confident about background sync /// more confident about background sync
class IosDebugInfoTile extends HookConsumerWidget { class IosDebugInfoTile extends HookConsumerWidget {
const IosDebugInfoTile({super.key}); final IOSBackgroundSettings settings;
const IosDebugInfoTile({
super.key,
required this.settings,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final futures = [ final fetch = settings.timeOfLastFetch;
ref final processing = settings.timeOfLastProcessing;
.read(backgroundServiceProvider) final processes = settings.numberOfBackgroundTasksQueued;
.getIOSBackupLastRun(IosBackgroundTask.fetch),
ref
.read(backgroundServiceProvider)
.getIOSBackupLastRun(IosBackgroundTask.processing),
ref.read(backgroundServiceProvider).getIOSBackupNumberOfProcesses(),
];
return FutureBuilder<List<dynamic>>(
future: Future.wait(futures),
builder: (context, snapshot) {
String? title;
String? subtitle;
if (snapshot.hasData) {
final results = snapshot.data as List<dynamic>;
final fetch = results[0] as DateTime?;
final processing = results[1] as DateTime?;
final processes = results[2] as int;
final processOrProcesses = processes == 1 ? 'process' : 'processes'; final processOrProcesses = processes == 1 ? 'process' : 'processes';
final numberOrZero = processes == 0 ? 'No' : processes.toString(); final numberOrZero = processes == 0 ? 'No' : processes.toString();
title = '$numberOrZero background $processOrProcesses queued'; final title = '$numberOrZero background $processOrProcesses queued';
final df = DateFormat.yMd().add_jm(); final df = DateFormat.yMd().add_jm();
if (fetch == null && processing == null) { final String subtitle;
subtitle = 'No background sync job has run yet'; if (fetch == null && processing == null) {
} else if (fetch != null && processing == null) { subtitle = 'No background sync job has run yet';
subtitle = 'Fetch ran ${df.format(fetch)}'; } else if (fetch != null && processing == null) {
} else if (processing != null && fetch == null) { subtitle = 'Fetch ran ${df.format(fetch)}';
subtitle = 'Processing ran ${df.format(processing)}'; } else if (processing != null && fetch == null) {
} else { subtitle = 'Processing ran ${df.format(processing)}';
final fetchOrProcessing = } else {
fetch!.isAfter(processing!) ? fetch : processing; final fetchOrProcessing =
subtitle = 'Last sync ${df.format(fetchOrProcessing)}'; fetch!.isAfter(processing!) ? fetch : processing;
} subtitle = 'Last sync ${df.format(fetchOrProcessing)}';
} }
return AnimatedSwitcher( return ListTile(
duration: const Duration(milliseconds: 200), key: ValueKey(title),
child: ListTile( title: Text(
key: ValueKey(title), title,
title: Text( style: TextStyle(
title ?? '', fontWeight: FontWeight.bold,
style: TextStyle( fontSize: 14,
fontWeight: FontWeight.bold, color: Theme.of(context).primaryColor,
fontSize: 14, ),
color: Theme.of(context).primaryColor, ),
), subtitle: Text(
), subtitle,
subtitle: Text( style: const TextStyle(
subtitle ?? '', fontSize: 14,
style: const TextStyle( ),
fontSize: 14, ),
), leading: Icon(
), Icons.bug_report,
leading: Icon( color: Theme.of(context).primaryColor,
Icons.bug_report, ),
color: Theme.of(context).primaryColor,
),
),
);
},
); );
} }
} }

View File

@ -5,7 +5,9 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/current_backup_asset_info_box.dart';
import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart'; import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.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/routing/router.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/modules/backup/ui/backup_info_card.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'; import 'package:url_launcher/url_launcher.dart';
class BackupControllerPage extends HookConsumerWidget { class BackupControllerPage extends HookConsumerWidget {
@ -24,6 +27,10 @@ class BackupControllerPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider); BackUpState backupState = ref.watch(backupProvider);
AuthenticationState authenticationState = ref.watch(authenticationProvider); AuthenticationState authenticationState = ref.watch(authenticationProvider);
final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings;
final appRefreshDisabled = Platform.isIOS &&
settings?.appRefreshEnabled != true;
bool hasExclusiveAccess = bool hasExclusiveAccess =
backupState.backupProgress != BackUpProgressEnum.inBackground; backupState.backupProgress != BackUpProgressEnum.inBackground;
bool shouldBackup = backupState.allUniqueAssets.length - bool shouldBackup = backupState.allUniqueAssets.length -
@ -40,6 +47,13 @@ class BackupControllerPage extends HookConsumerWidget {
ref.watch(backupProvider.notifier).getBackupInfo(); 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 ref
.watch(websocketProvider.notifier) .watch(websocketProvider.notifier)
.stopListenToEvent('on_upload_success'); .stopListenToEvent('on_upload_success');
@ -362,14 +376,65 @@ class BackupControllerPage extends HookConsumerWidget {
], ],
), ),
), ),
if (isBackgroundEnabled) if (isBackgroundEnabled && Platform.isIOS)
IosDebugInfoTile( FutureBuilder(
key: ValueKey(isChargingRequired), 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() { Widget buildSelectedAlbumName() {
var text = "backup_controller_page_backup_selected".tr(); var text = "backup_controller_page_backup_selected".tr();
var albums = ref.watch(backupProvider).selectedBackupAlbums; var albums = ref.watch(backupProvider).selectedBackupAlbums;
@ -613,7 +678,15 @@ class BackupControllerPage extends HookConsumerWidget {
const Divider(), const Divider(),
buildAutoBackupController(), buildAutoBackupController(),
const Divider(), const Divider(),
buildBackgroundBackupController(), AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: Platform.isIOS
? (
appRefreshDisabled
? buildBackgroundAppRefreshWarning()
: buildBackgroundBackupController()
) : buildBackgroundBackupController(),
),
const Divider(), const Divider(),
buildStorageInformation(), buildStorageInformation(),
const Divider(), const Divider(),
@ -624,4 +697,6 @@ class BackupControllerPage extends HookConsumerWidget {
), ),
); );
} }
} }