remote_sync_service.dart 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850
  1. import 'dart:async';
  2. import 'dart:io';
  3. import 'dart:math';
  4. import 'package:flutter/foundation.dart';
  5. import 'package:flutter/widgets.dart';
  6. import 'package:logging/logging.dart';
  7. import 'package:photos/core/configuration.dart';
  8. import 'package:photos/core/errors.dart';
  9. import 'package:photos/core/event_bus.dart';
  10. import 'package:photos/db/device_files_db.dart';
  11. import 'package:photos/db/file_updation_db.dart';
  12. import 'package:photos/db/files_db.dart';
  13. import 'package:photos/events/backup_folders_updated_event.dart';
  14. import 'package:photos/events/collection_updated_event.dart';
  15. import 'package:photos/events/files_updated_event.dart';
  16. import 'package:photos/events/force_reload_home_gallery_event.dart';
  17. import 'package:photos/events/local_photos_updated_event.dart';
  18. import 'package:photos/events/sync_status_update_event.dart';
  19. import 'package:photos/models/device_collection.dart';
  20. import 'package:photos/models/file.dart';
  21. import 'package:photos/models/file_type.dart';
  22. import 'package:photos/models/upload_strategy.dart';
  23. import 'package:photos/services/app_lifecycle_service.dart';
  24. import 'package:photos/services/collections_service.dart';
  25. import "package:photos/services/feature_flag_service.dart";
  26. import 'package:photos/services/ignored_files_service.dart';
  27. import 'package:photos/services/local_file_update_service.dart';
  28. import 'package:photos/services/sync_service.dart';
  29. import 'package:photos/services/trash_sync_service.dart';
  30. import 'package:photos/utils/diff_fetcher.dart';
  31. import 'package:photos/utils/file_uploader.dart';
  32. import 'package:photos/utils/file_util.dart';
  33. import 'package:shared_preferences/shared_preferences.dart';
  34. class RemoteSyncService {
  35. final _logger = Logger("RemoteSyncService");
  36. final _db = FilesDB.instance;
  37. final FileUploader _uploader = FileUploader.instance;
  38. final Configuration _config = Configuration.instance;
  39. final CollectionsService _collectionsService = CollectionsService.instance;
  40. final DiffFetcher _diffFetcher = DiffFetcher();
  41. final LocalFileUpdateService _localFileUpdateService =
  42. LocalFileUpdateService.instance;
  43. int _completedUploads = 0;
  44. late SharedPreferences _prefs;
  45. Completer<void>? _existingSync;
  46. bool _isExistingSyncSilent = false;
  47. static const kHasSyncedArchiveKey = "has_synced_archive";
  48. final String _isFirstRemoteSyncDone = "isFirstRemoteSyncDone";
  49. // 28 Sept, 2021 9:03:20 AM IST
  50. static const kArchiveFeatureReleaseTime = 1632800000000000;
  51. static const kHasSyncedEditTime = "has_synced_edit_time";
  52. // 29 October, 2021 3:56:40 AM IST
  53. static const kEditTimeFeatureReleaseTime = 1635460000000000;
  54. static const kMaximumPermissibleUploadsInThrottledMode = 4;
  55. static final RemoteSyncService instance =
  56. RemoteSyncService._privateConstructor();
  57. RemoteSyncService._privateConstructor();
  58. void init(SharedPreferences preferences) {
  59. _prefs = preferences;
  60. Bus.instance.on<LocalPhotosUpdatedEvent>().listen((event) async {
  61. if (event.type == EventType.addedOrUpdated) {
  62. if (_existingSync == null) {
  63. sync();
  64. }
  65. }
  66. });
  67. }
  68. Future<void> sync({bool silently = false}) async {
  69. if (!_config.hasConfiguredAccount()) {
  70. _logger.info("Skipping remote sync since account is not configured");
  71. return;
  72. }
  73. if (_existingSync != null) {
  74. _logger.info("Remote sync already in progress, skipping");
  75. // if current sync is silent but request sync is non-silent (demands UI
  76. // updates), update the syncSilently flag
  77. if (_isExistingSyncSilent && !silently) {
  78. _isExistingSyncSilent = false;
  79. }
  80. return _existingSync?.future;
  81. }
  82. _existingSync = Completer<void>();
  83. _isExistingSyncSilent = silently;
  84. _logger.info(
  85. "Starting remote sync " +
  86. (silently ? "silently" : " with status updates"),
  87. );
  88. try {
  89. // use flag to decide if we should start marking files for upload before
  90. // remote-sync is done. This is done to avoid adding existing files to
  91. // the same or different collection when user had already uploaded them
  92. // before.
  93. final bool hasSyncedBefore = _prefs.containsKey(_isFirstRemoteSyncDone);
  94. if (hasSyncedBefore) {
  95. await syncDeviceCollectionFilesForUpload();
  96. }
  97. await _pullDiff();
  98. // sync trash but consume error during initial launch.
  99. // this is to ensure that we don't pause upload due to any error during
  100. // the trash sync. Impact: We may end up re-uploading a file which was
  101. // recently trashed.
  102. await TrashSyncService.instance
  103. .syncTrash()
  104. .onError((e, s) => _logger.severe('trash sync failed', e, s));
  105. if (!hasSyncedBefore) {
  106. await _prefs.setBool(_isFirstRemoteSyncDone, true);
  107. await syncDeviceCollectionFilesForUpload();
  108. }
  109. final filesToBeUploaded = await _getFilesToBeUploaded();
  110. final hasUploadedFiles = await _uploadFiles(filesToBeUploaded);
  111. _logger.info("File upload complete");
  112. if (hasUploadedFiles) {
  113. await _pullDiff();
  114. _existingSync?.complete();
  115. _existingSync = null;
  116. await syncDeviceCollectionFilesForUpload();
  117. final hasMoreFilesToBackup = (await _getFilesToBeUploaded()).isNotEmpty;
  118. _logger.info("hasMoreFilesToBackup?" + hasMoreFilesToBackup.toString());
  119. if (hasMoreFilesToBackup && !_shouldThrottleSync()) {
  120. // Skipping a resync to ensure that files that were ignored in this
  121. // session are not processed now
  122. sync();
  123. } else {
  124. _logger.info("Fire backup completed event");
  125. Bus.instance.fire(SyncStatusUpdate(SyncStatus.completedBackup));
  126. }
  127. } else {
  128. _existingSync?.complete();
  129. _existingSync = null;
  130. }
  131. } catch (e, s) {
  132. _existingSync?.complete();
  133. _existingSync = null;
  134. // rethrow whitelisted error so that UI status can be updated correctly.
  135. if (e is UnauthorizedError ||
  136. e is NoActiveSubscriptionError ||
  137. e is WiFiUnavailableError ||
  138. e is StorageLimitExceededError ||
  139. e is SyncStopRequestedError) {
  140. _logger.warning("Error executing remote sync", e);
  141. rethrow;
  142. } else {
  143. _logger.severe("Error executing remote sync ", e, s);
  144. if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) {
  145. rethrow;
  146. }
  147. }
  148. } finally {
  149. _isExistingSyncSilent = false;
  150. }
  151. }
  152. bool isFirstRemoteSyncDone() {
  153. return _prefs.containsKey(_isFirstRemoteSyncDone);
  154. }
  155. Future<void> _pullDiff() async {
  156. _logger.info("Pulling remote diff");
  157. final isFirstSync = !_collectionsService.hasSyncedCollections();
  158. await _collectionsService.sync();
  159. // check and reset user's collection syncTime in past for older clients
  160. if (isFirstSync) {
  161. // not need reset syncTime, mark all flags as done if firstSync
  162. await _markResetSyncTimeAsDone();
  163. } else if (_shouldResetSyncTime()) {
  164. _logger.warning('Resetting syncTime for for the client');
  165. await _resetAllCollectionsSyncTime();
  166. await _markResetSyncTimeAsDone();
  167. }
  168. await _syncUpdatedCollections();
  169. unawaited(_localFileUpdateService.markUpdatedFilesForReUpload());
  170. }
  171. Future<void> _syncUpdatedCollections() async {
  172. final idsToRemoteUpdationTimeMap =
  173. await _collectionsService.getCollectionIDsToBeSynced();
  174. for (final cid in idsToRemoteUpdationTimeMap.keys) {
  175. await _syncCollectionDiff(
  176. cid,
  177. _collectionsService.getCollectionSyncTime(cid),
  178. );
  179. // update syncTime for the collection in sharedPrefs. Note: the
  180. // syncTime can change on remote but we might not get a diff for the
  181. // collection if there are not changes in the file, but the collection
  182. // metadata (name, archive status, sharing etc) has changed.
  183. final remoteUpdateTime = idsToRemoteUpdationTimeMap[cid];
  184. await _collectionsService.setCollectionSyncTime(cid, remoteUpdateTime);
  185. }
  186. _logger.info("All updated collections synced");
  187. }
  188. Future<void> _resetAllCollectionsSyncTime() async {
  189. final resetSyncTime = _getSinceTimeForReSync();
  190. _logger.info('re-setting all collections syncTime to: $resetSyncTime');
  191. final collections = _collectionsService.getActiveCollections();
  192. for (final c in collections) {
  193. final int newSyncTime =
  194. min(_collectionsService.getCollectionSyncTime(c.id), resetSyncTime);
  195. await _collectionsService.setCollectionSyncTime(c.id, newSyncTime);
  196. }
  197. }
  198. Future<void> _syncCollectionDiff(int collectionID, int sinceTime) async {
  199. _logger.info(
  200. "Syncing collection #" +
  201. collectionID.toString() +
  202. (_isExistingSyncSilent ? " silently" : ""),
  203. );
  204. if (!_isExistingSyncSilent) {
  205. Bus.instance.fire(SyncStatusUpdate(SyncStatus.applyingRemoteDiff));
  206. }
  207. final diff =
  208. await _diffFetcher.getEncryptedFilesDiff(collectionID, sinceTime);
  209. if (diff.deletedFiles.isNotEmpty) {
  210. await _syncCollectionDiffDelete(diff, collectionID);
  211. }
  212. if (diff.updatedFiles.isNotEmpty) {
  213. await _storeDiff(diff.updatedFiles, collectionID);
  214. _logger.info(
  215. "Updated " +
  216. diff.updatedFiles.length.toString() +
  217. " files in collection " +
  218. collectionID.toString(),
  219. );
  220. Bus.instance.fire(
  221. LocalPhotosUpdatedEvent(
  222. diff.updatedFiles,
  223. source: "syncUpdateFromRemote",
  224. ),
  225. );
  226. Bus.instance.fire(
  227. CollectionUpdatedEvent(
  228. collectionID,
  229. diff.updatedFiles,
  230. "syncUpdateFromRemote",
  231. ),
  232. );
  233. }
  234. if (diff.latestUpdatedAtTime > 0) {
  235. await _collectionsService.setCollectionSyncTime(
  236. collectionID,
  237. diff.latestUpdatedAtTime,
  238. );
  239. }
  240. if (diff.hasMore) {
  241. return await _syncCollectionDiff(
  242. collectionID,
  243. _collectionsService.getCollectionSyncTime(collectionID),
  244. );
  245. } else {
  246. _logger.info("Collection #" + collectionID.toString() + " synced");
  247. }
  248. }
  249. Future<void> _syncCollectionDiffDelete(Diff diff, int collectionID) async {
  250. final fileIDs = diff.deletedFiles.map((f) => f.uploadedFileID!).toList();
  251. final localDeleteCount =
  252. await _db.deleteFilesFromCollection(collectionID, fileIDs);
  253. if (localDeleteCount > 0) {
  254. final collectionFiles =
  255. (await _db.getFilesFromIDs(fileIDs)).values.toList();
  256. collectionFiles.removeWhere((f) => f.collectionID != collectionID);
  257. Bus.instance.fire(
  258. CollectionUpdatedEvent(
  259. collectionID,
  260. collectionFiles,
  261. "syncDeleteFromRemote",
  262. type: EventType.deletedFromRemote,
  263. ),
  264. );
  265. Bus.instance.fire(
  266. LocalPhotosUpdatedEvent(
  267. collectionFiles,
  268. type: EventType.deletedFromRemote,
  269. source: "syncDeleteFromRemote",
  270. ),
  271. );
  272. }
  273. }
  274. Future<void> syncDeviceCollectionFilesForUpload() async {
  275. _logger.info("Syncing device collections to be uploaded");
  276. final int ownerID = _config.getUserID()!;
  277. final deviceCollections = await _db.getDeviceCollections();
  278. deviceCollections.removeWhere((element) => !element.shouldBackup);
  279. // Sort by count to ensure that photos in iOS are first inserted in
  280. // smallest album marked for backup. This is to ensure that photo is
  281. // first attempted to upload in a non-recent album.
  282. deviceCollections.sort((a, b) => a.count.compareTo(b.count));
  283. final Map<String, Set<String>> pathIdToLocalIDs =
  284. await _db.getDevicePathIDToLocalIDMap();
  285. bool moreFilesMarkedForBackup = false;
  286. for (final deviceCollection in deviceCollections) {
  287. final Set<String> localIDsToSync =
  288. pathIdToLocalIDs[deviceCollection.id] ?? {};
  289. if (deviceCollection.uploadStrategy == UploadStrategy.ifMissing) {
  290. final Set<String> alreadyClaimedLocalIDs =
  291. await _db.getLocalIDsMarkedForOrAlreadyUploaded(ownerID);
  292. localIDsToSync.removeAll(alreadyClaimedLocalIDs);
  293. }
  294. if (localIDsToSync.isEmpty) {
  295. continue;
  296. }
  297. final collectionID = await _getCollectionID(deviceCollection);
  298. if (collectionID == null) {
  299. _logger.warning('DeviceCollection was either deleted or missing');
  300. continue;
  301. }
  302. moreFilesMarkedForBackup = true;
  303. await _db.setCollectionIDForUnMappedLocalFiles(
  304. collectionID,
  305. localIDsToSync,
  306. );
  307. // mark IDs as already synced if corresponding entry is present in
  308. // the collection. This can happen when a user has marked a folder
  309. // for sync, then un-synced it and again tries to mark if for sync.
  310. final Set<String> existingMapping =
  311. await _db.getLocalFileIDsForCollection(collectionID);
  312. final Set<String> commonElements =
  313. localIDsToSync.intersection(existingMapping);
  314. if (commonElements.isNotEmpty) {
  315. debugPrint(
  316. "${commonElements.length} files already existing in "
  317. "collection $collectionID for ${deviceCollection.name}",
  318. );
  319. localIDsToSync.removeAll(commonElements);
  320. }
  321. // At this point, the remaining localIDsToSync will need to create
  322. // new file entries, where we can store mapping for localID and
  323. // corresponding collection ID
  324. if (localIDsToSync.isNotEmpty) {
  325. debugPrint(
  326. 'Adding new entries for ${localIDsToSync.length} files'
  327. ' for ${deviceCollection.name}',
  328. );
  329. final filesWithCollectionID =
  330. await _db.getLocalFiles(localIDsToSync.toList());
  331. final List<File> newFilesToInsert = [];
  332. final Set<String> fileFoundForLocalIDs = {};
  333. for (var existingFile in filesWithCollectionID) {
  334. final String localID = existingFile.localID!;
  335. if (!fileFoundForLocalIDs.contains(localID)) {
  336. existingFile.generatedID = null;
  337. existingFile.collectionID = collectionID;
  338. existingFile.uploadedFileID = null;
  339. existingFile.ownerID = null;
  340. newFilesToInsert.add(existingFile);
  341. fileFoundForLocalIDs.add(localID);
  342. }
  343. }
  344. await _db.insertMultiple(newFilesToInsert);
  345. if (fileFoundForLocalIDs.length != localIDsToSync.length) {
  346. _logger.warning(
  347. "mismatch in num of filesToSync ${localIDsToSync.length} to "
  348. "fileSynced ${fileFoundForLocalIDs.length}",
  349. );
  350. }
  351. }
  352. }
  353. if (moreFilesMarkedForBackup && !_config.hasSelectedAllFoldersForBackup()) {
  354. // "force reload due to display new files"
  355. Bus.instance.fire(ForceReloadHomeGalleryEvent("newFilesDisplay"));
  356. }
  357. }
  358. Future<void> updateDeviceFolderSyncStatus(
  359. Map<String, bool> syncStatusUpdate,
  360. ) async {
  361. final Set<int> oldCollectionIDsForAutoSync =
  362. await _db.getDeviceSyncCollectionIDs();
  363. await _db.updateDevicePathSyncStatus(syncStatusUpdate);
  364. final Set<int> newCollectionIDsForAutoSync =
  365. await _db.getDeviceSyncCollectionIDs();
  366. SyncService.instance.onDeviceCollectionSet(newCollectionIDsForAutoSync);
  367. // remove all collectionIDs which are still marked for backup
  368. oldCollectionIDsForAutoSync.removeAll(newCollectionIDsForAutoSync);
  369. await removeFilesQueuedForUpload(oldCollectionIDsForAutoSync.toList());
  370. if (syncStatusUpdate.values.any((syncStatus) => syncStatus == false)) {
  371. Configuration.instance.setSelectAllFoldersForBackup(false).ignore();
  372. }
  373. Bus.instance.fire(
  374. LocalPhotosUpdatedEvent(<File>[], source: "deviceFolderSync"),
  375. );
  376. Bus.instance.fire(BackupFoldersUpdatedEvent());
  377. }
  378. Future<void> removeFilesQueuedForUpload(List<int> collectionIDs) async {
  379. /*
  380. For each collection, perform following action
  381. 1) Get List of all files not uploaded yet
  382. 2) Delete files who localIDs is also present in other collections.
  383. 3) For Remaining files, set the collectionID as -1
  384. */
  385. _logger.info("Removing files for collections $collectionIDs");
  386. for (int collectionID in collectionIDs) {
  387. final List<File> pendingUploads =
  388. await _db.getPendingUploadForCollection(collectionID);
  389. if (pendingUploads.isEmpty) {
  390. continue;
  391. } else {
  392. _logger.info(
  393. "RemovingFiles $collectionIDs: pendingUploads "
  394. "${pendingUploads.length}",
  395. );
  396. }
  397. final Set<String> localIDsInOtherFileEntries =
  398. await _db.getLocalIDsPresentInEntries(
  399. pendingUploads,
  400. collectionID,
  401. );
  402. _logger.info(
  403. "RemovingFiles $collectionIDs: filesInOtherCollection "
  404. "${localIDsInOtherFileEntries.length}",
  405. );
  406. final List<File> entriesToUpdate = [];
  407. final List<int> entriesToDelete = [];
  408. for (File pendingUpload in pendingUploads) {
  409. if (localIDsInOtherFileEntries.contains(pendingUpload.localID)) {
  410. entriesToDelete.add(pendingUpload.generatedID!);
  411. } else {
  412. pendingUpload.collectionID = null;
  413. entriesToUpdate.add(pendingUpload);
  414. }
  415. }
  416. await _db.deleteMultipleByGeneratedIDs(entriesToDelete);
  417. await _db.insertMultiple(entriesToUpdate);
  418. _logger.info(
  419. "RemovingFiles $collectionIDs: deleted "
  420. "${entriesToDelete.length} and updated ${entriesToUpdate.length}",
  421. );
  422. }
  423. }
  424. Future<int?> _getCollectionID(DeviceCollection deviceCollection) async {
  425. if (deviceCollection.hasCollectionID()) {
  426. final collection =
  427. _collectionsService.getCollectionByID(deviceCollection.collectionID!);
  428. if (collection != null && !collection.isDeleted) {
  429. return collection.id;
  430. }
  431. if (collection == null) {
  432. // ideally, this should never happen because the app keeps a track of
  433. // all collections and their IDs. But, if somehow the collection is
  434. // deleted, we should fetch it again
  435. _logger.severe(
  436. "Collection ${deviceCollection.collectionID} missing "
  437. "for pathID ${deviceCollection.id}",
  438. );
  439. _collectionsService
  440. .fetchCollectionByID(deviceCollection.collectionID!)
  441. .ignore();
  442. // return, by next run collection should be available.
  443. // we are not waiting on fetch by choice because device might have wrong
  444. // mapping which will result in breaking upload for other device path
  445. return null;
  446. } else if (collection.isDeleted) {
  447. _logger.warning("Collection ${deviceCollection.collectionID} deleted "
  448. "for pathID ${deviceCollection.id}, new collection will be created");
  449. }
  450. }
  451. final collection =
  452. await _collectionsService.getOrCreateForPath(deviceCollection.name);
  453. await _db.updateDeviceCollection(deviceCollection.id, collection.id);
  454. return collection.id;
  455. }
  456. Future<List<File>> _getFilesToBeUploaded() async {
  457. final deviceCollections = await _db.getDeviceCollections();
  458. deviceCollections.removeWhere((element) => !element.shouldBackup);
  459. final List<File> filesToBeUploaded = await _db.getFilesPendingForUpload();
  460. if (!_config.shouldBackupVideos() || _shouldThrottleSync()) {
  461. filesToBeUploaded
  462. .removeWhere((element) => element.fileType == FileType.video);
  463. }
  464. if (filesToBeUploaded.isNotEmpty) {
  465. final int prevCount = filesToBeUploaded.length;
  466. final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs;
  467. filesToBeUploaded.removeWhere(
  468. (file) =>
  469. IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, file),
  470. );
  471. if (prevCount != filesToBeUploaded.length) {
  472. _logger.info(
  473. (prevCount - filesToBeUploaded.length).toString() +
  474. " files were ignored for upload",
  475. );
  476. }
  477. }
  478. _sortByTimeAndType(filesToBeUploaded);
  479. _logger.info(
  480. filesToBeUploaded.length.toString() + " new files to be uploaded.",
  481. );
  482. return filesToBeUploaded;
  483. }
  484. Future<bool> _uploadFiles(List<File> filesToBeUploaded) async {
  485. final int ownerID = _config.getUserID()!;
  486. final updatedFileIDs = await _db.getUploadedFileIDsToBeUpdated(ownerID);
  487. if (updatedFileIDs.isNotEmpty) {
  488. _logger.info("Identified ${updatedFileIDs.length} files for reupload");
  489. }
  490. _completedUploads = 0;
  491. final int toBeUploaded = filesToBeUploaded.length + updatedFileIDs.length;
  492. if (toBeUploaded > 0) {
  493. Bus.instance.fire(SyncStatusUpdate(SyncStatus.preparingForUpload));
  494. await _uploader.checkNetworkForUpload();
  495. // verify if files upload is allowed based on their subscription plan and
  496. // storage limit. To avoid creating new endpoint, we are using
  497. // fetchUploadUrls as alternative method.
  498. await _uploader.fetchUploadURLs(toBeUploaded);
  499. }
  500. final List<Future> futures = [];
  501. for (final uploadedFileID in updatedFileIDs) {
  502. if (_shouldThrottleSync() &&
  503. futures.length >= kMaximumPermissibleUploadsInThrottledMode) {
  504. _logger
  505. .info("Skipping some updated files as we are throttling uploads");
  506. break;
  507. }
  508. final file = await _db.getUploadedLocalFileInAnyCollection(
  509. uploadedFileID,
  510. ownerID,
  511. );
  512. if (file != null) {
  513. _uploadFile(file, file.collectionID!, futures);
  514. }
  515. }
  516. for (final file in filesToBeUploaded) {
  517. if (_shouldThrottleSync() &&
  518. futures.length >= kMaximumPermissibleUploadsInThrottledMode) {
  519. _logger.info("Skipping some new files as we are throttling uploads");
  520. break;
  521. }
  522. // prefer existing collection ID for manually uploaded files.
  523. // See https://github.com/ente-io/photos-app/pull/187
  524. final collectionID = file.collectionID ??
  525. (await _collectionsService
  526. .getOrCreateForPath(file.deviceFolder ?? 'Unknown Folder'))
  527. .id;
  528. _uploadFile(file, collectionID, futures);
  529. }
  530. try {
  531. await Future.wait(futures);
  532. } on InvalidFileError {
  533. // Do nothing
  534. } on FileSystemException {
  535. // Do nothing since it's caused mostly due to concurrency issues
  536. // when the foreground app deletes temporary files, interrupting a background
  537. // upload
  538. } on LockAlreadyAcquiredError {
  539. // Do nothing
  540. } on SilentlyCancelUploadsError {
  541. // Do nothing
  542. } on UserCancelledUploadError {
  543. // Do nothing
  544. } catch (e) {
  545. rethrow;
  546. }
  547. return _completedUploads > 0;
  548. }
  549. void _uploadFile(File file, int collectionID, List<Future> futures) {
  550. final future = _uploader
  551. .upload(file, collectionID)
  552. .then((uploadedFile) => _onFileUploaded(uploadedFile));
  553. futures.add(future);
  554. }
  555. Future<void> _onFileUploaded(File file) async {
  556. Bus.instance.fire(
  557. CollectionUpdatedEvent(file.collectionID, [file], "fileUpload"),
  558. );
  559. _completedUploads++;
  560. final toBeUploadedInThisSession = _uploader.getCurrentSessionUploadCount();
  561. if (toBeUploadedInThisSession == 0) {
  562. return;
  563. }
  564. if (_completedUploads > toBeUploadedInThisSession ||
  565. _completedUploads < 0 ||
  566. toBeUploadedInThisSession < 0) {
  567. _logger.info(
  568. "Incorrect sync status",
  569. InvalidSyncStatusError(
  570. "Tried to report $_completedUploads as "
  571. "uploaded out of $toBeUploadedInThisSession",
  572. ),
  573. );
  574. return;
  575. }
  576. Bus.instance.fire(
  577. SyncStatusUpdate(
  578. SyncStatus.inProgress,
  579. completed: _completedUploads,
  580. total: toBeUploadedInThisSession,
  581. ),
  582. );
  583. }
  584. /* _storeDiff maps each remoteDiff file to existing
  585. entries in files table. When match is found, it compares both file to
  586. perform relevant actions like
  587. [1] Clear local cache when required (Both Shared and Owned files)
  588. [2] Retain localID of remote file based on matching logic [Owned files]
  589. [3] Refresh UI if visibility or creationTime has changed [Owned files]
  590. [4] Schedule file update if the local file has changed since last time
  591. [Owned files]
  592. [Important Note: If given uploadedFileID and collectionID is already present
  593. in files db, the generateID should already point to existing entry.
  594. Known Issues:
  595. [K1] Cached entry will not be cleared when if a file was edited and
  596. moved to different collection as Vid/Image cache key is uploadedID.
  597. [Existing]
  598. ]
  599. */
  600. Future _storeDiff(List<File> diff, int collectionID) async {
  601. int sharedFileNew = 0,
  602. sharedFileUpdated = 0,
  603. localUploadedFromDevice = 0,
  604. localButUpdatedOnDevice = 0,
  605. remoteNewFile = 0;
  606. final int userID = _config.getUserID()!;
  607. bool needsGalleryReload = false;
  608. // this is required when same file is uploaded twice in the same
  609. // collection. Without this check, if both remote files are part of same
  610. // diff response, then we end up inserting one entry instead of two
  611. // as we update the generatedID for remoteDiff to local file's genID
  612. final Set<int> alreadyClaimedLocalFilesGenID = {};
  613. final List<File> toBeInserted = [];
  614. for (File remoteDiff in diff) {
  615. // existingFile will be either set to existing collectionID+localID or
  616. // to the unclaimed aka not already linked to any uploaded file.
  617. File? existingFile;
  618. if (remoteDiff.generatedID != null) {
  619. // Case [1] Check and clear local cache when uploadedFile already exist
  620. // Note: Existing file can be null here if it's replaced by the time we
  621. // reach here
  622. existingFile = await _db.getFile(remoteDiff.generatedID!);
  623. if (existingFile != null &&
  624. _shouldClearCache(remoteDiff, existingFile)) {
  625. needsGalleryReload = true;
  626. await clearCache(remoteDiff);
  627. }
  628. }
  629. /* If file is not owned by the user, no further processing is required
  630. as Case [2,3,4] are only relevant to files owned by user
  631. */
  632. if (userID != remoteDiff.ownerID) {
  633. if (existingFile == null) {
  634. sharedFileNew++;
  635. remoteDiff.localID = null;
  636. } else {
  637. sharedFileUpdated++;
  638. // if user has downloaded the file on the device, avoid removing the
  639. // localID reference.
  640. // [Todo-fix: Excluded shared file's localIDs during syncALL]
  641. remoteDiff.localID = existingFile.localID;
  642. }
  643. toBeInserted.add(remoteDiff);
  644. // end processing for file here, move to next file now
  645. continue;
  646. }
  647. // If remoteDiff is not already synced (i.e. existingFile is null), check
  648. // if the remoteFile was uploaded from this device.
  649. // Note: DeviceFolder is ignored for iOS during matching
  650. if (existingFile == null && remoteDiff.localID != null) {
  651. final localFileEntries = await _db.getUnlinkedLocalMatchesForRemoteFile(
  652. userID,
  653. remoteDiff.localID!,
  654. remoteDiff.fileType,
  655. title: remoteDiff.title ?? '',
  656. deviceFolder: remoteDiff.deviceFolder ?? '',
  657. );
  658. if (localFileEntries.isEmpty) {
  659. // set remote file's localID as null because corresponding local file
  660. // does not exist [Case 2, do not retain localID of the remote file]
  661. remoteDiff.localID = null;
  662. } else {
  663. // case 4: Check and schedule the file for update
  664. final int maxModificationTime = localFileEntries
  665. .map(
  666. (e) => e.modificationTime ?? 0,
  667. )
  668. .reduce(max);
  669. /* Note: In case of iOS, we will miss any asset modification in
  670. between of two installation. This is done to avoid fetching assets
  671. from iCloud when modification time could have changed for number of
  672. reasons. To fix this, we need to identify a way to store version
  673. for the adjustments or just if the asset has been modified ever.
  674. https://stackoverflow.com/a/50093266/546896
  675. */
  676. if (maxModificationTime > remoteDiff.modificationTime! &&
  677. Platform.isAndroid) {
  678. localButUpdatedOnDevice++;
  679. await FileUpdationDB.instance.insertMultiple(
  680. [remoteDiff.localID!],
  681. FileUpdationDB.modificationTimeUpdated,
  682. );
  683. }
  684. localFileEntries.removeWhere(
  685. (e) =>
  686. e.uploadedFileID != null ||
  687. alreadyClaimedLocalFilesGenID.contains(e.generatedID),
  688. );
  689. if (localFileEntries.isNotEmpty) {
  690. // file uploaded from same device, replace the local file row by
  691. // setting the generated ID of remoteFile to localFile generatedID
  692. existingFile = localFileEntries.first;
  693. localUploadedFromDevice++;
  694. alreadyClaimedLocalFilesGenID.add(existingFile.generatedID!);
  695. remoteDiff.generatedID = existingFile.generatedID;
  696. }
  697. }
  698. }
  699. if (existingFile != null &&
  700. _shouldReloadHomeGallery(remoteDiff, existingFile)) {
  701. needsGalleryReload = true;
  702. } else {
  703. remoteNewFile++;
  704. }
  705. toBeInserted.add(remoteDiff);
  706. }
  707. await _db.insertMultiple(toBeInserted);
  708. _logger.info(
  709. "Diff to be deduplicated was: " +
  710. diff.length.toString() +
  711. " out of which \n" +
  712. localUploadedFromDevice.toString() +
  713. " was uploaded from device, \n" +
  714. localButUpdatedOnDevice.toString() +
  715. " was uploaded from device, but has been updated since and should be reuploaded, \n" +
  716. sharedFileNew.toString() +
  717. " new sharedFiles, \n" +
  718. sharedFileUpdated.toString() +
  719. " updatedSharedFiles, and \n" +
  720. remoteNewFile.toString() +
  721. " remoteFiles seen first time",
  722. );
  723. if (needsGalleryReload) {
  724. // 'force reload home gallery'
  725. Bus.instance.fire(ForceReloadHomeGalleryEvent("remoteSync"));
  726. }
  727. }
  728. bool _shouldClearCache(File remoteFile, File existingFile) {
  729. if (remoteFile.hash != null && existingFile.hash != null) {
  730. return remoteFile.hash != existingFile.hash;
  731. }
  732. return remoteFile.updationTime != (existingFile.updationTime ?? 0);
  733. }
  734. bool _shouldReloadHomeGallery(File remoteFile, File existingFile) {
  735. int remoteCreationTime = remoteFile.creationTime!;
  736. if (remoteFile.pubMmdVersion > 0 &&
  737. (remoteFile.pubMagicMetadata?.editedTime ?? 0) != 0) {
  738. remoteCreationTime = remoteFile.pubMagicMetadata!.editedTime!;
  739. }
  740. if (remoteCreationTime != existingFile.creationTime) {
  741. return true;
  742. }
  743. if (existingFile.mMdVersion > 0 &&
  744. remoteFile.mMdVersion != existingFile.mMdVersion &&
  745. remoteFile.magicMetadata.visibility !=
  746. existingFile.magicMetadata.visibility) {
  747. return false;
  748. }
  749. return false;
  750. }
  751. // return true if the client needs to re-sync the collections from previous
  752. // version
  753. bool _shouldResetSyncTime() {
  754. return !_prefs.containsKey(kHasSyncedEditTime) ||
  755. !_prefs.containsKey(kHasSyncedArchiveKey);
  756. }
  757. Future<void> _markResetSyncTimeAsDone() async {
  758. await _prefs.setBool(kHasSyncedArchiveKey, true);
  759. await _prefs.setBool(kHasSyncedEditTime, true);
  760. // Check to avoid regression because of change or additions of keys
  761. if (_shouldResetSyncTime()) {
  762. throw Exception("_shouldResetSyncTime should return false now");
  763. }
  764. }
  765. int _getSinceTimeForReSync() {
  766. // re-sync from archive feature time if the client still hasn't synced
  767. // since the feature release.
  768. if (!_prefs.containsKey(kHasSyncedArchiveKey)) {
  769. return kArchiveFeatureReleaseTime;
  770. }
  771. return kEditTimeFeatureReleaseTime;
  772. }
  773. bool _shouldThrottleSync() {
  774. return Platform.isIOS && !AppLifecycleService.instance.isForeground;
  775. }
  776. // _sortByTimeAndType moves videos to end and sort by creation time (desc).
  777. // This is done to upload most recent photo first.
  778. void _sortByTimeAndType(List<File> file) {
  779. file.sort((first, second) {
  780. if (first.fileType == second.fileType) {
  781. return second.creationTime!.compareTo(first.creationTime!);
  782. } else if (first.fileType == FileType.video) {
  783. return 1;
  784. } else {
  785. return -1;
  786. }
  787. });
  788. // move updated files towards the end
  789. file.sort((first, second) {
  790. if (first.updationTime == second.updationTime) {
  791. return 0;
  792. }
  793. if (first.updationTime == -1) {
  794. return 1;
  795. } else {
  796. return -1;
  797. }
  798. });
  799. }
  800. }