feat(mobile): iOS background sync (#1758)

* first run of getting background sync working in iOS

* got background sync calling into flutter

* added background task

* added necessary sync files

* fixed some names and added more implementations

* got as far as Hive.initFlutter

* brute force got to await Hive.initFlutter

* lots of print statements to figure out where execution is failing, and its failing at the root asset bundle in the localization.dart service

* first time working, got plugins registered

* removed broken cleanup code

* refactored

* linters

* now can pass user settings

* background service plugin uses app background processing instead of fetch

* renamed backgroundFetch to backgroundProcessing to make it clearer

* don't use max delay

* adds fetch back in

* fixes require charging default values and backup controller page

* fixes background fetch

* fixes ios not importing photos

* guarded path provider ios

* lint

* adds max tries for heartbeat to work in iOS

* fail after seconds

* timeout instead of fail after seconds

* removes release lock from system stop

* restores checkLockReleasedWithHeartbeat to Future<void>

* removes max tries from acquire lock

* fixes lock timeout with iOS

* restored for loop

* adds comments, made the AppRefresh task only run while not requiring network or charge

* fixed compile issue

* now both are registered and added better comments. also added ability for task to cancel itself

* added the podfile and pubspec

* added backup diagnostics to IOS and removed iOS ignored backup options and fixed network connectivity always required

* Added Alex's dev team

* styled debug list item, fixed refresh task not set bug, fixed enable / disable background service on platform channel

---------

Co-authored-by: Marty Fuhry <marty@fuhry.farm>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
martyfuhry 2023-02-20 00:59:50 -05:00 committed by GitHub
parent 2cf42e867c
commit 87fea29e32
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 926 additions and 284 deletions

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'dart:isolate';
import 'dart:ui' show IsolateNameServer, PluginUtilities;
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
@ -10,7 +10,6 @@ import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/background_service/localization.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
@ -19,6 +18,7 @@ import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:path_provider_ios/path_provider_ios.dart';
import 'package:photo_manager/photo_manager.dart';
final backgroundServiceProvider = Provider(
@ -51,8 +51,7 @@ class BackgroundService {
late final _Throttle _throttledDetailNotify =
_Throttle(_updateDetailProgress, notifyInterval);
Completer<bool> _hasAccessCompleter = Completer();
late Future<bool> _hasAccess =
Platform.isAndroid ? _hasAccessCompleter.future : Future.value(true);
late Future<bool> _hasAccess = _hasAccessCompleter.future;
Future<bool> get hasAccess => _hasAccess;
@ -67,9 +66,6 @@ class BackgroundService {
/// Enqueues the background service
Future<bool> enableService({bool immediate = false}) async {
if (!Platform.isAndroid) {
return true;
}
try {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
final String title =
@ -89,9 +85,6 @@ class BackgroundService {
int triggerUpdateDelay = 5000,
int triggerMaxDelay = 50000,
}) async {
if (!Platform.isAndroid) {
return true;
}
try {
final bool ok = await _foregroundChannel.invokeMethod(
'configure',
@ -110,9 +103,6 @@ class BackgroundService {
/// Cancels the background service (if currently running) and removes it from work queue
Future<bool> disableService() async {
if (!Platform.isAndroid) {
return true;
}
try {
final ok = await _foregroundChannel.invokeMethod('disable');
return ok;
@ -123,9 +113,6 @@ class BackgroundService {
/// Returns `true` if the background service is enabled
Future<bool> isBackgroundBackupEnabled() async {
if (!Platform.isAndroid) {
return false;
}
try {
return await _foregroundChannel.invokeMethod("isEnabled");
} catch (error) {
@ -135,7 +122,8 @@ class BackgroundService {
/// Returns `true` if battery optimizations are disabled
Future<bool> isIgnoringBatteryOptimizations() async {
if (!Platform.isAndroid) {
// iOS does not need battery optimizations enabled
if (Platform.isIOS) {
return true;
}
try {
@ -156,9 +144,6 @@ class BackgroundService {
bool isDetail = false,
bool onlyIfFG = false,
}) async {
if (!Platform.isAndroid) {
return true;
}
try {
if (_isBackgroundInitialized) {
return _backgroundChannel.invokeMethod<bool>(
@ -178,9 +163,6 @@ class BackgroundService {
String? content,
String? individualTag,
}) async {
if (!Platform.isAndroid) {
return true;
}
try {
if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
return await _backgroundChannel
@ -193,9 +175,6 @@ class BackgroundService {
}
Future<bool> _clearErrorNotifications() async {
if (!Platform.isAndroid) {
return true;
}
try {
if (_isBackgroundInitialized) {
return await _backgroundChannel.invokeMethod('clearErrorNotifications');
@ -210,9 +189,6 @@ class BackgroundService {
/// await to ensure this thread (foreground or background) has exclusive access
Future<bool> acquireLock() async {
if (!Platform.isAndroid) {
return true;
}
if (_hasLock) {
debugPrint("WARNING: [acquireLock] called more than once");
return true;
@ -253,7 +229,7 @@ class BackgroundService {
while (_wantsLockTime == lockTime) {
other.send(tempSp);
final dynamic answer = await bs.first
.timeout(const Duration(seconds: 5), onTimeout: () => null);
.timeout(const Duration(seconds: 3), onTimeout: () => null);
if (_wantsLockTime != lockTime) {
break;
}
@ -270,7 +246,7 @@ class BackgroundService {
// other isolate is still active
}
final dynamic isFinished = await bs.first
.timeout(const Duration(seconds: 5), onTimeout: () => false);
.timeout(const Duration(seconds: 3), onTimeout: () => false);
if (isFinished == true) {
break;
}
@ -288,9 +264,6 @@ class BackgroundService {
/// releases the exclusive access lock
void releaseLock() {
if (!Platform.isAndroid) {
return;
}
_wantsLockTime = 0;
if (_hasLock) {
_hasAccessCompleter = Completer();
@ -311,17 +284,35 @@ class BackgroundService {
}
Future<bool> _callHandler(MethodCall call) async {
DartPluginRegistrant.ensureInitialized();
if (Platform.isIOS) {
// NOTE: I'm not sure this is strictly necessary anymore, but
// out of an abundance of caution, we will keep it in until someone
// can say for sure
PathProviderIOS.registerWith();
}
switch (call.method) {
case "backgroundProcessing":
case "onAssetsChanged":
final Future<bool> translationsLoaded = loadTranslations();
try {
_clearErrorNotifications();
final bool hasAccess = await acquireLock();
// iOS should time out after some threshhold so it doesn't wait
// indefinitely and can run later
// Android is fine to wait here until the lock releases
final waitForLock = Platform.isIOS
? acquireLock()
.timeout(
const Duration(seconds: 5),
onTimeout: () => false,
)
: acquireLock();
final bool hasAccess = await waitForLock;
if (!hasAccess) {
debugPrint("[_callHandler] could not acquire lock, exiting");
return false;
}
await translationsLoaded;
final bool ok = await _onAssetsChanged();
return ok;
} catch (error) {
@ -388,9 +379,9 @@ class BackgroundService {
.put(backupFailedSince, DateTime.now());
return false;
}
// check for new assets added while performing backup
} while (true ==
await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
// Android should check for new assets added while performing backup
} while (Platform.isAndroid &&
true == await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
return true;
}
@ -560,6 +551,28 @@ class BackgroundService {
final String total = numberFormat.format(bytesTotal / 1024.0);
return "$percent% ($done/$total$unit)";
}
Future<DateTime?> getIOSBackupLastRun(IosBackgroundTask task) async {
// Seconds since last run
final double? lastRun = task == IosBackgroundTask.fetch
? await _foregroundChannel.invokeMethod('lastBackgroundFetchTime')
: await _foregroundChannel.invokeMethod('lastBackgroundProcessingTime');
if (lastRun == null) {
return null;
}
final time = DateTime.fromMillisecondsSinceEpoch(lastRun.toInt() * 1000);
return time;
}
Future<int> getIOSBackupNumberOfProcesses() async {
return await _foregroundChannel
.invokeMethod('numberOfBackgroundProcesses');
}
}
enum IosBackgroundTask {
fetch,
processing
}
class _Throttle {
@ -603,6 +616,7 @@ class _Throttle {
@pragma('vm:entry-point')
void _nativeEntry() {
WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();
BackgroundService backgroundService = BackgroundService();
backgroundService._setupBackgroundCallHandler();
}