remote_sync_service.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import 'dart:async';
  2. import 'dart:io';
  3. import 'dart:math';
  4. import 'package:logging/logging.dart';
  5. import 'package:photos/core/configuration.dart';
  6. import 'package:photos/core/errors.dart';
  7. import 'package:photos/core/event_bus.dart';
  8. import 'package:photos/db/files_db.dart';
  9. import 'package:photos/events/collection_updated_event.dart';
  10. import 'package:photos/events/files_updated_event.dart';
  11. import 'package:photos/events/force_reload_home_gallery_event.dart';
  12. import 'package:photos/events/local_photos_updated_event.dart';
  13. import 'package:photos/events/sync_status_update_event.dart';
  14. import 'package:photos/models/file.dart';
  15. import 'package:photos/models/file_type.dart';
  16. import 'package:photos/services/app_lifecycle_service.dart';
  17. import 'package:photos/services/collections_service.dart';
  18. import 'package:photos/services/ignored_files_service.dart';
  19. import 'package:photos/services/local_sync_service.dart';
  20. import 'package:photos/services/trash_sync_service.dart';
  21. import 'package:photos/utils/diff_fetcher.dart';
  22. import 'package:photos/utils/file_uploader.dart';
  23. import 'package:photos/utils/file_util.dart';
  24. import 'package:shared_preferences/shared_preferences.dart';
  25. class RemoteSyncService {
  26. final _logger = Logger("RemoteSyncService");
  27. final _db = FilesDB.instance;
  28. final _uploader = FileUploader.instance;
  29. final _collectionsService = CollectionsService.instance;
  30. final _diffFetcher = DiffFetcher();
  31. int _completedUploads = 0;
  32. SharedPreferences _prefs;
  33. Completer<void> _existingSync;
  34. static const kHasSyncedArchiveKey = "has_synced_archive";
  35. // 28 Sept, 2021 9:03:20 AM IST
  36. static const kArchiveFeatureReleaseTime = 1632800000000000;
  37. static const kHasSyncedEditTime = "has_synced_edit_time";
  38. // 29 October, 2021 3:56:40 AM IST
  39. static const kEditTimeFeatureReleaseTime = 1635460000000000;
  40. static const kMaximumPermissibleUploadsInThrottledMode = 4;
  41. static const kFileUploadTimeout = Duration(minutes: 50);
  42. static final RemoteSyncService instance =
  43. RemoteSyncService._privateConstructor();
  44. RemoteSyncService._privateConstructor();
  45. Future<void> init() async {
  46. _prefs = await SharedPreferences.getInstance();
  47. }
  48. Future<void> sync({bool silently = false}) async {
  49. if (!Configuration.instance.hasConfiguredAccount()) {
  50. _logger.info("Skipping remote sync since account is not configured");
  51. return;
  52. }
  53. if (_existingSync != null) {
  54. _logger.info("Remote sync already in progress, skipping");
  55. return _existingSync.future;
  56. }
  57. _existingSync = Completer<void>();
  58. bool isFirstSync = !_collectionsService.hasSyncedCollections();
  59. await _collectionsService.sync();
  60. if (isFirstSync || _hasReSynced()) {
  61. await _syncUpdatedCollections(silently);
  62. } else {
  63. final syncSinceTime = _getSinceTimeForReSync();
  64. await _resyncAllCollectionsSinceTime(syncSinceTime);
  65. }
  66. if (!_hasReSynced()) {
  67. await _markReSyncAsDone();
  68. }
  69. // sync trash but consume error during initial launch.
  70. // this is to ensure that we don't pause upload due to any error during
  71. // the trash sync. Impact: We may end up re-uploading a file which was
  72. // recently trashed.
  73. await TrashSyncService.instance
  74. .syncTrash()
  75. .onError((e, s) => _logger.severe('trash sync failed', e, s));
  76. bool hasUploadedFiles = await _uploadDiff();
  77. _existingSync.complete();
  78. _existingSync = null;
  79. if (hasUploadedFiles && !_shouldThrottleSync()) {
  80. // Skipping a resync to ensure that files that were ignored in this
  81. // session are not processed now
  82. sync(silently: true);
  83. }
  84. }
  85. Future<void> _syncUpdatedCollections(bool silently) async {
  86. final updatedCollections =
  87. await _collectionsService.getCollectionsToBeSynced();
  88. if (updatedCollections.isNotEmpty && !silently) {
  89. Bus.instance.fire(SyncStatusUpdate(SyncStatus.applying_remote_diff));
  90. }
  91. for (final c in updatedCollections) {
  92. await _syncCollectionDiff(
  93. c.id, _collectionsService.getCollectionSyncTime(c.id));
  94. await _collectionsService.setCollectionSyncTime(c.id, c.updationTime);
  95. }
  96. }
  97. Future<void> _resyncAllCollectionsSinceTime(int sinceTime) async {
  98. _logger.info('re-sync collections sinceTime: $sinceTime');
  99. final collections = _collectionsService.getActiveCollections();
  100. for (final c in collections) {
  101. await _syncCollectionDiff(c.id,
  102. min(_collectionsService.getCollectionSyncTime(c.id), sinceTime));
  103. await _collectionsService.setCollectionSyncTime(c.id, c.updationTime);
  104. }
  105. }
  106. Future<void> _syncCollectionDiff(int collectionID, int sinceTime) async {
  107. final diff =
  108. await _diffFetcher.getEncryptedFilesDiff(collectionID, sinceTime);
  109. if (diff.deletedFiles.isNotEmpty) {
  110. final fileIDs = diff.deletedFiles.map((f) => f.uploadedFileID).toList();
  111. final deletedFiles =
  112. (await FilesDB.instance.getFilesFromIDs(fileIDs)).values.toList();
  113. await FilesDB.instance.deleteFilesFromCollection(collectionID, fileIDs);
  114. Bus.instance.fire(CollectionUpdatedEvent(collectionID, deletedFiles,
  115. type: EventType.deletedFromRemote));
  116. Bus.instance.fire(LocalPhotosUpdatedEvent(deletedFiles,
  117. type: EventType.deletedFromRemote));
  118. }
  119. if (diff.updatedFiles.isNotEmpty) {
  120. await _storeDiff(diff.updatedFiles, collectionID);
  121. _logger.info("Updated " +
  122. diff.updatedFiles.length.toString() +
  123. " files in collection " +
  124. collectionID.toString());
  125. Bus.instance.fire(LocalPhotosUpdatedEvent(diff.updatedFiles));
  126. Bus.instance
  127. .fire(CollectionUpdatedEvent(collectionID, diff.updatedFiles));
  128. }
  129. if (diff.latestUpdatedAtTime > 0) {
  130. await _collectionsService.setCollectionSyncTime(
  131. collectionID, diff.latestUpdatedAtTime);
  132. }
  133. if (diff.hasMore) {
  134. return await _syncCollectionDiff(collectionID,
  135. _collectionsService.getCollectionSyncTime(collectionID));
  136. }
  137. }
  138. Future<bool> _uploadDiff() async {
  139. final foldersToBackUp = Configuration.instance.getPathsToBackUp();
  140. List<File> filesToBeUploaded;
  141. if (LocalSyncService.instance.hasGrantedLimitedPermissions() &&
  142. foldersToBackUp.isEmpty) {
  143. filesToBeUploaded = await _db.getAllLocalFiles();
  144. } else {
  145. filesToBeUploaded =
  146. await _db.getFilesToBeUploadedWithinFolders(foldersToBackUp);
  147. }
  148. if (!Configuration.instance.shouldBackupVideos() || _shouldThrottleSync()) {
  149. filesToBeUploaded
  150. .removeWhere((element) => element.fileType == FileType.video);
  151. }
  152. if (filesToBeUploaded.isNotEmpty) {
  153. final int prevCount = filesToBeUploaded.length;
  154. final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs;
  155. filesToBeUploaded.removeWhere((file) =>
  156. IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, file));
  157. if (prevCount != filesToBeUploaded.length) {
  158. _logger.info((prevCount - filesToBeUploaded.length).toString() +
  159. " files were ignored for upload");
  160. }
  161. }
  162. _logger.info(
  163. filesToBeUploaded.length.toString() + " new files to be uploaded.");
  164. final updatedFileIDs = await _db.getUploadedFileIDsToBeUpdated();
  165. _logger.info(updatedFileIDs.length.toString() + " files updated.");
  166. final editedFiles = await _db.getEditedRemoteFiles();
  167. _logger.info(editedFiles.length.toString() + " files edited.");
  168. _completedUploads = 0;
  169. int toBeUploaded =
  170. filesToBeUploaded.length + updatedFileIDs.length + editedFiles.length;
  171. if (toBeUploaded > 0) {
  172. Bus.instance.fire(SyncStatusUpdate(SyncStatus.preparing_for_upload));
  173. }
  174. final List<Future> futures = [];
  175. for (final uploadedFileID in updatedFileIDs) {
  176. if (_shouldThrottleSync() &&
  177. futures.length == kMaximumPermissibleUploadsInThrottledMode) {
  178. _logger
  179. .info("Skipping some updated files as we are throttling uploads");
  180. break;
  181. }
  182. final file = await _db.getUploadedFileInAnyCollection(uploadedFileID);
  183. _uploadFile(file, file.collectionID, futures);
  184. }
  185. for (final file in filesToBeUploaded) {
  186. if (_shouldThrottleSync() &&
  187. futures.length == kMaximumPermissibleUploadsInThrottledMode) {
  188. _logger.info("Skipping some new files as we are throttling uploads");
  189. break;
  190. }
  191. final collectionID = (await CollectionsService.instance
  192. .getOrCreateForPath(file.deviceFolder))
  193. .id;
  194. _uploadFile(file, collectionID, futures);
  195. }
  196. for (final file in editedFiles) {
  197. if (_shouldThrottleSync() &&
  198. futures.length == kMaximumPermissibleUploadsInThrottledMode) {
  199. _logger.info("Skipping some edited files as we are throttling uploads");
  200. break;
  201. }
  202. _uploadFile(file, file.collectionID, futures);
  203. }
  204. try {
  205. await Future.wait(futures);
  206. } on InvalidFileError {
  207. // Do nothing
  208. } on FileSystemException {
  209. // Do nothing since it's caused mostly due to concurrency issues
  210. // when the foreground app deletes temporary files, interrupting a background
  211. // upload
  212. } on LockAlreadyAcquiredError {
  213. // Do nothing
  214. } on SilentlyCancelUploadsError {
  215. // Do nothing
  216. } on UserCancelledUploadError {
  217. // Do nothing
  218. } catch (e) {
  219. rethrow;
  220. }
  221. return _completedUploads > 0;
  222. }
  223. void _uploadFile(File file, int collectionID, List<Future> futures) {
  224. final future = _uploader
  225. .upload(file, collectionID)
  226. .timeout(kFileUploadTimeout, onTimeout: () async {
  227. final message = "Upload timed out for file " + file.toString();
  228. _logger.severe(message);
  229. throw TimeoutException(message);
  230. }).then((uploadedFile) => _onFileUploaded(uploadedFile));
  231. futures.add(future);
  232. }
  233. Future<void> _onFileUploaded(File file) async {
  234. Bus.instance.fire(CollectionUpdatedEvent(file.collectionID, [file]));
  235. _completedUploads++;
  236. final toBeUploadedInThisSession =
  237. FileUploader.instance.getCurrentSessionUploadCount();
  238. if (toBeUploadedInThisSession == 0) {
  239. return;
  240. }
  241. if (_completedUploads > toBeUploadedInThisSession ||
  242. _completedUploads < 0 ||
  243. toBeUploadedInThisSession < 0) {
  244. _logger.info(
  245. "Incorrect sync status",
  246. InvalidSyncStatusError("Tried to report $_completedUploads as "
  247. "uploaded out of $toBeUploadedInThisSession"));
  248. return;
  249. }
  250. Bus.instance.fire(SyncStatusUpdate(SyncStatus.in_progress,
  251. completed: _completedUploads, total: toBeUploadedInThisSession));
  252. }
  253. Future _storeDiff(List<File> diff, int collectionID) async {
  254. int existing = 0,
  255. updated = 0,
  256. remote = 0,
  257. localButUpdatedOnRemote = 0,
  258. localButAddedToNewCollectionOnRemote = 0;
  259. bool hasAnyCreationTimeChanged = false;
  260. List<File> toBeInserted = [];
  261. for (File file in diff) {
  262. final existingFiles = file.deviceFolder == null
  263. ? null
  264. : await _db.getMatchingFiles(file.title, file.deviceFolder);
  265. if (existingFiles == null || existingFiles.isEmpty) {
  266. // File uploaded from a different device.
  267. // Other rare possibilities : The local file is present on
  268. // device but it's not imported in local db due to missing permission
  269. // after reinstall (iOS selected file permissions or user revoking
  270. // permissions, or issue/delay in importing devices files.
  271. file.localID = null;
  272. toBeInserted.add(file);
  273. remote++;
  274. } else {
  275. // File exists in ente db with same title & device folder
  276. // Note: The file.generatedID might be already set inside
  277. // [DiffFetcher.getEncryptedFilesDiff]
  278. final fileWithLocalID = existingFiles
  279. .firstWhere((e) => e.localID != null, orElse: () => null);
  280. if (fileWithLocalID != null) {
  281. // File should ideally have the same localID
  282. if (file.localID != null && file.localID != fileWithLocalID.localID) {
  283. _logger.severe(
  284. "unexpected mismatch in localIDs remote: ${file.toString()} and existing: ${fileWithLocalID.toString()}");
  285. }
  286. file.localID = fileWithLocalID.localID;
  287. } else {
  288. file.localID = null;
  289. }
  290. bool wasUploadedOnAPreviousInstallation =
  291. existingFiles.length == 1 && existingFiles[0].collectionID == null;
  292. if (wasUploadedOnAPreviousInstallation) {
  293. file.generatedID = existingFiles[0].generatedID;
  294. if (file.modificationTime != existingFiles[0].modificationTime) {
  295. // File was updated since the app was uninstalled
  296. _logger.info("Updated since last installation: " +
  297. file.uploadedFileID.toString());
  298. file.updationTime = null;
  299. updated++;
  300. } else {
  301. existing++;
  302. }
  303. toBeInserted.add(file);
  304. } else {
  305. bool foundMatchingCollection = false;
  306. for (final existingFile in existingFiles) {
  307. if (file.collectionID == existingFile.collectionID &&
  308. file.uploadedFileID == existingFile.uploadedFileID) {
  309. // File was updated on remote
  310. if (file.creationTime != existingFile.creationTime) {
  311. hasAnyCreationTimeChanged = true;
  312. }
  313. foundMatchingCollection = true;
  314. file.generatedID = existingFile.generatedID;
  315. toBeInserted.add(file);
  316. await clearCache(file);
  317. localButUpdatedOnRemote++;
  318. break;
  319. }
  320. }
  321. if (!foundMatchingCollection) {
  322. // Added to a new collection
  323. toBeInserted.add(file);
  324. localButAddedToNewCollectionOnRemote++;
  325. }
  326. }
  327. }
  328. }
  329. await _db.insertMultiple(toBeInserted);
  330. _logger.info(
  331. "Diff to be deduplicated was: " +
  332. diff.length.toString() +
  333. " out of which \n" +
  334. existing.toString() +
  335. " was uploaded from device, \n" +
  336. updated.toString() +
  337. " was uploaded from device, but has been updated since and should be reuploaded, \n" +
  338. remote.toString() +
  339. " was uploaded from remote, \n" +
  340. localButUpdatedOnRemote.toString() +
  341. " was uploaded from device but updated on remote, and \n" +
  342. localButAddedToNewCollectionOnRemote.toString() +
  343. " was uploaded from device but added to a new collection on remote.",
  344. );
  345. if (hasAnyCreationTimeChanged) {
  346. Bus.instance.fire(ForceReloadHomeGalleryEvent());
  347. }
  348. }
  349. // return true if the client needs to re-sync the collections from previous
  350. // version
  351. bool _hasReSynced() {
  352. return _prefs.containsKey(kHasSyncedEditTime) &&
  353. _prefs.containsKey(kHasSyncedArchiveKey);
  354. }
  355. Future<void> _markReSyncAsDone() async {
  356. await _prefs.setBool(kHasSyncedArchiveKey, true);
  357. await _prefs.setBool(kHasSyncedEditTime, true);
  358. }
  359. int _getSinceTimeForReSync() {
  360. // re-sync from archive feature time if the client still hasn't synced
  361. // since the feature release.
  362. if (!_prefs.containsKey(kHasSyncedArchiveKey)) {
  363. return kArchiveFeatureReleaseTime;
  364. }
  365. return kEditTimeFeatureReleaseTime;
  366. }
  367. bool _shouldThrottleSync() {
  368. return Platform.isIOS && !AppLifecycleService.instance.isForeground;
  369. }
  370. }