background.service.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. import 'dart:async';
  2. import 'dart:developer';
  3. import 'dart:io';
  4. import 'dart:isolate';
  5. import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
  6. import 'package:cancellation_token_http/http.dart';
  7. import 'package:collection/collection.dart';
  8. import 'package:easy_localization/easy_localization.dart';
  9. import 'package:flutter/services.dart';
  10. import 'package:flutter/widgets.dart';
  11. import 'package:hooks_riverpod/hooks_riverpod.dart';
  12. import 'package:immich_mobile/main.dart';
  13. import 'package:immich_mobile/modules/backup/background_service/localization.dart';
  14. import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
  15. import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
  16. import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
  17. import 'package:immich_mobile/modules/backup/services/backup.service.dart';
  18. import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
  19. import 'package:immich_mobile/shared/models/store.dart';
  20. import 'package:immich_mobile/shared/services/api.service.dart';
  21. import 'package:immich_mobile/utils/backup_progress.dart';
  22. import 'package:immich_mobile/utils/diff.dart';
  23. import 'package:isar/isar.dart';
  24. import 'package:path_provider_ios/path_provider_ios.dart';
  25. import 'package:photo_manager/photo_manager.dart';
  26. final backgroundServiceProvider = Provider(
  27. (ref) => BackgroundService(),
  28. );
  29. /// Background backup service
  30. class BackgroundService {
  31. static const String _portNameLock = "immichLock";
  32. static const MethodChannel _foregroundChannel =
  33. MethodChannel('immich/foregroundChannel');
  34. static const MethodChannel _backgroundChannel =
  35. MethodChannel('immich/backgroundChannel');
  36. static const notifyInterval = Duration(milliseconds: 400);
  37. bool _isBackgroundInitialized = false;
  38. CancellationToken? _cancellationToken;
  39. bool _canceledBySystem = false;
  40. int _wantsLockTime = 0;
  41. bool _hasLock = false;
  42. SendPort? _waitingIsolate;
  43. ReceivePort? _rp;
  44. bool _errorGracePeriodExceeded = true;
  45. int _uploadedAssetsCount = 0;
  46. int _assetsToUploadCount = 0;
  47. String _lastPrintedDetailContent = "";
  48. String? _lastPrintedDetailTitle;
  49. late final ThrottleProgressUpdate _throttledNotifiy =
  50. ThrottleProgressUpdate(_updateProgress, notifyInterval);
  51. late final ThrottleProgressUpdate _throttledDetailNotify =
  52. ThrottleProgressUpdate(_updateDetailProgress, notifyInterval);
  53. bool get isBackgroundInitialized {
  54. return _isBackgroundInitialized;
  55. }
  56. /// Ensures that the background service is enqueued if enabled in settings
  57. Future<bool> resumeServiceIfEnabled() async {
  58. return await isBackgroundBackupEnabled() && await enableService();
  59. }
  60. /// Enqueues the background service
  61. Future<bool> enableService({bool immediate = false}) async {
  62. try {
  63. final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
  64. final String title =
  65. "backup_background_service_default_notification".tr();
  66. final bool ok = await _foregroundChannel
  67. .invokeMethod('enable', [callback.toRawHandle(), title, immediate]);
  68. return ok;
  69. } catch (error) {
  70. return false;
  71. }
  72. }
  73. /// Configures the background service
  74. Future<bool> configureService({
  75. bool requireUnmetered = true,
  76. bool requireCharging = false,
  77. int triggerUpdateDelay = 5000,
  78. int triggerMaxDelay = 50000,
  79. }) async {
  80. try {
  81. final bool ok = await _foregroundChannel.invokeMethod(
  82. 'configure',
  83. [
  84. requireUnmetered,
  85. requireCharging,
  86. triggerUpdateDelay,
  87. triggerMaxDelay,
  88. ],
  89. );
  90. return ok;
  91. } catch (error) {
  92. return false;
  93. }
  94. }
  95. /// Cancels the background service (if currently running) and removes it from work queue
  96. Future<bool> disableService() async {
  97. try {
  98. final ok = await _foregroundChannel.invokeMethod('disable');
  99. return ok;
  100. } catch (error) {
  101. return false;
  102. }
  103. }
  104. /// Returns `true` if the background service is enabled
  105. Future<bool> isBackgroundBackupEnabled() async {
  106. try {
  107. return await _foregroundChannel.invokeMethod("isEnabled");
  108. } catch (error) {
  109. return false;
  110. }
  111. }
  112. /// Returns `true` if battery optimizations are disabled
  113. Future<bool> isIgnoringBatteryOptimizations() async {
  114. // iOS does not need battery optimizations enabled
  115. if (Platform.isIOS) {
  116. return true;
  117. }
  118. try {
  119. return await _foregroundChannel
  120. .invokeMethod('isIgnoringBatteryOptimizations');
  121. } catch (error) {
  122. return false;
  123. }
  124. }
  125. Future<Uint8List?> digestFile(String path) {
  126. return _foregroundChannel.invokeMethod<Uint8List>("digestFile", [path]);
  127. }
  128. Future<List<Uint8List?>?> digestFiles(List<String> paths) {
  129. return _foregroundChannel.invokeListMethod<Uint8List?>(
  130. "digestFiles",
  131. paths,
  132. );
  133. }
  134. /// Updates the notification shown by the background service
  135. Future<bool?> _updateNotification({
  136. String? title,
  137. String? content,
  138. int progress = 0,
  139. int max = 0,
  140. bool indeterminate = false,
  141. bool isDetail = false,
  142. bool onlyIfFG = false,
  143. }) async {
  144. try {
  145. if (_isBackgroundInitialized) {
  146. return _backgroundChannel.invokeMethod<bool>(
  147. 'updateNotification',
  148. [title, content, progress, max, indeterminate, isDetail, onlyIfFG],
  149. );
  150. }
  151. } catch (error) {
  152. debugPrint("[_updateNotification] failed to communicate with plugin");
  153. }
  154. return false;
  155. }
  156. /// Shows a new priority notification
  157. Future<bool> _showErrorNotification({
  158. required String title,
  159. String? content,
  160. String? individualTag,
  161. }) async {
  162. try {
  163. if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
  164. return await _backgroundChannel
  165. .invokeMethod('showError', [title, content, individualTag]);
  166. }
  167. } catch (error) {
  168. debugPrint("[_showErrorNotification] failed to communicate with plugin");
  169. }
  170. return false;
  171. }
  172. Future<bool> _clearErrorNotifications() async {
  173. try {
  174. if (_isBackgroundInitialized) {
  175. return await _backgroundChannel.invokeMethod('clearErrorNotifications');
  176. }
  177. } catch (error) {
  178. debugPrint(
  179. "[_clearErrorNotifications] failed to communicate with plugin",
  180. );
  181. }
  182. return false;
  183. }
  184. /// await to ensure this thread (foreground or background) has exclusive access
  185. Future<bool> acquireLock() async {
  186. if (_hasLock) {
  187. debugPrint("WARNING: [acquireLock] called more than once");
  188. return true;
  189. }
  190. final int lockTime = Timeline.now;
  191. _wantsLockTime = lockTime;
  192. final ReceivePort rp = ReceivePort(_portNameLock);
  193. _rp = rp;
  194. final SendPort sp = rp.sendPort;
  195. while (!IsolateNameServer.registerPortWithName(sp, _portNameLock)) {
  196. try {
  197. await _checkLockReleasedWithHeartbeat(lockTime);
  198. } catch (error) {
  199. return false;
  200. }
  201. if (_wantsLockTime != lockTime) {
  202. return false;
  203. }
  204. }
  205. _hasLock = true;
  206. rp.listen(_heartbeatListener);
  207. return true;
  208. }
  209. Future<void> _checkLockReleasedWithHeartbeat(final int lockTime) async {
  210. SendPort? other = IsolateNameServer.lookupPortByName(_portNameLock);
  211. if (other != null) {
  212. final ReceivePort tempRp = ReceivePort();
  213. final SendPort tempSp = tempRp.sendPort;
  214. final bs = tempRp.asBroadcastStream();
  215. while (_wantsLockTime == lockTime) {
  216. other.send(tempSp);
  217. final dynamic answer = await bs.first
  218. .timeout(const Duration(seconds: 3), onTimeout: () => null);
  219. if (_wantsLockTime != lockTime) {
  220. break;
  221. }
  222. if (answer == null) {
  223. // other isolate failed to answer, assuming it exited without releasing the lock
  224. if (other == IsolateNameServer.lookupPortByName(_portNameLock)) {
  225. IsolateNameServer.removePortNameMapping(_portNameLock);
  226. }
  227. break;
  228. } else if (answer == true) {
  229. // other isolate released the lock
  230. break;
  231. } else if (answer == false) {
  232. // other isolate is still active
  233. }
  234. final dynamic isFinished = await bs.first
  235. .timeout(const Duration(seconds: 3), onTimeout: () => false);
  236. if (isFinished == true) {
  237. break;
  238. }
  239. }
  240. tempRp.close();
  241. }
  242. }
  243. void _heartbeatListener(dynamic msg) {
  244. if (msg is SendPort) {
  245. _waitingIsolate = msg;
  246. msg.send(false);
  247. }
  248. }
  249. /// releases the exclusive access lock
  250. void releaseLock() {
  251. _wantsLockTime = 0;
  252. if (_hasLock) {
  253. IsolateNameServer.removePortNameMapping(_portNameLock);
  254. _waitingIsolate?.send(true);
  255. _waitingIsolate = null;
  256. _hasLock = false;
  257. }
  258. _rp?.close();
  259. _rp = null;
  260. }
  261. void _setupBackgroundCallHandler() {
  262. _backgroundChannel.setMethodCallHandler(_callHandler);
  263. _isBackgroundInitialized = true;
  264. _backgroundChannel.invokeMethod('initialized');
  265. }
  266. Future<bool> _callHandler(MethodCall call) async {
  267. DartPluginRegistrant.ensureInitialized();
  268. if (Platform.isIOS) {
  269. // NOTE: I'm not sure this is strictly necessary anymore, but
  270. // out of an abundance of caution, we will keep it in until someone
  271. // can say for sure
  272. PathProviderIOS.registerWith();
  273. }
  274. switch (call.method) {
  275. case "backgroundProcessing":
  276. case "onAssetsChanged":
  277. try {
  278. _clearErrorNotifications();
  279. // iOS should time out after some threshhold so it doesn't wait
  280. // indefinitely and can run later
  281. // Android is fine to wait here until the lock releases
  282. final waitForLock = Platform.isIOS
  283. ? acquireLock().timeout(
  284. const Duration(seconds: 5),
  285. onTimeout: () => false,
  286. )
  287. : acquireLock();
  288. final bool hasAccess = await waitForLock;
  289. if (!hasAccess) {
  290. debugPrint("[_callHandler] could not acquire lock, exiting");
  291. return false;
  292. }
  293. final translationsOk = await loadTranslations();
  294. if (!translationsOk) {
  295. debugPrint("[_callHandler] could not load translations");
  296. }
  297. final bool ok = await _onAssetsChanged();
  298. return ok;
  299. } catch (error) {
  300. debugPrint(error.toString());
  301. return false;
  302. } finally {
  303. releaseLock();
  304. }
  305. case "systemStop":
  306. _canceledBySystem = true;
  307. _cancellationToken?.cancel();
  308. return true;
  309. default:
  310. debugPrint("Unknown method ${call.method}");
  311. return false;
  312. }
  313. }
  314. Future<bool> _onAssetsChanged() async {
  315. final Isar db = await loadDb();
  316. ApiService apiService = ApiService();
  317. apiService.setAccessToken(Store.get(StoreKey.accessToken));
  318. BackupService backupService = BackupService(apiService, db);
  319. AppSettingsService settingsService = AppSettingsService();
  320. final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
  321. final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
  322. if (selectedAlbums.isEmpty) {
  323. return true;
  324. }
  325. await PhotoManager.setIgnorePermissionCheck(true);
  326. do {
  327. final bool backupOk = await _runBackup(
  328. backupService,
  329. settingsService,
  330. selectedAlbums,
  331. excludedAlbums,
  332. );
  333. if (backupOk) {
  334. await Store.delete(StoreKey.backupFailedSince);
  335. final backupAlbums = [...selectedAlbums, ...excludedAlbums];
  336. backupAlbums.sortBy((e) => e.id);
  337. db.writeTxnSync(() {
  338. final dbAlbums = db.backupAlbums.where().sortById().findAllSync();
  339. final List<int> toDelete = [];
  340. final List<BackupAlbum> toUpsert = [];
  341. // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state
  342. diffSortedListsSync(
  343. dbAlbums,
  344. backupAlbums,
  345. compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
  346. both: (BackupAlbum a, BackupAlbum b) {
  347. a.lastBackup = a.lastBackup.isAfter(b.lastBackup)
  348. ? a.lastBackup
  349. : b.lastBackup;
  350. toUpsert.add(a);
  351. return true;
  352. },
  353. onlyFirst: (BackupAlbum a) => toUpsert.add(a),
  354. onlySecond: (BackupAlbum b) => toDelete.add(b.isarId),
  355. );
  356. db.backupAlbums.deleteAllSync(toDelete);
  357. db.backupAlbums.putAllSync(toUpsert);
  358. });
  359. } else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
  360. Store.put(StoreKey.backupFailedSince, DateTime.now());
  361. return false;
  362. }
  363. // Android should check for new assets added while performing backup
  364. } while (Platform.isAndroid &&
  365. true ==
  366. await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
  367. return true;
  368. }
  369. Future<bool> _runBackup(
  370. BackupService backupService,
  371. AppSettingsService settingsService,
  372. List<BackupAlbum> selectedAlbums,
  373. List<BackupAlbum> excludedAlbums,
  374. ) async {
  375. _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
  376. final bool notifyTotalProgress = settingsService
  377. .getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
  378. final bool notifySingleProgress = settingsService
  379. .getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
  380. if (_canceledBySystem) {
  381. return false;
  382. }
  383. List<AssetEntity> toUpload = await backupService.buildUploadCandidates(
  384. selectedAlbums,
  385. excludedAlbums,
  386. );
  387. try {
  388. toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
  389. } catch (e) {
  390. _showErrorNotification(
  391. title: "backup_background_service_error_title".tr(),
  392. content: "backup_background_service_connection_failed_message".tr(),
  393. );
  394. return false;
  395. }
  396. if (_canceledBySystem) {
  397. return false;
  398. }
  399. if (toUpload.isEmpty) {
  400. return true;
  401. }
  402. _assetsToUploadCount = toUpload.length;
  403. _uploadedAssetsCount = 0;
  404. _updateNotification(
  405. title: "backup_background_service_in_progress_notification".tr(),
  406. content: notifyTotalProgress
  407. ? formatAssetBackupProgress(
  408. _uploadedAssetsCount,
  409. _assetsToUploadCount,
  410. )
  411. : null,
  412. progress: 0,
  413. max: notifyTotalProgress ? _assetsToUploadCount : 0,
  414. indeterminate: !notifyTotalProgress,
  415. onlyIfFG: !notifyTotalProgress,
  416. );
  417. _cancellationToken = CancellationToken();
  418. final bool ok = await backupService.backupAsset(
  419. toUpload,
  420. _cancellationToken!,
  421. notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {},
  422. notifySingleProgress ? _onProgress : (sent, total) {},
  423. notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
  424. _onBackupError,
  425. );
  426. if (!ok && !_cancellationToken!.isCancelled) {
  427. _showErrorNotification(
  428. title: "backup_background_service_error_title".tr(),
  429. content: "backup_background_service_backup_failed_message".tr(),
  430. );
  431. }
  432. return ok;
  433. }
  434. void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) {
  435. _uploadedAssetsCount++;
  436. _throttledNotifiy();
  437. }
  438. void _onProgress(int sent, int total) {
  439. _throttledDetailNotify(progress: sent, total: total);
  440. }
  441. void _updateDetailProgress(String? title, int progress, int total) {
  442. final String msg =
  443. total > 0 ? humanReadableBytesProgress(progress, total) : "";
  444. // only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
  445. if (msg != _lastPrintedDetailContent || _lastPrintedDetailTitle != title) {
  446. _lastPrintedDetailContent = msg;
  447. _lastPrintedDetailTitle = title;
  448. _updateNotification(
  449. progress: total > 0 ? (progress * 1000) ~/ total : 0,
  450. max: 1000,
  451. isDetail: true,
  452. title: title,
  453. content: msg,
  454. );
  455. }
  456. }
  457. void _updateProgress(String? title, int progress, int total) {
  458. _updateNotification(
  459. progress: _uploadedAssetsCount,
  460. max: _assetsToUploadCount,
  461. title: title,
  462. content: formatAssetBackupProgress(
  463. _uploadedAssetsCount,
  464. _assetsToUploadCount,
  465. ),
  466. );
  467. }
  468. void _onBackupError(ErrorUploadAsset errorAssetInfo) {
  469. _showErrorNotification(
  470. title: "backup_background_service_upload_failure_notification"
  471. .tr(args: [errorAssetInfo.fileName]),
  472. individualTag: errorAssetInfo.id,
  473. );
  474. }
  475. void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
  476. _throttledDetailNotify.title =
  477. "backup_background_service_current_upload_notification"
  478. .tr(args: [currentUploadAsset.fileName]);
  479. _throttledDetailNotify.progress = 0;
  480. _throttledDetailNotify.total = 0;
  481. }
  482. bool _isErrorGracePeriodExceeded(AppSettingsService appSettingsService) {
  483. final int value = appSettingsService
  484. .getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
  485. if (value == 0) {
  486. return true;
  487. } else if (value == 5) {
  488. return false;
  489. }
  490. final DateTime? failedSince = Store.tryGet(StoreKey.backupFailedSince);
  491. if (failedSince == null) {
  492. return false;
  493. }
  494. final Duration duration = DateTime.now().difference(failedSince);
  495. if (value == 1) {
  496. return duration > const Duration(minutes: 30);
  497. } else if (value == 2) {
  498. return duration > const Duration(hours: 2);
  499. } else if (value == 3) {
  500. return duration > const Duration(hours: 8);
  501. } else if (value == 4) {
  502. return duration > const Duration(hours: 24);
  503. }
  504. assert(false, "Invalid value");
  505. return true;
  506. }
  507. Future<DateTime?> getIOSBackupLastRun(IosBackgroundTask task) async {
  508. if (!Platform.isIOS) {
  509. return null;
  510. }
  511. // Seconds since last run
  512. final double? lastRun = task == IosBackgroundTask.fetch
  513. ? await _foregroundChannel.invokeMethod('lastBackgroundFetchTime')
  514. : await _foregroundChannel.invokeMethod('lastBackgroundProcessingTime');
  515. if (lastRun == null) {
  516. return null;
  517. }
  518. final time = DateTime.fromMillisecondsSinceEpoch(lastRun.toInt() * 1000);
  519. return time;
  520. }
  521. Future<int> getIOSBackupNumberOfProcesses() async {
  522. if (!Platform.isIOS) {
  523. return 0;
  524. }
  525. return await _foregroundChannel.invokeMethod('numberOfBackgroundProcesses');
  526. }
  527. Future<bool> getIOSBackgroundAppRefreshEnabled() async {
  528. if (!Platform.isIOS) {
  529. return false;
  530. }
  531. return await _foregroundChannel.invokeMethod('backgroundAppRefreshEnabled');
  532. }
  533. }
  534. enum IosBackgroundTask { fetch, processing }
  535. /// entry point called by Kotlin/Java code; needs to be a top-level function
  536. @pragma('vm:entry-point')
  537. void _nativeEntry() {
  538. WidgetsFlutterBinding.ensureInitialized();
  539. DartPluginRegistrant.ensureInitialized();
  540. BackgroundService backgroundService = BackgroundService();
  541. backgroundService._setupBackgroundCallHandler();
  542. }