remote_sync_service.dart 36 KB

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