remote_sync_service.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  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/local_photos_updated_event.dart';
  12. import 'package:photos/events/sync_status_update_event.dart';
  13. import 'package:photos/models/file.dart';
  14. import 'package:photos/models/file_type.dart';
  15. import 'package:photos/services/collections_service.dart';
  16. import 'package:photos/services/local_sync_service.dart';
  17. import 'package:photos/services/trash_sync_service.dart';
  18. import 'package:photos/utils/diff_fetcher.dart';
  19. import 'package:photos/utils/file_uploader.dart';
  20. import 'package:photos/utils/file_util.dart';
  21. import 'package:shared_preferences/shared_preferences.dart';
  22. class RemoteSyncService {
  23. final _logger = Logger("RemoteSyncService");
  24. final _db = FilesDB.instance;
  25. final _uploader = FileUploader.instance;
  26. final _collectionsService = CollectionsService.instance;
  27. final _diffFetcher = DiffFetcher();
  28. int _completedUploads = 0;
  29. SharedPreferences _prefs;
  30. static const kDiffLimit = 2500;
  31. static const kHasSyncedArchiveKey = "has_synced_archive";
  32. // 28 Sept, 2021 9:03:20 AM IST
  33. static const kArchiveFeatureReleaseTime = 1632800000000000;
  34. static final RemoteSyncService instance =
  35. RemoteSyncService._privateConstructor();
  36. RemoteSyncService._privateConstructor();
  37. Future<void> init() async {
  38. _prefs = await SharedPreferences.getInstance();
  39. }
  40. Future<void> sync({bool silently = false}) async {
  41. if (!Configuration.instance.hasConfiguredAccount()) {
  42. _logger.info("Skipping remote sync since account is not configured");
  43. return;
  44. }
  45. bool isFirstSync = !_collectionsService.hasSyncedCollections();
  46. await _collectionsService.sync();
  47. if (isFirstSync || _hasSyncedArchive()) {
  48. await _syncUpdatedCollections(silently);
  49. } else {
  50. await _resyncAllCollectionsSinceTime(kArchiveFeatureReleaseTime);
  51. }
  52. if (!_hasSyncedArchive()) {
  53. await _markArchiveAsSynced();
  54. }
  55. // sync trash but consume error during initial launch.
  56. // this is to ensure that we don't pause upload due to any error during
  57. // the trash sync. Impact: We may end up re-uploading a file which was
  58. // recently trashed.
  59. await TrashSyncService.instance.syncTrash()
  60. .onError((e, s) => _logger.severe('trash sync failed', e, s));
  61. bool hasUploadedFiles = await _uploadDiff();
  62. if (hasUploadedFiles) {
  63. sync(silently: true);
  64. }
  65. }
  66. Future<void> _syncUpdatedCollections(bool silently) async {
  67. final updatedCollections =
  68. await _collectionsService.getCollectionsToBeSynced();
  69. if (updatedCollections.isNotEmpty && !silently) {
  70. Bus.instance.fire(SyncStatusUpdate(SyncStatus.applying_remote_diff));
  71. }
  72. for (final c in updatedCollections) {
  73. await _syncCollectionDiff(
  74. c.id, _collectionsService.getCollectionSyncTime(c.id));
  75. await _collectionsService.setCollectionSyncTime(c.id, c.updationTime);
  76. }
  77. }
  78. Future<void> _resyncAllCollectionsSinceTime(int sinceTime) async {
  79. final collections = _collectionsService.getCollections();
  80. for (final c in collections) {
  81. await _syncCollectionDiff(c.id,
  82. min(_collectionsService.getCollectionSyncTime(c.id), sinceTime));
  83. await _collectionsService.setCollectionSyncTime(c.id, c.updationTime);
  84. }
  85. }
  86. Future<void> _syncCollectionDiff(int collectionID, int sinceTime) async {
  87. final diff = await _diffFetcher.getEncryptedFilesDiff(
  88. collectionID, sinceTime, kDiffLimit);
  89. if (diff.deletedFiles.isNotEmpty) {
  90. final fileIDs = diff.deletedFiles.map((f) => f.uploadedFileID).toList();
  91. final deletedFiles =
  92. (await FilesDB.instance.getFilesFromIDs(fileIDs)).values.toList();
  93. await FilesDB.instance.deleteFilesFromCollection(collectionID, fileIDs);
  94. Bus.instance.fire(CollectionUpdatedEvent(collectionID, deletedFiles,
  95. type: EventType.deleted));
  96. Bus.instance
  97. .fire(LocalPhotosUpdatedEvent(deletedFiles, type: EventType.deleted));
  98. }
  99. if (diff.updatedFiles.isNotEmpty) {
  100. await _storeDiff(diff.updatedFiles, collectionID);
  101. _logger.info("Updated " +
  102. diff.updatedFiles.length.toString() +
  103. " files in collection " +
  104. collectionID.toString());
  105. Bus.instance.fire(LocalPhotosUpdatedEvent(diff.updatedFiles));
  106. Bus.instance
  107. .fire(CollectionUpdatedEvent(collectionID, diff.updatedFiles));
  108. }
  109. if (diff.fetchCount == kDiffLimit) {
  110. return await _syncCollectionDiff(collectionID,
  111. _collectionsService.getCollectionSyncTime(collectionID));
  112. }
  113. }
  114. Future<bool> _uploadDiff() async {
  115. final foldersToBackUp = Configuration.instance.getPathsToBackUp();
  116. List<File> filesToBeUploaded;
  117. if (LocalSyncService.instance.hasGrantedLimitedPermissions() &&
  118. foldersToBackUp.isEmpty) {
  119. filesToBeUploaded = await _db.getAllLocalFiles();
  120. } else {
  121. filesToBeUploaded =
  122. await _db.getFilesToBeUploadedWithinFolders(foldersToBackUp);
  123. }
  124. if (!Configuration.instance.shouldBackupVideos()) {
  125. filesToBeUploaded
  126. .removeWhere((element) => element.fileType == FileType.video);
  127. }
  128. _logger.info(
  129. filesToBeUploaded.length.toString() + " new files to be uploaded.");
  130. final updatedFileIDs = await _db.getUploadedFileIDsToBeUpdated();
  131. _logger.info(updatedFileIDs.length.toString() + " files updated.");
  132. final editedFiles = await _db.getEditedRemoteFiles();
  133. _logger.info(editedFiles.length.toString() + " files edited.");
  134. _completedUploads = 0;
  135. int toBeUploaded =
  136. filesToBeUploaded.length + updatedFileIDs.length + editedFiles.length;
  137. if (toBeUploaded > 0) {
  138. Bus.instance.fire(SyncStatusUpdate(SyncStatus.preparing_for_upload));
  139. }
  140. final List<Future> futures = [];
  141. for (final uploadedFileID in updatedFileIDs) {
  142. final file = await _db.getUploadedFileInAnyCollection(uploadedFileID);
  143. final future = _uploader
  144. .upload(file, file.collectionID)
  145. .then((uploadedFile) => _onFileUploaded(uploadedFile));
  146. futures.add(future);
  147. }
  148. for (final file in filesToBeUploaded) {
  149. final collectionID = (await CollectionsService.instance
  150. .getOrCreateForPath(file.deviceFolder))
  151. .id;
  152. final future = _uploader
  153. .upload(file, collectionID)
  154. .then((uploadedFile) => _onFileUploaded(uploadedFile));
  155. futures.add(future);
  156. }
  157. for (final file in editedFiles) {
  158. final future = _uploader
  159. .upload(file, file.collectionID)
  160. .then((uploadedFile) => _onFileUploaded(uploadedFile));
  161. futures.add(future);
  162. }
  163. try {
  164. await Future.wait(futures);
  165. } on InvalidFileError {
  166. // Do nothing
  167. } on FileSystemException {
  168. // Do nothing since it's caused mostly due to concurrency issues
  169. // when the foreground app deletes temporary files, interrupting a background
  170. // upload
  171. } on LockAlreadyAcquiredError {
  172. // Do nothing
  173. } on SilentlyCancelUploadsError {
  174. // Do nothing
  175. } on UserCancelledUploadError {
  176. // Do nothing
  177. } catch (e) {
  178. rethrow;
  179. }
  180. return _completedUploads > 0;
  181. }
  182. Future<void> _onFileUploaded(File file) async {
  183. Bus.instance.fire(CollectionUpdatedEvent(file.collectionID, [file]));
  184. _completedUploads++;
  185. final toBeUploadedInThisSession =
  186. FileUploader.instance.getCurrentSessionUploadCount();
  187. if (toBeUploadedInThisSession == 0) {
  188. return;
  189. }
  190. if (_completedUploads > toBeUploadedInThisSession ||
  191. _completedUploads < 0 ||
  192. toBeUploadedInThisSession < 0) {
  193. _logger.severe(
  194. "Incorrect sync status",
  195. InvalidSyncStatusError("Tried to report " +
  196. _completedUploads.toString() +
  197. " as uploaded out of " +
  198. toBeUploadedInThisSession.toString()));
  199. return;
  200. }
  201. Bus.instance.fire(SyncStatusUpdate(SyncStatus.in_progress,
  202. completed: _completedUploads, total: toBeUploadedInThisSession));
  203. }
  204. Future _storeDiff(List<File> diff, int collectionID) async {
  205. int existing = 0,
  206. updated = 0,
  207. remote = 0,
  208. localButUpdatedOnRemote = 0,
  209. localButAddedToNewCollectionOnRemote = 0;
  210. List<File> toBeInserted = [];
  211. for (File file in diff) {
  212. final existingFiles = file.deviceFolder == null
  213. ? null
  214. : await _db.getMatchingFiles(file.title, file.deviceFolder);
  215. if (existingFiles == null || existingFiles.isEmpty) {
  216. // File uploaded from a different device.
  217. // Other rare possibilities : The local file is present on
  218. // device but it's not imported in local db due to missing permission
  219. // after reinstall (iOS selected file permissions or user revoking
  220. // permissions, or issue/delay in importing devices files.
  221. file.localID = null;
  222. toBeInserted.add(file);
  223. remote++;
  224. } else {
  225. // File exists in ente db with same title & device folder
  226. // Note: The file.generatedID might be already set inside
  227. // [DiffFetcher.getEncryptedFilesDiff]
  228. final fileWithLocalID = existingFiles
  229. .firstWhere((e) => e.localID != null, orElse: () => null);
  230. if (fileWithLocalID != null) {
  231. // File should ideally have the same localID
  232. if (file.localID != null && file.localID != fileWithLocalID.localID) {
  233. _logger.severe(
  234. "unexpected mismatch in localIDs remote: ${file.toString()} and existing: ${fileWithLocalID.toString()}");
  235. }
  236. file.localID = fileWithLocalID.localID;
  237. } else {
  238. file.localID = null;
  239. }
  240. bool wasUploadedOnAPreviousInstallation =
  241. existingFiles.length == 1 && existingFiles[0].collectionID == null;
  242. if (wasUploadedOnAPreviousInstallation) {
  243. file.generatedID = existingFiles[0].generatedID;
  244. if (file.modificationTime != existingFiles[0].modificationTime) {
  245. // File was updated since the app was uninstalled
  246. _logger.info("Updated since last installation: " +
  247. file.uploadedFileID.toString());
  248. file.updationTime = null;
  249. updated++;
  250. } else {
  251. existing++;
  252. }
  253. toBeInserted.add(file);
  254. } else {
  255. bool foundMatchingCollection = false;
  256. for (final existingFile in existingFiles) {
  257. if (file.collectionID == existingFile.collectionID &&
  258. file.uploadedFileID == existingFile.uploadedFileID) {
  259. // File was updated on remote
  260. foundMatchingCollection = true;
  261. file.generatedID = existingFile.generatedID;
  262. toBeInserted.add(file);
  263. await clearCache(file);
  264. localButUpdatedOnRemote++;
  265. break;
  266. }
  267. }
  268. if (!foundMatchingCollection) {
  269. // Added to a new collection
  270. toBeInserted.add(file);
  271. localButAddedToNewCollectionOnRemote++;
  272. }
  273. }
  274. }
  275. }
  276. await _db.insertMultiple(toBeInserted);
  277. if (toBeInserted.isNotEmpty) {
  278. await _collectionsService.setCollectionSyncTime(
  279. collectionID, toBeInserted[toBeInserted.length - 1].updationTime);
  280. }
  281. _logger.info(
  282. "Diff to be deduplicated was: " +
  283. diff.length.toString() +
  284. " out of which \n" +
  285. existing.toString() +
  286. " was uploaded from device, \n" +
  287. updated.toString() +
  288. " was uploaded from device, but has been updated since and should be reuploaded, \n" +
  289. remote.toString() +
  290. " was uploaded from remote, \n" +
  291. localButUpdatedOnRemote.toString() +
  292. " was uploaded from device but updated on remote, and \n" +
  293. localButAddedToNewCollectionOnRemote.toString() +
  294. " was uploaded from device but added to a new collection on remote.",
  295. );
  296. }
  297. bool _hasSyncedArchive() {
  298. return _prefs.containsKey(kHasSyncedArchiveKey);
  299. }
  300. Future<bool> _markArchiveAsSynced() {
  301. return _prefs.setBool(kHasSyncedArchiveKey, true);
  302. }
  303. }