remote_sync_service.dart 36 KB

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