remote_sync_service.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import 'dart:async';
  2. import 'dart:io';
  3. import 'dart:math';
  4. import 'package:logging/logging.dart';
  5. import 'package:photos/core/configuration.dart';
  6. import 'package:photos/core/errors.dart';
  7. import 'package:photos/core/event_bus.dart';
  8. import 'package:photos/db/files_db.dart';
  9. import 'package:photos/events/collection_updated_event.dart';
  10. import 'package:photos/events/files_updated_event.dart';
  11. import 'package:photos/events/force_reload_home_gallery_event.dart';
  12. import 'package:photos/events/local_photos_updated_event.dart';
  13. import 'package:photos/events/sync_status_update_event.dart';
  14. import 'package:photos/models/file.dart';
  15. import 'package:photos/models/file_type.dart';
  16. import 'package:photos/services/app_lifecycle_service.dart';
  17. import 'package:photos/services/collections_service.dart';
  18. import 'package:photos/services/feature_flag_service.dart';
  19. import 'package:photos/services/file_migration_service.dart';
  20. import 'package:photos/services/ignored_files_service.dart';
  21. import 'package:photos/services/local_sync_service.dart';
  22. import 'package:photos/services/trash_sync_service.dart';
  23. import 'package:photos/utils/diff_fetcher.dart';
  24. import 'package:photos/utils/file_uploader.dart';
  25. import 'package:photos/utils/file_util.dart';
  26. import 'package:shared_preferences/shared_preferences.dart';
  27. class RemoteSyncService {
  28. final _logger = Logger("RemoteSyncService");
  29. final _db = FilesDB.instance;
  30. final _uploader = FileUploader.instance;
  31. final _collectionsService = CollectionsService.instance;
  32. final _diffFetcher = DiffFetcher();
  33. final FileMigrationService _fileMigrationService =
  34. FileMigrationService.instance;
  35. int _completedUploads = 0;
  36. SharedPreferences _prefs;
  37. Completer<void> _existingSync;
  38. static const kHasSyncedArchiveKey = "has_synced_archive";
  39. // 28 Sept, 2021 9:03:20 AM IST
  40. static const kArchiveFeatureReleaseTime = 1632800000000000;
  41. static const kHasSyncedEditTime = "has_synced_edit_time";
  42. // 29 October, 2021 3:56:40 AM IST
  43. static const kEditTimeFeatureReleaseTime = 1635460000000000;
  44. static const kMaximumPermissibleUploadsInThrottledMode = 4;
  45. static final RemoteSyncService instance =
  46. RemoteSyncService._privateConstructor();
  47. RemoteSyncService._privateConstructor();
  48. Future<void> init() async {
  49. _prefs = await SharedPreferences.getInstance();
  50. Bus.instance.on<LocalPhotosUpdatedEvent>().listen((event) async {
  51. if (event.type == EventType.addedOrUpdated) {
  52. if (_existingSync == null) {
  53. sync();
  54. }
  55. }
  56. });
  57. }
  58. Future<void> sync({bool silently = false}) async {
  59. if (!Configuration.instance.hasConfiguredAccount()) {
  60. _logger.info("Skipping remote sync since account is not configured");
  61. return;
  62. }
  63. if (_existingSync != null) {
  64. _logger.info("Remote sync already in progress, skipping");
  65. return _existingSync.future;
  66. }
  67. _existingSync = Completer<void>();
  68. try {
  69. await _pullDiff(silently);
  70. // sync trash but consume error during initial launch.
  71. // this is to ensure that we don't pause upload due to any error during
  72. // the trash sync. Impact: We may end up re-uploading a file which was
  73. // recently trashed.
  74. await TrashSyncService.instance
  75. .syncTrash()
  76. .onError((e, s) => _logger.severe('trash sync failed', e, s));
  77. final filesToBeUploaded = await _getFilesToBeUploaded();
  78. final hasUploadedFiles = await _uploadFiles(filesToBeUploaded);
  79. if (hasUploadedFiles) {
  80. await _pullDiff(true);
  81. _existingSync.complete();
  82. _existingSync = null;
  83. final hasMoreFilesToBackup = (await _getFilesToBeUploaded()).isNotEmpty;
  84. if (hasMoreFilesToBackup && !_shouldThrottleSync()) {
  85. // Skipping a resync to ensure that files that were ignored in this
  86. // session are not processed now
  87. sync();
  88. } else {
  89. Bus.instance.fire(SyncStatusUpdate(SyncStatus.completedBackup));
  90. }
  91. } else {
  92. _existingSync.complete();
  93. _existingSync = null;
  94. }
  95. } catch (e, s) {
  96. _existingSync.complete();
  97. _existingSync = null;
  98. // rethrow whitelisted error so that UI status can be updated correctly.
  99. if (e is UnauthorizedError ||
  100. e is NoActiveSubscriptionError ||
  101. e is WiFiUnavailableError ||
  102. e is StorageLimitExceededError ||
  103. e is SyncStopRequestedError) {
  104. _logger.warning("Error executing remote sync", e);
  105. rethrow;
  106. } else {
  107. _logger.severe("Error executing remote sync ", e, s);
  108. }
  109. }
  110. }
  111. Future<void> _pullDiff(bool silently) async {
  112. final isFirstSync = !_collectionsService.hasSyncedCollections();
  113. await _collectionsService.sync();
  114. if (isFirstSync || _hasReSynced()) {
  115. await _syncUpdatedCollections(silently);
  116. } else {
  117. final syncSinceTime = _getSinceTimeForReSync();
  118. await _resyncAllCollectionsSinceTime(syncSinceTime);
  119. }
  120. if (!_hasReSynced()) {
  121. await _markReSyncAsDone();
  122. }
  123. if (FeatureFlagService.instance.enableMissingLocationMigration() &&
  124. !_fileMigrationService.isLocationMigrationCompleted()) {
  125. _fileMigrationService.runMigration();
  126. }
  127. }
  128. Future<void> _syncUpdatedCollections(bool silently) async {
  129. final updatedCollections =
  130. await _collectionsService.getCollectionsToBeSynced();
  131. if (updatedCollections.isNotEmpty && !silently) {
  132. Bus.instance.fire(SyncStatusUpdate(SyncStatus.applyingRemoteDiff));
  133. }
  134. for (final c in updatedCollections) {
  135. await _syncCollectionDiff(
  136. c.id,
  137. _collectionsService.getCollectionSyncTime(c.id),
  138. );
  139. await _collectionsService.setCollectionSyncTime(c.id, c.updationTime);
  140. }
  141. }
  142. Future<void> _resyncAllCollectionsSinceTime(int sinceTime) async {
  143. _logger.info('re-sync collections sinceTime: $sinceTime');
  144. final collections = _collectionsService.getActiveCollections();
  145. for (final c in collections) {
  146. await _syncCollectionDiff(
  147. c.id,
  148. min(_collectionsService.getCollectionSyncTime(c.id), sinceTime),
  149. );
  150. await _collectionsService.setCollectionSyncTime(c.id, c.updationTime);
  151. }
  152. }
  153. Future<void> _syncCollectionDiff(int collectionID, int sinceTime) async {
  154. final diff =
  155. await _diffFetcher.getEncryptedFilesDiff(collectionID, sinceTime);
  156. if (diff.deletedFiles.isNotEmpty) {
  157. final fileIDs = diff.deletedFiles.map((f) => f.uploadedFileID).toList();
  158. final deletedFiles =
  159. (await FilesDB.instance.getFilesFromIDs(fileIDs)).values.toList();
  160. await FilesDB.instance.deleteFilesFromCollection(collectionID, fileIDs);
  161. Bus.instance.fire(
  162. CollectionUpdatedEvent(
  163. collectionID,
  164. deletedFiles,
  165. type: EventType.deletedFromRemote,
  166. ),
  167. );
  168. Bus.instance.fire(
  169. LocalPhotosUpdatedEvent(
  170. deletedFiles,
  171. type: EventType.deletedFromRemote,
  172. ),
  173. );
  174. }
  175. if (diff.updatedFiles.isNotEmpty) {
  176. await _storeDiff(diff.updatedFiles, collectionID);
  177. _logger.info(
  178. "Updated " +
  179. diff.updatedFiles.length.toString() +
  180. " files in collection " +
  181. collectionID.toString(),
  182. );
  183. Bus.instance.fire(LocalPhotosUpdatedEvent(diff.updatedFiles));
  184. Bus.instance
  185. .fire(CollectionUpdatedEvent(collectionID, diff.updatedFiles));
  186. }
  187. if (diff.latestUpdatedAtTime > 0) {
  188. await _collectionsService.setCollectionSyncTime(
  189. collectionID,
  190. diff.latestUpdatedAtTime,
  191. );
  192. }
  193. if (diff.hasMore) {
  194. return await _syncCollectionDiff(
  195. collectionID,
  196. _collectionsService.getCollectionSyncTime(collectionID),
  197. );
  198. }
  199. }
  200. Future<List<File>> _getFilesToBeUploaded() async {
  201. final foldersToBackUp = Configuration.instance.getPathsToBackUp();
  202. List<File> filesToBeUploaded;
  203. if (LocalSyncService.instance.hasGrantedLimitedPermissions() &&
  204. foldersToBackUp.isEmpty) {
  205. filesToBeUploaded = await _db.getAllLocalFiles();
  206. } else {
  207. filesToBeUploaded =
  208. await _db.getFilesToBeUploadedWithinFolders(foldersToBackUp);
  209. }
  210. if (!Configuration.instance.shouldBackupVideos() || _shouldThrottleSync()) {
  211. filesToBeUploaded
  212. .removeWhere((element) => element.fileType == FileType.video);
  213. }
  214. if (filesToBeUploaded.isNotEmpty) {
  215. final int prevCount = filesToBeUploaded.length;
  216. final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs;
  217. filesToBeUploaded.removeWhere(
  218. (file) =>
  219. IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, file),
  220. );
  221. if (prevCount != filesToBeUploaded.length) {
  222. _logger.info(
  223. (prevCount - filesToBeUploaded.length).toString() +
  224. " files were ignored for upload",
  225. );
  226. }
  227. }
  228. if (filesToBeUploaded.isEmpty) {
  229. // look for files which user manually tried to back up but they are not
  230. // uploaded yet. These files should ignore video backup & ignored files filter
  231. filesToBeUploaded = await _db.getPendingManualUploads();
  232. }
  233. _sortByTimeAndType(filesToBeUploaded);
  234. _logger.info(
  235. filesToBeUploaded.length.toString() + " new files to be uploaded.",
  236. );
  237. return filesToBeUploaded;
  238. }
  239. Future<bool> _uploadFiles(List<File> filesToBeUploaded) async {
  240. final updatedFileIDs = await _db.getUploadedFileIDsToBeUpdated();
  241. _logger.info(updatedFileIDs.length.toString() + " files updated.");
  242. final editedFiles = await _db.getEditedRemoteFiles();
  243. _logger.info(editedFiles.length.toString() + " files edited.");
  244. _completedUploads = 0;
  245. int toBeUploaded =
  246. filesToBeUploaded.length + updatedFileIDs.length + editedFiles.length;
  247. if (toBeUploaded > 0) {
  248. Bus.instance.fire(SyncStatusUpdate(SyncStatus.preparingForUpload));
  249. // verify if files upload is allowed based on their subscription plan and
  250. // storage limit. To avoid creating new endpoint, we are using
  251. // fetchUploadUrls as alternative method.
  252. await _uploader.fetchUploadURLs(toBeUploaded);
  253. }
  254. final List<Future> futures = [];
  255. for (final uploadedFileID in updatedFileIDs) {
  256. if (_shouldThrottleSync() &&
  257. futures.length >= kMaximumPermissibleUploadsInThrottledMode) {
  258. _logger
  259. .info("Skipping some updated files as we are throttling uploads");
  260. break;
  261. }
  262. final file = await _db.getUploadedFileInAnyCollection(uploadedFileID);
  263. _uploadFile(file, file.collectionID, futures);
  264. }
  265. for (final file in filesToBeUploaded) {
  266. if (_shouldThrottleSync() &&
  267. futures.length >= kMaximumPermissibleUploadsInThrottledMode) {
  268. _logger.info("Skipping some new files as we are throttling uploads");
  269. break;
  270. }
  271. // prefer existing collection ID for manually uploaded files.
  272. // See https://github.com/ente-io/frame/pull/187
  273. final collectionID = file.collectionID ??
  274. (await CollectionsService.instance
  275. .getOrCreateForPath(file.deviceFolder))
  276. .id;
  277. _uploadFile(file, collectionID, futures);
  278. }
  279. for (final file in editedFiles) {
  280. if (_shouldThrottleSync() &&
  281. futures.length >= kMaximumPermissibleUploadsInThrottledMode) {
  282. _logger.info("Skipping some edited files as we are throttling uploads");
  283. break;
  284. }
  285. _uploadFile(file, file.collectionID, futures);
  286. }
  287. try {
  288. await Future.wait(futures);
  289. } on InvalidFileError {
  290. // Do nothing
  291. } on FileSystemException {
  292. // Do nothing since it's caused mostly due to concurrency issues
  293. // when the foreground app deletes temporary files, interrupting a background
  294. // upload
  295. } on LockAlreadyAcquiredError {
  296. // Do nothing
  297. } on SilentlyCancelUploadsError {
  298. // Do nothing
  299. } on UserCancelledUploadError {
  300. // Do nothing
  301. } catch (e) {
  302. rethrow;
  303. }
  304. return _completedUploads > 0;
  305. }
  306. void _uploadFile(File file, int collectionID, List<Future> futures) {
  307. final future = _uploader
  308. .upload(file, collectionID)
  309. .then((uploadedFile) => _onFileUploaded(uploadedFile));
  310. futures.add(future);
  311. }
  312. Future<void> _onFileUploaded(File file) async {
  313. Bus.instance.fire(CollectionUpdatedEvent(file.collectionID, [file]));
  314. _completedUploads++;
  315. final toBeUploadedInThisSession =
  316. FileUploader.instance.getCurrentSessionUploadCount();
  317. if (toBeUploadedInThisSession == 0) {
  318. return;
  319. }
  320. if (_completedUploads > toBeUploadedInThisSession ||
  321. _completedUploads < 0 ||
  322. toBeUploadedInThisSession < 0) {
  323. _logger.info(
  324. "Incorrect sync status",
  325. InvalidSyncStatusError(
  326. "Tried to report $_completedUploads as "
  327. "uploaded out of $toBeUploadedInThisSession",
  328. ),
  329. );
  330. return;
  331. }
  332. Bus.instance.fire(
  333. SyncStatusUpdate(
  334. SyncStatus.inProgress,
  335. completed: _completedUploads,
  336. total: toBeUploadedInThisSession,
  337. ),
  338. );
  339. }
  340. Future _storeDiff(List<File> diff, int collectionID) async {
  341. int existing = 0,
  342. updated = 0,
  343. remote = 0,
  344. localButUpdatedOnRemote = 0,
  345. localButAddedToNewCollectionOnRemote = 0;
  346. bool hasAnyCreationTimeChanged = false;
  347. List<File> toBeInserted = [];
  348. int userID = Configuration.instance.getUserID();
  349. for (File file in diff) {
  350. final existingFiles = file.deviceFolder == null
  351. ? null
  352. : await _db.getMatchingFiles(file.title, file.deviceFolder);
  353. if (existingFiles == null ||
  354. existingFiles.isEmpty ||
  355. userID != file.ownerID) {
  356. // File uploaded from a different device or uploaded by different user
  357. // Other rare possibilities : The local file is present on
  358. // device but it's not imported in local db due to missing permission
  359. // after reinstall (iOS selected file permissions or user revoking
  360. // permissions, or issue/delay in importing devices files.
  361. file.localID = null;
  362. toBeInserted.add(file);
  363. remote++;
  364. } else {
  365. // File exists in ente db with same title & device folder
  366. // Note: The file.generatedID might be already set inside
  367. // [DiffFetcher.getEncryptedFilesDiff]
  368. // Try to find existing file with same localID as remote file with a fallback
  369. // to finding any existing file with localID. This is needed to handle
  370. // case when localID for a file changes and the file is uploaded again in
  371. // the same collection
  372. final fileWithLocalID = existingFiles.firstWhere(
  373. (e) =>
  374. file.localID != null &&
  375. e.localID != null &&
  376. e.localID == file.localID,
  377. orElse: () => existingFiles.firstWhere(
  378. (e) => e.localID != null,
  379. orElse: () => null,
  380. ),
  381. );
  382. if (fileWithLocalID != null) {
  383. // File should ideally have the same localID
  384. if (file.localID != null && file.localID != fileWithLocalID.localID) {
  385. _logger.severe(
  386. "unexpected mismatch in localIDs remote: ${file.toString()} and existing: ${fileWithLocalID.toString()}",
  387. );
  388. }
  389. file.localID = fileWithLocalID.localID;
  390. } else {
  391. file.localID = null;
  392. }
  393. bool wasUploadedOnAPreviousInstallation =
  394. existingFiles.length == 1 && existingFiles[0].collectionID == null;
  395. if (wasUploadedOnAPreviousInstallation) {
  396. file.generatedID = existingFiles[0].generatedID;
  397. if (file.modificationTime != existingFiles[0].modificationTime) {
  398. // File was updated since the app was uninstalled
  399. // mark it for re-upload
  400. _logger.info(
  401. "re-upload because file was updated since last installation: "
  402. "remoteFile: ${file.toString()}, localFile: ${existingFiles[0].toString()}",
  403. );
  404. file.modificationTime = existingFiles[0].modificationTime;
  405. file.updationTime = null;
  406. updated++;
  407. } else {
  408. existing++;
  409. }
  410. toBeInserted.add(file);
  411. } else {
  412. bool foundMatchingCollection = false;
  413. for (final existingFile in existingFiles) {
  414. if (file.collectionID == existingFile.collectionID &&
  415. file.uploadedFileID == existingFile.uploadedFileID) {
  416. // File was updated on remote
  417. if (file.creationTime != existingFile.creationTime) {
  418. hasAnyCreationTimeChanged = true;
  419. }
  420. foundMatchingCollection = true;
  421. file.generatedID = existingFile.generatedID;
  422. toBeInserted.add(file);
  423. await clearCache(file);
  424. localButUpdatedOnRemote++;
  425. break;
  426. }
  427. }
  428. if (!foundMatchingCollection) {
  429. // Added to a new collection
  430. toBeInserted.add(file);
  431. localButAddedToNewCollectionOnRemote++;
  432. }
  433. }
  434. }
  435. }
  436. await _db.insertMultiple(toBeInserted);
  437. _logger.info(
  438. "Diff to be deduplicated was: " +
  439. diff.length.toString() +
  440. " out of which \n" +
  441. existing.toString() +
  442. " was uploaded from device, \n" +
  443. updated.toString() +
  444. " was uploaded from device, but has been updated since and should be reuploaded, \n" +
  445. remote.toString() +
  446. " was uploaded from remote, \n" +
  447. localButUpdatedOnRemote.toString() +
  448. " was uploaded from device but updated on remote, and \n" +
  449. localButAddedToNewCollectionOnRemote.toString() +
  450. " was uploaded from device but added to a new collection on remote.",
  451. );
  452. if (hasAnyCreationTimeChanged) {
  453. Bus.instance.fire(ForceReloadHomeGalleryEvent());
  454. }
  455. }
  456. // return true if the client needs to re-sync the collections from previous
  457. // version
  458. bool _hasReSynced() {
  459. return _prefs.containsKey(kHasSyncedEditTime) &&
  460. _prefs.containsKey(kHasSyncedArchiveKey);
  461. }
  462. Future<void> _markReSyncAsDone() async {
  463. await _prefs.setBool(kHasSyncedArchiveKey, true);
  464. await _prefs.setBool(kHasSyncedEditTime, true);
  465. }
  466. int _getSinceTimeForReSync() {
  467. // re-sync from archive feature time if the client still hasn't synced
  468. // since the feature release.
  469. if (!_prefs.containsKey(kHasSyncedArchiveKey)) {
  470. return kArchiveFeatureReleaseTime;
  471. }
  472. return kEditTimeFeatureReleaseTime;
  473. }
  474. bool _shouldThrottleSync() {
  475. return Platform.isIOS && !AppLifecycleService.instance.isForeground;
  476. }
  477. // _sortByTimeAndType moves videos to end and sort by creation time (desc).
  478. // This is done to upload most recent photo first.
  479. void _sortByTimeAndType(List<File> file) {
  480. file.sort((first, second) {
  481. if (first.fileType == second.fileType) {
  482. return second.creationTime.compareTo(first.creationTime);
  483. } else if (first.fileType == FileType.video) {
  484. return 1;
  485. } else {
  486. return -1;
  487. }
  488. });
  489. // move updated files towards the end
  490. file.sort((first, second) {
  491. if (first.updationTime == second.updationTime) {
  492. return 0;
  493. }
  494. if (first.updationTime == -1) {
  495. return 1;
  496. } else {
  497. return -1;
  498. }
  499. });
  500. }
  501. }