remote_sync_service.dart 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. // @dart=2.9
  2. import 'dart:async';
  3. import 'dart:io';
  4. import 'dart:math';
  5. import 'package:logging/logging.dart';
  6. import 'package:photos/core/configuration.dart';
  7. import 'package:photos/core/errors.dart';
  8. import 'package:photos/core/event_bus.dart';
  9. import 'package:photos/db/file_updation_db.dart';
  10. import 'package:photos/db/files_db.dart';
  11. import 'package:photos/events/collection_updated_event.dart';
  12. import 'package:photos/events/files_updated_event.dart';
  13. import 'package:photos/events/force_reload_home_gallery_event.dart';
  14. import 'package:photos/events/local_photos_updated_event.dart';
  15. import 'package:photos/events/sync_status_update_event.dart';
  16. import 'package:photos/models/file.dart';
  17. import 'package:photos/models/file_type.dart';
  18. import 'package:photos/services/app_lifecycle_service.dart';
  19. import 'package:photos/services/collections_service.dart';
  20. import 'package:photos/services/ignored_files_service.dart';
  21. import 'package:photos/services/local_file_update_service.dart';
  22. import 'package:photos/services/local_sync_service.dart';
  23. import 'package:photos/services/trash_sync_service.dart';
  24. import 'package:photos/utils/diff_fetcher.dart';
  25. import 'package:photos/utils/file_uploader.dart';
  26. import 'package:photos/utils/file_util.dart';
  27. import 'package:shared_preferences/shared_preferences.dart';
  28. class RemoteSyncService {
  29. final _logger = Logger("RemoteSyncService");
  30. final _db = FilesDB.instance;
  31. final _uploader = FileUploader.instance;
  32. final _collectionsService = CollectionsService.instance;
  33. final _diffFetcher = DiffFetcher();
  34. final LocalFileUpdateService _localFileUpdateService =
  35. LocalFileUpdateService.instance;
  36. int _completedUploads = 0;
  37. SharedPreferences _prefs;
  38. Completer<void> _existingSync;
  39. bool _existingSyncSilent = false;
  40. static const kHasSyncedArchiveKey = "has_synced_archive";
  41. // 28 Sept, 2021 9:03:20 AM IST
  42. static const kArchiveFeatureReleaseTime = 1632800000000000;
  43. static const kHasSyncedEditTime = "has_synced_edit_time";
  44. // 29 October, 2021 3:56:40 AM IST
  45. static const kEditTimeFeatureReleaseTime = 1635460000000000;
  46. static const kMaximumPermissibleUploadsInThrottledMode = 4;
  47. static final RemoteSyncService instance =
  48. RemoteSyncService._privateConstructor();
  49. RemoteSyncService._privateConstructor();
  50. Future<void> init() async {
  51. _prefs = await SharedPreferences.getInstance();
  52. Bus.instance.on<LocalPhotosUpdatedEvent>().listen((event) async {
  53. if (event.type == EventType.addedOrUpdated) {
  54. if (_existingSync == null) {
  55. sync();
  56. }
  57. }
  58. });
  59. }
  60. Future<void> sync({bool silently = false}) async {
  61. if (!Configuration.instance.hasConfiguredAccount()) {
  62. _logger.info("Skipping remote sync since account is not configured");
  63. return;
  64. }
  65. if (_existingSync != null) {
  66. _logger.info("Remote sync already in progress, skipping");
  67. // if current sync is silent but request sync is non-silent (demands UI
  68. // updates), update the syncSilently flag
  69. if (_existingSyncSilent == true && silently == false) {
  70. _existingSyncSilent = false;
  71. }
  72. return _existingSync.future;
  73. }
  74. _existingSync = Completer<void>();
  75. _existingSyncSilent = silently;
  76. try {
  77. await _pullDiff();
  78. // sync trash but consume error during initial launch.
  79. // this is to ensure that we don't pause upload due to any error during
  80. // the trash sync. Impact: We may end up re-uploading a file which was
  81. // recently trashed.
  82. await TrashSyncService.instance
  83. .syncTrash()
  84. .onError((e, s) => _logger.severe('trash sync failed', e, s));
  85. final filesToBeUploaded = await _getFilesToBeUploaded();
  86. final hasUploadedFiles = await _uploadFiles(filesToBeUploaded);
  87. if (hasUploadedFiles) {
  88. await _pullDiff();
  89. _existingSync.complete();
  90. _existingSync = null;
  91. final hasMoreFilesToBackup = (await _getFilesToBeUploaded()).isNotEmpty;
  92. if (hasMoreFilesToBackup && !_shouldThrottleSync()) {
  93. // Skipping a resync to ensure that files that were ignored in this
  94. // session are not processed now
  95. sync();
  96. } else {
  97. Bus.instance.fire(SyncStatusUpdate(SyncStatus.completedBackup));
  98. }
  99. } else {
  100. _existingSync.complete();
  101. _existingSync = null;
  102. }
  103. } catch (e, s) {
  104. _existingSync.complete();
  105. _existingSync = null;
  106. // rethrow whitelisted error so that UI status can be updated correctly.
  107. if (e is UnauthorizedError ||
  108. e is NoActiveSubscriptionError ||
  109. e is WiFiUnavailableError ||
  110. e is StorageLimitExceededError ||
  111. e is SyncStopRequestedError) {
  112. _logger.warning("Error executing remote sync", e);
  113. rethrow;
  114. } else {
  115. _logger.severe("Error executing remote sync ", e, s);
  116. }
  117. } finally {
  118. _existingSyncSilent = false;
  119. }
  120. }
  121. Future<void> _pullDiff() async {
  122. final isFirstSync = !_collectionsService.hasSyncedCollections();
  123. await _collectionsService.sync();
  124. // check and reset user's collection syncTime in past for older clients
  125. if (isFirstSync) {
  126. // not need reset syncTime, mark all flags as done if firstSync
  127. await _markResetSyncTimeAsDone();
  128. } else if (_shouldResetSyncTime()) {
  129. _logger.warning('Resetting syncTime for for the client');
  130. await _resetAllCollectionsSyncTime();
  131. await _markResetSyncTimeAsDone();
  132. }
  133. await _syncUpdatedCollections();
  134. unawaited(_localFileUpdateService.markUpdatedFilesForReUpload());
  135. }
  136. Future<void> _syncUpdatedCollections() async {
  137. final updatedCollections =
  138. await _collectionsService.getCollectionsToBeSynced();
  139. for (final c in updatedCollections) {
  140. await _syncCollectionDiff(
  141. c.id,
  142. _collectionsService.getCollectionSyncTime(c.id),
  143. );
  144. await _collectionsService.setCollectionSyncTime(c.id, c.updationTime);
  145. }
  146. }
  147. Future<void> _resetAllCollectionsSyncTime() async {
  148. final resetSyncTime = _getSinceTimeForReSync();
  149. _logger.info('re-setting all collections syncTime to: $resetSyncTime');
  150. final collections = _collectionsService.getActiveCollections();
  151. for (final c in collections) {
  152. final int newSyncTime =
  153. min(_collectionsService.getCollectionSyncTime(c.id), resetSyncTime);
  154. await _collectionsService.setCollectionSyncTime(c.id, newSyncTime);
  155. }
  156. }
  157. Future<void> _syncCollectionDiff(int collectionID, int sinceTime) async {
  158. if (!_existingSyncSilent) {
  159. Bus.instance.fire(SyncStatusUpdate(SyncStatus.applyingRemoteDiff));
  160. }
  161. final diff =
  162. await _diffFetcher.getEncryptedFilesDiff(collectionID, sinceTime);
  163. if (diff.deletedFiles.isNotEmpty) {
  164. final fileIDs = diff.deletedFiles.map((f) => f.uploadedFileID).toList();
  165. final deletedFiles =
  166. (await FilesDB.instance.getFilesFromIDs(fileIDs)).values.toList();
  167. await FilesDB.instance.deleteFilesFromCollection(collectionID, fileIDs);
  168. Bus.instance.fire(
  169. CollectionUpdatedEvent(
  170. collectionID,
  171. deletedFiles,
  172. type: EventType.deletedFromRemote,
  173. ),
  174. );
  175. Bus.instance.fire(
  176. LocalPhotosUpdatedEvent(
  177. deletedFiles,
  178. type: EventType.deletedFromRemote,
  179. ),
  180. );
  181. }
  182. if (diff.updatedFiles.isNotEmpty) {
  183. await _storeDiff(diff.updatedFiles, collectionID);
  184. _logger.info(
  185. "Updated " +
  186. diff.updatedFiles.length.toString() +
  187. " files in collection " +
  188. collectionID.toString(),
  189. );
  190. Bus.instance.fire(LocalPhotosUpdatedEvent(diff.updatedFiles));
  191. Bus.instance
  192. .fire(CollectionUpdatedEvent(collectionID, diff.updatedFiles));
  193. }
  194. if (diff.latestUpdatedAtTime > 0) {
  195. await _collectionsService.setCollectionSyncTime(
  196. collectionID,
  197. diff.latestUpdatedAtTime,
  198. );
  199. }
  200. if (diff.hasMore) {
  201. return await _syncCollectionDiff(
  202. collectionID,
  203. _collectionsService.getCollectionSyncTime(collectionID),
  204. );
  205. }
  206. }
  207. Future<List<File>> _getFilesToBeUploaded() async {
  208. final foldersToBackUp = Configuration.instance.getPathsToBackUp();
  209. List<File> filesToBeUploaded;
  210. if (LocalSyncService.instance.hasGrantedLimitedPermissions() &&
  211. foldersToBackUp.isEmpty) {
  212. filesToBeUploaded = await _db.getAllLocalFiles();
  213. } else {
  214. filesToBeUploaded =
  215. await _db.getFilesToBeUploadedWithinFolders(foldersToBackUp);
  216. }
  217. if (!Configuration.instance.shouldBackupVideos() || _shouldThrottleSync()) {
  218. filesToBeUploaded
  219. .removeWhere((element) => element.fileType == FileType.video);
  220. }
  221. if (filesToBeUploaded.isNotEmpty) {
  222. final int prevCount = filesToBeUploaded.length;
  223. final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs;
  224. filesToBeUploaded.removeWhere(
  225. (file) =>
  226. IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, file),
  227. );
  228. if (prevCount != filesToBeUploaded.length) {
  229. _logger.info(
  230. (prevCount - filesToBeUploaded.length).toString() +
  231. " files were ignored for upload",
  232. );
  233. }
  234. }
  235. if (filesToBeUploaded.isEmpty) {
  236. // look for files which user manually tried to back up but they are not
  237. // uploaded yet. These files should ignore video backup & ignored files filter
  238. filesToBeUploaded = await _db.getPendingManualUploads();
  239. }
  240. _sortByTimeAndType(filesToBeUploaded);
  241. _logger.info(
  242. filesToBeUploaded.length.toString() + " new files to be uploaded.",
  243. );
  244. return filesToBeUploaded;
  245. }
  246. Future<bool> _uploadFiles(List<File> filesToBeUploaded) async {
  247. final updatedFileIDs = await _db.getUploadedFileIDsToBeUpdated();
  248. _logger.info(updatedFileIDs.length.toString() + " files updated.");
  249. final editedFiles = await _db.getEditedRemoteFiles();
  250. _logger.info(editedFiles.length.toString() + " files edited.");
  251. _completedUploads = 0;
  252. final int toBeUploaded =
  253. filesToBeUploaded.length + updatedFileIDs.length + editedFiles.length;
  254. if (toBeUploaded > 0) {
  255. Bus.instance.fire(SyncStatusUpdate(SyncStatus.preparingForUpload));
  256. // verify if files upload is allowed based on their subscription plan and
  257. // storage limit. To avoid creating new endpoint, we are using
  258. // fetchUploadUrls as alternative method.
  259. await _uploader.fetchUploadURLs(toBeUploaded);
  260. }
  261. final List<Future> futures = [];
  262. for (final uploadedFileID in updatedFileIDs) {
  263. if (_shouldThrottleSync() &&
  264. futures.length >= kMaximumPermissibleUploadsInThrottledMode) {
  265. _logger
  266. .info("Skipping some updated files as we are throttling uploads");
  267. break;
  268. }
  269. final file = await _db.getUploadedFileInAnyCollection(uploadedFileID);
  270. _uploadFile(file, file.collectionID, futures);
  271. }
  272. for (final file in filesToBeUploaded) {
  273. if (_shouldThrottleSync() &&
  274. futures.length >= kMaximumPermissibleUploadsInThrottledMode) {
  275. _logger.info("Skipping some new files as we are throttling uploads");
  276. break;
  277. }
  278. // prefer existing collection ID for manually uploaded files.
  279. // See https://github.com/ente-io/frame/pull/187
  280. final collectionID = file.collectionID ??
  281. (await CollectionsService.instance
  282. .getOrCreateForPath(file.deviceFolder))
  283. .id;
  284. _uploadFile(file, collectionID, futures);
  285. }
  286. for (final file in editedFiles) {
  287. if (_shouldThrottleSync() &&
  288. futures.length >= kMaximumPermissibleUploadsInThrottledMode) {
  289. _logger.info("Skipping some edited files as we are throttling uploads");
  290. break;
  291. }
  292. _uploadFile(file, file.collectionID, futures);
  293. }
  294. try {
  295. await Future.wait(futures);
  296. } on InvalidFileError {
  297. // Do nothing
  298. } on FileSystemException {
  299. // Do nothing since it's caused mostly due to concurrency issues
  300. // when the foreground app deletes temporary files, interrupting a background
  301. // upload
  302. } on LockAlreadyAcquiredError {
  303. // Do nothing
  304. } on SilentlyCancelUploadsError {
  305. // Do nothing
  306. } on UserCancelledUploadError {
  307. // Do nothing
  308. } catch (e) {
  309. rethrow;
  310. }
  311. return _completedUploads > 0;
  312. }
  313. void _uploadFile(File file, int collectionID, List<Future> futures) {
  314. final future = _uploader
  315. .upload(file, collectionID)
  316. .then((uploadedFile) => _onFileUploaded(uploadedFile));
  317. futures.add(future);
  318. }
  319. Future<void> _onFileUploaded(File file) async {
  320. Bus.instance.fire(CollectionUpdatedEvent(file.collectionID, [file]));
  321. _completedUploads++;
  322. final toBeUploadedInThisSession =
  323. FileUploader.instance.getCurrentSessionUploadCount();
  324. if (toBeUploadedInThisSession == 0) {
  325. return;
  326. }
  327. if (_completedUploads > toBeUploadedInThisSession ||
  328. _completedUploads < 0 ||
  329. toBeUploadedInThisSession < 0) {
  330. _logger.info(
  331. "Incorrect sync status",
  332. InvalidSyncStatusError(
  333. "Tried to report $_completedUploads as "
  334. "uploaded out of $toBeUploadedInThisSession",
  335. ),
  336. );
  337. return;
  338. }
  339. Bus.instance.fire(
  340. SyncStatusUpdate(
  341. SyncStatus.inProgress,
  342. completed: _completedUploads,
  343. total: toBeUploadedInThisSession,
  344. ),
  345. );
  346. }
  347. /* _storeDiff maps each remoteDiff file to existing
  348. entries in files table. When match is found, it compares both file to
  349. perform relevant actions like
  350. [1] Clear local cache when required (Both Shared and Owned files)
  351. [2] Retain localID of remote file based on matching logic [Owned files]
  352. [3] Refresh UI if visibility or creationTime has changed [Owned files]
  353. [4] Schedule file update if the local file has changed since last time
  354. [Owned files]
  355. [Important Note: If given uploadedFileID and collectionID is already present
  356. in files db, the generateID should already point to existing entry.
  357. Known Issues:
  358. [K1] Cached entry will not be cleared when if a file was edited and
  359. moved to different collection as Vid/Image cache key is uploadedID.
  360. [Existing]
  361. ]
  362. */
  363. Future _storeDiff(List<File> diff, int collectionID) async {
  364. int sharedFileNew = 0,
  365. sharedFileUpdated = 0,
  366. localUploadedFromDevice = 0,
  367. localButUpdatedOnDevice = 0,
  368. remoteNewFile = 0;
  369. final int userID = Configuration.instance.getUserID();
  370. bool needsGalleryReload = false;
  371. // this is required when same file is uploaded twice in the same
  372. // collection. Without this check, if both remote files are part of same
  373. // diff response, then we end up inserting one entry instead of two
  374. // as we update the generatedID for remoteDiff to local file's genID
  375. final Set<int> alreadyClaimedLocalFilesGenID = {};
  376. final List<File> toBeInserted = [];
  377. for (File remoteDiff in diff) {
  378. // existingFile will be either set to existing collectionID+localID or
  379. // to the unclaimed aka not already linked to any uploaded file.
  380. File existingFile;
  381. if (remoteDiff.generatedID != null) {
  382. // Case [1] Check and clear local cache when uploadedFile already exist
  383. existingFile = await _db.getFile(remoteDiff.generatedID);
  384. if (_shouldClearCache(remoteDiff, existingFile)) {
  385. await clearCache(remoteDiff);
  386. }
  387. }
  388. /* If file is not owned by the user, no further processing is required
  389. as Case [2,3,4] are only relevant to files owned by user
  390. */
  391. if (userID != remoteDiff.ownerID) {
  392. if (existingFile == null) {
  393. sharedFileNew++;
  394. remoteDiff.localID = null;
  395. } else {
  396. sharedFileUpdated++;
  397. // if user has downloaded the file on the device, avoid removing the
  398. // localID reference.
  399. // [Todo-fix: Excluded shared file's localIDs during syncALL]
  400. remoteDiff.localID = existingFile.localID;
  401. }
  402. toBeInserted.add(remoteDiff);
  403. // end processing for file here, move to next file now
  404. continue;
  405. }
  406. // If remoteDiff is not already synced (i.e. existingFile is null), check
  407. // if the remoteFile was uploaded from this device.
  408. // Note: DeviceFolder is ignored for iOS during matching
  409. if (existingFile == null && remoteDiff.localID != null) {
  410. final localFileEntries = await _db.getUnlinkedLocalMatchesForRemoteFile(
  411. userID,
  412. remoteDiff.localID,
  413. remoteDiff.fileType,
  414. title: remoteDiff.title,
  415. deviceFolder: remoteDiff.deviceFolder ?? '',
  416. );
  417. if (localFileEntries.isEmpty) {
  418. // set remote file's localID as null because corresponding local file
  419. // does not exist [Case 2, do not retain localID of the remote file]
  420. remoteDiff.localID = null;
  421. } else {
  422. // case 4: Check and schedule the file for update
  423. final int maxModificationTime = localFileEntries
  424. .map(
  425. (e) => e.modificationTime ?? 0,
  426. )
  427. .reduce(max);
  428. if (maxModificationTime > remoteDiff.modificationTime) {
  429. localButUpdatedOnDevice++;
  430. await FileUpdationDB.instance.insertMultiple(
  431. [remoteDiff.localID],
  432. FileUpdationDB.modificationTimeUpdated,
  433. );
  434. }
  435. localFileEntries.removeWhere(
  436. (e) =>
  437. e.uploadedFileID != null ||
  438. alreadyClaimedLocalFilesGenID.contains(e.generatedID),
  439. );
  440. if (localFileEntries.isNotEmpty) {
  441. // file uploaded from same device, replace the local file row by
  442. // setting the generated ID of remoteFile to localFile generatedID
  443. existingFile = localFileEntries.first;
  444. localUploadedFromDevice++;
  445. alreadyClaimedLocalFilesGenID.add(existingFile.generatedID);
  446. remoteDiff.generatedID = existingFile.generatedID;
  447. }
  448. }
  449. }
  450. if (existingFile != null &&
  451. _shouldReloadHomeGallery(remoteDiff, existingFile)) {
  452. needsGalleryReload = true;
  453. } else {
  454. remoteNewFile++;
  455. }
  456. toBeInserted.add(remoteDiff);
  457. }
  458. await _db.insertMultiple(toBeInserted);
  459. _logger.info(
  460. "Diff to be deduplicated was: " +
  461. diff.length.toString() +
  462. " out of which \n" +
  463. localUploadedFromDevice.toString() +
  464. " was uploaded from device, \n" +
  465. localButUpdatedOnDevice.toString() +
  466. " was uploaded from device, but has been updated since and should be reuploaded, \n" +
  467. sharedFileNew.toString() +
  468. " new sharedFiles, \n" +
  469. sharedFileUpdated.toString() +
  470. " updatedSharedFiles, and \n" +
  471. remoteNewFile.toString() +
  472. " remoteFiles seen first time",
  473. );
  474. if (needsGalleryReload) {
  475. Bus.instance.fire(ForceReloadHomeGalleryEvent());
  476. }
  477. }
  478. bool _shouldClearCache(File remoteFile, File existingFile) {
  479. if (remoteFile.hash != null && existingFile.hash != null) {
  480. return remoteFile.hash != existingFile.hash;
  481. }
  482. return remoteFile.updationTime != (existingFile.updationTime ?? 0);
  483. }
  484. bool _shouldReloadHomeGallery(File remoteFile, File existingFile) {
  485. int remoteCreationTime = remoteFile.creationTime;
  486. if (remoteFile.pubMmdVersion > 0 &&
  487. (remoteFile.pubMagicMetadata.editedTime ?? 0) != 0) {
  488. remoteCreationTime = remoteFile.pubMagicMetadata.editedTime;
  489. }
  490. if (remoteCreationTime != existingFile.creationTime) {
  491. return true;
  492. }
  493. if (existingFile.mMdVersion > 0 &&
  494. remoteFile.mMdVersion != existingFile.mMdVersion &&
  495. remoteFile.magicMetadata.visibility !=
  496. existingFile.magicMetadata.visibility) {
  497. return false;
  498. }
  499. return false;
  500. }
  501. // return true if the client needs to re-sync the collections from previous
  502. // version
  503. bool _shouldResetSyncTime() {
  504. return !_prefs.containsKey(kHasSyncedEditTime) ||
  505. !_prefs.containsKey(kHasSyncedArchiveKey);
  506. }
  507. Future<void> _markResetSyncTimeAsDone() async {
  508. await _prefs.setBool(kHasSyncedArchiveKey, true);
  509. await _prefs.setBool(kHasSyncedEditTime, true);
  510. // Check to avoid regression because of change or additions of keys
  511. if (_shouldResetSyncTime()) {
  512. throw Exception("_shouldResetSyncTime should return false now");
  513. }
  514. }
  515. int _getSinceTimeForReSync() {
  516. // re-sync from archive feature time if the client still hasn't synced
  517. // since the feature release.
  518. if (!_prefs.containsKey(kHasSyncedArchiveKey)) {
  519. return kArchiveFeatureReleaseTime;
  520. }
  521. return kEditTimeFeatureReleaseTime;
  522. }
  523. bool _shouldThrottleSync() {
  524. return Platform.isIOS && !AppLifecycleService.instance.isForeground;
  525. }
  526. // _sortByTimeAndType moves videos to end and sort by creation time (desc).
  527. // This is done to upload most recent photo first.
  528. void _sortByTimeAndType(List<File> file) {
  529. file.sort((first, second) {
  530. if (first.fileType == second.fileType) {
  531. return second.creationTime.compareTo(first.creationTime);
  532. } else if (first.fileType == FileType.video) {
  533. return 1;
  534. } else {
  535. return -1;
  536. }
  537. });
  538. // move updated files towards the end
  539. file.sort((first, second) {
  540. if (first.updationTime == second.updationTime) {
  541. return 0;
  542. }
  543. if (first.updationTime == -1) {
  544. return 1;
  545. } else {
  546. return -1;
  547. }
  548. });
  549. }
  550. }