mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	
		
			
				
	
	
		
			728 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			728 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'dart:async';
 | 
						|
import 'dart:developer';
 | 
						|
import 'dart:io';
 | 
						|
import 'dart:isolate';
 | 
						|
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
 | 
						|
 | 
						|
import 'package:cancellation_token_http/http.dart';
 | 
						|
import 'package:collection/collection.dart';
 | 
						|
import 'package:easy_localization/easy_localization.dart';
 | 
						|
import 'package:flutter/foundation.dart';
 | 
						|
import 'package:flutter/services.dart';
 | 
						|
import 'package:flutter/widgets.dart';
 | 
						|
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
						|
import 'package:immich_mobile/domain/models/store.model.dart';
 | 
						|
import 'package:immich_mobile/entities/backup_album.entity.dart';
 | 
						|
import 'package:immich_mobile/entities/store.entity.dart';
 | 
						|
import 'package:immich_mobile/interfaces/backup_album.interface.dart';
 | 
						|
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
 | 
						|
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
 | 
						|
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
 | 
						|
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
 | 
						|
import 'package:immich_mobile/repositories/album.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/album_api.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/album_media.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/asset.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/auth.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/auth_api.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/backup.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/etag.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/exif_info.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/file_media.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/network.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/partner_api.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/permission.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/user.repository.dart';
 | 
						|
import 'package:immich_mobile/repositories/user_api.repository.dart';
 | 
						|
import 'package:immich_mobile/services/album.service.dart';
 | 
						|
import 'package:immich_mobile/services/api.service.dart';
 | 
						|
import 'package:immich_mobile/services/app_settings.service.dart';
 | 
						|
import 'package:immich_mobile/services/auth.service.dart';
 | 
						|
import 'package:immich_mobile/services/backup.service.dart';
 | 
						|
import 'package:immich_mobile/services/entity.service.dart';
 | 
						|
import 'package:immich_mobile/services/hash.service.dart';
 | 
						|
import 'package:immich_mobile/services/localization.service.dart';
 | 
						|
import 'package:immich_mobile/services/network.service.dart';
 | 
						|
import 'package:immich_mobile/services/sync.service.dart';
 | 
						|
import 'package:immich_mobile/services/user.service.dart';
 | 
						|
import 'package:immich_mobile/utils/backup_progress.dart';
 | 
						|
import 'package:immich_mobile/utils/bootstrap.dart';
 | 
						|
import 'package:immich_mobile/utils/diff.dart';
 | 
						|
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
 | 
						|
import 'package:network_info_plus/network_info_plus.dart';
 | 
						|
import 'package:path_provider_ios/path_provider_ios.dart';
 | 
						|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
 | 
						|
 | 
						|
final backgroundServiceProvider = Provider(
 | 
						|
  (ref) => BackgroundService(),
 | 
						|
);
 | 
						|
 | 
						|
/// Background backup service
 | 
						|
class BackgroundService {
 | 
						|
  static const String _portNameLock = "immichLock";
 | 
						|
  static const MethodChannel _foregroundChannel =
 | 
						|
      MethodChannel('immich/foregroundChannel');
 | 
						|
  static const MethodChannel _backgroundChannel =
 | 
						|
      MethodChannel('immich/backgroundChannel');
 | 
						|
  static const notifyInterval = Duration(milliseconds: 400);
 | 
						|
  bool _isBackgroundInitialized = false;
 | 
						|
  CancellationToken? _cancellationToken;
 | 
						|
  bool _canceledBySystem = false;
 | 
						|
  int _wantsLockTime = 0;
 | 
						|
  bool _hasLock = false;
 | 
						|
  SendPort? _waitingIsolate;
 | 
						|
  ReceivePort? _rp;
 | 
						|
  bool _errorGracePeriodExceeded = true;
 | 
						|
  int _uploadedAssetsCount = 0;
 | 
						|
  int _assetsToUploadCount = 0;
 | 
						|
  String _lastPrintedDetailContent = "";
 | 
						|
  String? _lastPrintedDetailTitle;
 | 
						|
  late final ThrottleProgressUpdate _throttledNotifiy =
 | 
						|
      ThrottleProgressUpdate(_updateProgress, notifyInterval);
 | 
						|
  late final ThrottleProgressUpdate _throttledDetailNotify =
 | 
						|
      ThrottleProgressUpdate(_updateDetailProgress, notifyInterval);
 | 
						|
 | 
						|
  bool get isBackgroundInitialized {
 | 
						|
    return _isBackgroundInitialized;
 | 
						|
  }
 | 
						|
 | 
						|
  /// Ensures that the background service is enqueued if enabled in settings
 | 
						|
  Future<bool> resumeServiceIfEnabled() async {
 | 
						|
    return await isBackgroundBackupEnabled() && await enableService();
 | 
						|
  }
 | 
						|
 | 
						|
  /// Enqueues the background service
 | 
						|
  Future<bool> enableService({bool immediate = false}) async {
 | 
						|
    try {
 | 
						|
      final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
 | 
						|
      final String title =
 | 
						|
          "backup_background_service_default_notification".tr();
 | 
						|
      final bool ok = await _foregroundChannel
 | 
						|
          .invokeMethod('enable', [callback.toRawHandle(), title, immediate]);
 | 
						|
      return ok;
 | 
						|
    } catch (error) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /// Configures the background service
 | 
						|
  Future<bool> configureService({
 | 
						|
    bool requireUnmetered = true,
 | 
						|
    bool requireCharging = false,
 | 
						|
    int triggerUpdateDelay = 5000,
 | 
						|
    int triggerMaxDelay = 50000,
 | 
						|
  }) async {
 | 
						|
    try {
 | 
						|
      final bool ok = await _foregroundChannel.invokeMethod(
 | 
						|
        'configure',
 | 
						|
        [
 | 
						|
          requireUnmetered,
 | 
						|
          requireCharging,
 | 
						|
          triggerUpdateDelay,
 | 
						|
          triggerMaxDelay,
 | 
						|
        ],
 | 
						|
      );
 | 
						|
      return ok;
 | 
						|
    } catch (error) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /// Cancels the background service (if currently running) and removes it from work queue
 | 
						|
  Future<bool> disableService() async {
 | 
						|
    try {
 | 
						|
      final ok = await _foregroundChannel.invokeMethod('disable');
 | 
						|
      return ok;
 | 
						|
    } catch (error) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /// Returns `true` if the background service is enabled
 | 
						|
  Future<bool> isBackgroundBackupEnabled() async {
 | 
						|
    try {
 | 
						|
      return await _foregroundChannel.invokeMethod("isEnabled");
 | 
						|
    } catch (error) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /// Returns `true` if battery optimizations are disabled
 | 
						|
  Future<bool> isIgnoringBatteryOptimizations() async {
 | 
						|
    // iOS does not need battery optimizations enabled
 | 
						|
    if (Platform.isIOS) {
 | 
						|
      return true;
 | 
						|
    }
 | 
						|
    try {
 | 
						|
      return await _foregroundChannel
 | 
						|
          .invokeMethod('isIgnoringBatteryOptimizations');
 | 
						|
    } catch (error) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  // Yet to be implemented
 | 
						|
  Future<Uint8List?> digestFile(String path) {
 | 
						|
    return _foregroundChannel.invokeMethod<Uint8List>("digestFile", [path]);
 | 
						|
  }
 | 
						|
 | 
						|
  Future<List<Uint8List?>?> digestFiles(List<String> paths) {
 | 
						|
    return _foregroundChannel.invokeListMethod<Uint8List?>(
 | 
						|
      "digestFiles",
 | 
						|
      paths,
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  /// Updates the notification shown by the background service
 | 
						|
  Future<bool?> _updateNotification({
 | 
						|
    String? title,
 | 
						|
    String? content,
 | 
						|
    int progress = 0,
 | 
						|
    int max = 0,
 | 
						|
    bool indeterminate = false,
 | 
						|
    bool isDetail = false,
 | 
						|
    bool onlyIfFG = false,
 | 
						|
  }) async {
 | 
						|
    try {
 | 
						|
      if (_isBackgroundInitialized) {
 | 
						|
        return _backgroundChannel.invokeMethod<bool>(
 | 
						|
          'updateNotification',
 | 
						|
          [title, content, progress, max, indeterminate, isDetail, onlyIfFG],
 | 
						|
        );
 | 
						|
      }
 | 
						|
    } catch (error) {
 | 
						|
      debugPrint("[_updateNotification] failed to communicate with plugin");
 | 
						|
    }
 | 
						|
    return false;
 | 
						|
  }
 | 
						|
 | 
						|
  /// Shows a new priority notification
 | 
						|
  Future<bool> _showErrorNotification({
 | 
						|
    required String title,
 | 
						|
    String? content,
 | 
						|
    String? individualTag,
 | 
						|
  }) async {
 | 
						|
    try {
 | 
						|
      if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
 | 
						|
        return await _backgroundChannel
 | 
						|
            .invokeMethod('showError', [title, content, individualTag]);
 | 
						|
      }
 | 
						|
    } catch (error) {
 | 
						|
      debugPrint("[_showErrorNotification] failed to communicate with plugin");
 | 
						|
    }
 | 
						|
    return false;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<bool> _clearErrorNotifications() async {
 | 
						|
    try {
 | 
						|
      if (_isBackgroundInitialized) {
 | 
						|
        return await _backgroundChannel.invokeMethod('clearErrorNotifications');
 | 
						|
      }
 | 
						|
    } catch (error) {
 | 
						|
      debugPrint(
 | 
						|
        "[_clearErrorNotifications] failed to communicate with plugin",
 | 
						|
      );
 | 
						|
    }
 | 
						|
    return false;
 | 
						|
  }
 | 
						|
 | 
						|
  /// await to ensure this thread (foreground or background) has exclusive access
 | 
						|
  Future<bool> acquireLock() async {
 | 
						|
    if (_hasLock) {
 | 
						|
      debugPrint("WARNING: [acquireLock] called more than once");
 | 
						|
      return true;
 | 
						|
    }
 | 
						|
    final int lockTime = Timeline.now;
 | 
						|
    _wantsLockTime = lockTime;
 | 
						|
    final ReceivePort rp = ReceivePort(_portNameLock);
 | 
						|
    _rp = rp;
 | 
						|
    final SendPort sp = rp.sendPort;
 | 
						|
 | 
						|
    while (!IsolateNameServer.registerPortWithName(sp, _portNameLock)) {
 | 
						|
      try {
 | 
						|
        await _checkLockReleasedWithHeartbeat(lockTime);
 | 
						|
      } catch (error) {
 | 
						|
        return false;
 | 
						|
      }
 | 
						|
      if (_wantsLockTime != lockTime) {
 | 
						|
        return false;
 | 
						|
      }
 | 
						|
    }
 | 
						|
    _hasLock = true;
 | 
						|
    rp.listen(_heartbeatListener);
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> _checkLockReleasedWithHeartbeat(final int lockTime) async {
 | 
						|
    SendPort? other = IsolateNameServer.lookupPortByName(_portNameLock);
 | 
						|
    if (other != null) {
 | 
						|
      final ReceivePort tempRp = ReceivePort();
 | 
						|
      final SendPort tempSp = tempRp.sendPort;
 | 
						|
      final bs = tempRp.asBroadcastStream();
 | 
						|
      while (_wantsLockTime == lockTime) {
 | 
						|
        other.send(tempSp);
 | 
						|
        final dynamic answer = await bs.first
 | 
						|
            .timeout(const Duration(seconds: 3), onTimeout: () => null);
 | 
						|
        if (_wantsLockTime != lockTime) {
 | 
						|
          break;
 | 
						|
        }
 | 
						|
        if (answer == null) {
 | 
						|
          // other isolate failed to answer, assuming it exited without releasing the lock
 | 
						|
          if (other == IsolateNameServer.lookupPortByName(_portNameLock)) {
 | 
						|
            IsolateNameServer.removePortNameMapping(_portNameLock);
 | 
						|
          }
 | 
						|
          break;
 | 
						|
        } else if (answer == true) {
 | 
						|
          // other isolate released the lock
 | 
						|
          break;
 | 
						|
        } else if (answer == false) {
 | 
						|
          // other isolate is still active
 | 
						|
        }
 | 
						|
        final dynamic isFinished = await bs.first
 | 
						|
            .timeout(const Duration(seconds: 3), onTimeout: () => false);
 | 
						|
        if (isFinished == true) {
 | 
						|
          break;
 | 
						|
        }
 | 
						|
      }
 | 
						|
      tempRp.close();
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  void _heartbeatListener(dynamic msg) {
 | 
						|
    if (msg is SendPort) {
 | 
						|
      _waitingIsolate = msg;
 | 
						|
      msg.send(false);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /// releases the exclusive access lock
 | 
						|
  void releaseLock() {
 | 
						|
    _wantsLockTime = 0;
 | 
						|
    if (_hasLock) {
 | 
						|
      IsolateNameServer.removePortNameMapping(_portNameLock);
 | 
						|
      _waitingIsolate?.send(true);
 | 
						|
      _waitingIsolate = null;
 | 
						|
      _hasLock = false;
 | 
						|
    }
 | 
						|
    _rp?.close();
 | 
						|
    _rp = null;
 | 
						|
  }
 | 
						|
 | 
						|
  void _setupBackgroundCallHandler() {
 | 
						|
    _backgroundChannel.setMethodCallHandler(_callHandler);
 | 
						|
    _isBackgroundInitialized = true;
 | 
						|
    _backgroundChannel.invokeMethod('initialized');
 | 
						|
  }
 | 
						|
 | 
						|
  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":
 | 
						|
        try {
 | 
						|
          _clearErrorNotifications();
 | 
						|
 | 
						|
          // iOS should time out after some threshold 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;
 | 
						|
          }
 | 
						|
 | 
						|
          final translationsOk = await loadTranslations();
 | 
						|
          if (!translationsOk) {
 | 
						|
            debugPrint("[_callHandler] could not load translations");
 | 
						|
          }
 | 
						|
 | 
						|
          final bool ok = await _onAssetsChanged();
 | 
						|
          return ok;
 | 
						|
        } catch (error) {
 | 
						|
          debugPrint(error.toString());
 | 
						|
          return false;
 | 
						|
        } finally {
 | 
						|
          releaseLock();
 | 
						|
        }
 | 
						|
      case "systemStop":
 | 
						|
        _canceledBySystem = true;
 | 
						|
        _cancellationToken?.cancel();
 | 
						|
        return true;
 | 
						|
      default:
 | 
						|
        debugPrint("Unknown method ${call.method}");
 | 
						|
        return false;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  Future<bool> _onAssetsChanged() async {
 | 
						|
    final db = await Bootstrap.initIsar();
 | 
						|
    await Bootstrap.initDomain(db);
 | 
						|
 | 
						|
    HttpOverrides.global = HttpSSLCertOverride();
 | 
						|
    ApiService apiService = ApiService();
 | 
						|
    apiService.setAccessToken(Store.get(StoreKey.accessToken));
 | 
						|
    AppSettingsService settingsService = AppSettingsService();
 | 
						|
    AlbumRepository albumRepository = AlbumRepository(db);
 | 
						|
    AssetRepository assetRepository = AssetRepository(db);
 | 
						|
    BackupAlbumRepository backupRepository = BackupAlbumRepository(db);
 | 
						|
    ExifInfoRepository exifInfoRepository = ExifInfoRepository(db);
 | 
						|
    ETagRepository eTagRepository = ETagRepository(db);
 | 
						|
    AlbumMediaRepository albumMediaRepository = AlbumMediaRepository();
 | 
						|
    FileMediaRepository fileMediaRepository = FileMediaRepository();
 | 
						|
    AssetMediaRepository assetMediaRepository = AssetMediaRepository();
 | 
						|
    UserRepository userRepository = UserRepository(db);
 | 
						|
    UserApiRepository userApiRepository =
 | 
						|
        UserApiRepository(apiService.usersApi);
 | 
						|
    AlbumApiRepository albumApiRepository =
 | 
						|
        AlbumApiRepository(apiService.albumsApi);
 | 
						|
    PartnerApiRepository partnerApiRepository =
 | 
						|
        PartnerApiRepository(apiService.partnersApi);
 | 
						|
    HashService hashService =
 | 
						|
        HashService(assetRepository, this, albumMediaRepository);
 | 
						|
    EntityService entityService =
 | 
						|
        EntityService(assetRepository, userRepository);
 | 
						|
    SyncService syncSerive = SyncService(
 | 
						|
      hashService,
 | 
						|
      entityService,
 | 
						|
      albumMediaRepository,
 | 
						|
      albumApiRepository,
 | 
						|
      albumRepository,
 | 
						|
      assetRepository,
 | 
						|
      exifInfoRepository,
 | 
						|
      userRepository,
 | 
						|
      eTagRepository,
 | 
						|
    );
 | 
						|
    UserService userService = UserService(
 | 
						|
      partnerApiRepository,
 | 
						|
      userApiRepository,
 | 
						|
      userRepository,
 | 
						|
      syncSerive,
 | 
						|
    );
 | 
						|
    AlbumService albumService = AlbumService(
 | 
						|
      userService,
 | 
						|
      syncSerive,
 | 
						|
      entityService,
 | 
						|
      albumRepository,
 | 
						|
      assetRepository,
 | 
						|
      backupRepository,
 | 
						|
      albumMediaRepository,
 | 
						|
      albumApiRepository,
 | 
						|
    );
 | 
						|
    BackupService backupService = BackupService(
 | 
						|
      apiService,
 | 
						|
      settingsService,
 | 
						|
      albumService,
 | 
						|
      albumMediaRepository,
 | 
						|
      fileMediaRepository,
 | 
						|
      assetRepository,
 | 
						|
      assetMediaRepository,
 | 
						|
    );
 | 
						|
 | 
						|
    AuthApiRepository authApiRepository = AuthApiRepository(apiService);
 | 
						|
    AuthRepository authRepository = AuthRepository(db);
 | 
						|
    NetworkRepository networkRepository = NetworkRepository(NetworkInfo());
 | 
						|
    PermissionRepository permissionRepository = PermissionRepository();
 | 
						|
    NetworkService networkService =
 | 
						|
        NetworkService(networkRepository, permissionRepository);
 | 
						|
    AuthService authService = AuthService(
 | 
						|
      authApiRepository,
 | 
						|
      authRepository,
 | 
						|
      apiService,
 | 
						|
      networkService,
 | 
						|
    );
 | 
						|
 | 
						|
    final endpoint = await authService.setOpenApiServiceEndpoint();
 | 
						|
    if (kDebugMode) {
 | 
						|
      debugPrint("[BG UPLOAD] Using endpoint: $endpoint");
 | 
						|
    }
 | 
						|
 | 
						|
    final selectedAlbums =
 | 
						|
        await backupRepository.getAllBySelection(BackupSelection.select);
 | 
						|
    final excludedAlbums =
 | 
						|
        await backupRepository.getAllBySelection(BackupSelection.exclude);
 | 
						|
    if (selectedAlbums.isEmpty) {
 | 
						|
      return true;
 | 
						|
    }
 | 
						|
 | 
						|
    await fileMediaRepository.enableBackgroundAccess();
 | 
						|
 | 
						|
    do {
 | 
						|
      final bool backupOk = await _runBackup(
 | 
						|
        backupService,
 | 
						|
        settingsService,
 | 
						|
        selectedAlbums,
 | 
						|
        excludedAlbums,
 | 
						|
      );
 | 
						|
      if (backupOk) {
 | 
						|
        await Store.delete(StoreKey.backupFailedSince);
 | 
						|
        final backupAlbums = [...selectedAlbums, ...excludedAlbums];
 | 
						|
        backupAlbums.sortBy((e) => e.id);
 | 
						|
 | 
						|
        final dbAlbums =
 | 
						|
            await backupRepository.getAll(sort: BackupAlbumSort.id);
 | 
						|
        final List<int> toDelete = [];
 | 
						|
        final List<BackupAlbum> toUpsert = [];
 | 
						|
        // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state
 | 
						|
        diffSortedListsSync(
 | 
						|
          dbAlbums,
 | 
						|
          backupAlbums,
 | 
						|
          compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
 | 
						|
          both: (BackupAlbum a, BackupAlbum b) {
 | 
						|
            a.lastBackup = a.lastBackup.isAfter(b.lastBackup)
 | 
						|
                ? a.lastBackup
 | 
						|
                : b.lastBackup;
 | 
						|
            toUpsert.add(a);
 | 
						|
            return true;
 | 
						|
          },
 | 
						|
          onlyFirst: (BackupAlbum a) => toUpsert.add(a),
 | 
						|
          onlySecond: (BackupAlbum b) => toDelete.add(b.isarId),
 | 
						|
        );
 | 
						|
        await backupRepository.deleteAll(toDelete);
 | 
						|
        await backupRepository.updateAll(toUpsert);
 | 
						|
      } else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
 | 
						|
        Store.put(StoreKey.backupFailedSince, DateTime.now());
 | 
						|
        return false;
 | 
						|
      }
 | 
						|
      // Android should check for new assets added while performing backup
 | 
						|
    } while (Platform.isAndroid &&
 | 
						|
        true ==
 | 
						|
            await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<bool> _runBackup(
 | 
						|
    BackupService backupService,
 | 
						|
    AppSettingsService settingsService,
 | 
						|
    List<BackupAlbum> selectedAlbums,
 | 
						|
    List<BackupAlbum> excludedAlbums,
 | 
						|
  ) async {
 | 
						|
    _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
 | 
						|
    final bool notifyTotalProgress = settingsService
 | 
						|
        .getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
 | 
						|
    final bool notifySingleProgress = settingsService
 | 
						|
        .getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
 | 
						|
 | 
						|
    if (_canceledBySystem) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    Set<BackupCandidate> toUpload = await backupService.buildUploadCandidates(
 | 
						|
      selectedAlbums,
 | 
						|
      excludedAlbums,
 | 
						|
    );
 | 
						|
 | 
						|
    try {
 | 
						|
      toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
 | 
						|
    } catch (e) {
 | 
						|
      _showErrorNotification(
 | 
						|
        title: "backup_background_service_error_title".tr(),
 | 
						|
        content: "backup_background_service_connection_failed_message".tr(),
 | 
						|
      );
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    if (_canceledBySystem) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    if (toUpload.isEmpty) {
 | 
						|
      return true;
 | 
						|
    }
 | 
						|
    _assetsToUploadCount = toUpload.length;
 | 
						|
    _uploadedAssetsCount = 0;
 | 
						|
    _updateNotification(
 | 
						|
      title: "backup_background_service_in_progress_notification".tr(),
 | 
						|
      content: notifyTotalProgress
 | 
						|
          ? formatAssetBackupProgress(
 | 
						|
              _uploadedAssetsCount,
 | 
						|
              _assetsToUploadCount,
 | 
						|
            )
 | 
						|
          : null,
 | 
						|
      progress: 0,
 | 
						|
      max: notifyTotalProgress ? _assetsToUploadCount : 0,
 | 
						|
      indeterminate: !notifyTotalProgress,
 | 
						|
      onlyIfFG: !notifyTotalProgress,
 | 
						|
    );
 | 
						|
 | 
						|
    _cancellationToken = CancellationToken();
 | 
						|
    final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
 | 
						|
 | 
						|
    final bool ok = await backupService.backupAsset(
 | 
						|
      toUpload,
 | 
						|
      _cancellationToken!,
 | 
						|
      pmProgressHandler: pmProgressHandler,
 | 
						|
      onSuccess: (result) => _onAssetUploaded(
 | 
						|
        result: result,
 | 
						|
        shouldNotify: notifyTotalProgress,
 | 
						|
      ),
 | 
						|
      onProgress: (bytes, totalBytes) =>
 | 
						|
          _onProgress(bytes, totalBytes, shouldNotify: notifySingleProgress),
 | 
						|
      onCurrentAsset: (asset) =>
 | 
						|
          _onSetCurrentBackupAsset(asset, shouldNotify: notifySingleProgress),
 | 
						|
      onError: _onBackupError,
 | 
						|
      isBackground: true,
 | 
						|
    );
 | 
						|
 | 
						|
    if (!ok && !_cancellationToken!.isCancelled) {
 | 
						|
      _showErrorNotification(
 | 
						|
        title: "backup_background_service_error_title".tr(),
 | 
						|
        content: "backup_background_service_backup_failed_message".tr(),
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    return ok;
 | 
						|
  }
 | 
						|
 | 
						|
  void _onAssetUploaded({
 | 
						|
    required SuccessUploadAsset result,
 | 
						|
    bool shouldNotify = false,
 | 
						|
  }) async {
 | 
						|
    if (!shouldNotify) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    _uploadedAssetsCount++;
 | 
						|
    _throttledNotifiy();
 | 
						|
  }
 | 
						|
 | 
						|
  void _onProgress(int bytes, int totalBytes, {bool shouldNotify = false}) {
 | 
						|
    if (!shouldNotify) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    _throttledDetailNotify(progress: bytes, total: totalBytes);
 | 
						|
  }
 | 
						|
 | 
						|
  void _updateDetailProgress(String? title, int progress, int total) {
 | 
						|
    final String msg =
 | 
						|
        total > 0 ? humanReadableBytesProgress(progress, total) : "";
 | 
						|
    // only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
 | 
						|
    if (msg != _lastPrintedDetailContent || _lastPrintedDetailTitle != title) {
 | 
						|
      _lastPrintedDetailContent = msg;
 | 
						|
      _lastPrintedDetailTitle = title;
 | 
						|
      _updateNotification(
 | 
						|
        progress: total > 0 ? (progress * 1000) ~/ total : 0,
 | 
						|
        max: 1000,
 | 
						|
        isDetail: true,
 | 
						|
        title: title,
 | 
						|
        content: msg,
 | 
						|
      );
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  void _updateProgress(String? title, int progress, int total) {
 | 
						|
    _updateNotification(
 | 
						|
      progress: _uploadedAssetsCount,
 | 
						|
      max: _assetsToUploadCount,
 | 
						|
      title: title,
 | 
						|
      content: formatAssetBackupProgress(
 | 
						|
        _uploadedAssetsCount,
 | 
						|
        _assetsToUploadCount,
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  void _onBackupError(ErrorUploadAsset errorAssetInfo) {
 | 
						|
    _showErrorNotification(
 | 
						|
      title: "backup_background_service_upload_failure_notification"
 | 
						|
          .tr(args: [errorAssetInfo.fileName]),
 | 
						|
      individualTag: errorAssetInfo.id,
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  void _onSetCurrentBackupAsset(
 | 
						|
    CurrentUploadAsset currentUploadAsset, {
 | 
						|
    bool shouldNotify = false,
 | 
						|
  }) {
 | 
						|
    if (!shouldNotify) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    _throttledDetailNotify.title =
 | 
						|
        "backup_background_service_current_upload_notification"
 | 
						|
            .tr(args: [currentUploadAsset.fileName]);
 | 
						|
    _throttledDetailNotify.progress = 0;
 | 
						|
    _throttledDetailNotify.total = 0;
 | 
						|
  }
 | 
						|
 | 
						|
  bool _isErrorGracePeriodExceeded(AppSettingsService appSettingsService) {
 | 
						|
    final int value = appSettingsService
 | 
						|
        .getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
 | 
						|
    if (value == 0) {
 | 
						|
      return true;
 | 
						|
    } else if (value == 5) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
    final DateTime? failedSince = Store.tryGet(StoreKey.backupFailedSince);
 | 
						|
    if (failedSince == null) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
    final Duration duration = DateTime.now().difference(failedSince);
 | 
						|
    if (value == 1) {
 | 
						|
      return duration > const Duration(minutes: 30);
 | 
						|
    } else if (value == 2) {
 | 
						|
      return duration > const Duration(hours: 2);
 | 
						|
    } else if (value == 3) {
 | 
						|
      return duration > const Duration(hours: 8);
 | 
						|
    } else if (value == 4) {
 | 
						|
      return duration > const Duration(hours: 24);
 | 
						|
    }
 | 
						|
    assert(false, "Invalid value");
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<DateTime?> getIOSBackupLastRun(IosBackgroundTask task) async {
 | 
						|
    if (!Platform.isIOS) {
 | 
						|
      return null;
 | 
						|
    }
 | 
						|
    // 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 {
 | 
						|
    if (!Platform.isIOS) {
 | 
						|
      return 0;
 | 
						|
    }
 | 
						|
    return await _foregroundChannel.invokeMethod('numberOfBackgroundProcesses');
 | 
						|
  }
 | 
						|
 | 
						|
  Future<bool> getIOSBackgroundAppRefreshEnabled() async {
 | 
						|
    if (!Platform.isIOS) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
    return await _foregroundChannel.invokeMethod('backgroundAppRefreshEnabled');
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
enum IosBackgroundTask { fetch, processing }
 | 
						|
 | 
						|
/// entry point called by Kotlin/Java code; needs to be a top-level function
 | 
						|
@pragma('vm:entry-point')
 | 
						|
void _nativeEntry() {
 | 
						|
  WidgetsFlutterBinding.ensureInitialized();
 | 
						|
  DartPluginRegistrant.ensureInitialized();
 | 
						|
  BackgroundService backgroundService = BackgroundService();
 | 
						|
  backgroundService._setupBackgroundCallHandler();
 | 
						|
}
 |