ente/lib/services/remote_sync_service.dart

534 lines
19 KiB
Dart
Raw Normal View History

import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:logging/logging.dart';
import 'package:photos/core/configuration.dart';
import 'package:photos/core/errors.dart';
import 'package:photos/core/event_bus.dart';
import 'package:photos/db/files_db.dart';
import 'package:photos/events/collection_updated_event.dart';
import 'package:photos/events/files_updated_event.dart';
import 'package:photos/events/force_reload_home_gallery_event.dart';
import 'package:photos/events/local_photos_updated_event.dart';
import 'package:photos/events/sync_status_update_event.dart';
import 'package:photos/models/file.dart';
import 'package:photos/models/file_type.dart';
import 'package:photos/services/app_lifecycle_service.dart';
import 'package:photos/services/collections_service.dart';
import 'package:photos/services/ignored_files_service.dart';
2022-08-01 11:05:16 +00:00
import 'package:photos/services/local_file_update_service.dart';
import 'package:photos/services/local_sync_service.dart';
2021-10-12 19:27:11 +00:00
import 'package:photos/services/trash_sync_service.dart';
import 'package:photos/utils/diff_fetcher.dart';
import 'package:photos/utils/file_uploader.dart';
import 'package:photos/utils/file_util.dart';
import 'package:shared_preferences/shared_preferences.dart';
class RemoteSyncService {
final _logger = Logger("RemoteSyncService");
final _db = FilesDB.instance;
final _uploader = FileUploader.instance;
final _collectionsService = CollectionsService.instance;
final _diffFetcher = DiffFetcher();
2022-08-01 11:05:16 +00:00
final LocalFileUpdateService _localFileUpdateService =
LocalFileUpdateService.instance;
int _completedUploads = 0;
SharedPreferences _prefs;
2021-10-30 00:39:53 +00:00
Completer<void> _existingSync;
static const kHasSyncedArchiveKey = "has_synced_archive";
// 28 Sept, 2021 9:03:20 AM IST
static const kArchiveFeatureReleaseTime = 1632800000000000;
static const kHasSyncedEditTime = "has_synced_edit_time";
// 29 October, 2021 3:56:40 AM IST
static const kEditTimeFeatureReleaseTime = 1635460000000000;
2021-11-15 15:50:20 +00:00
static const kMaximumPermissibleUploadsInThrottledMode = 4;
2022-07-03 09:49:33 +00:00
static final RemoteSyncService instance =
RemoteSyncService._privateConstructor();
RemoteSyncService._privateConstructor();
2021-11-15 15:35:07 +00:00
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
2022-01-03 18:24:43 +00:00
Bus.instance.on<LocalPhotosUpdatedEvent>().listen((event) async {
if (event.type == EventType.addedOrUpdated) {
if (_existingSync == null) {
sync();
}
}
});
}
Future<void> sync({bool silently = false}) async {
if (!Configuration.instance.hasConfiguredAccount()) {
_logger.info("Skipping remote sync since account is not configured");
return;
}
2021-10-30 00:39:53 +00:00
if (_existingSync != null) {
_logger.info("Remote sync already in progress, skipping");
return _existingSync.future;
}
_existingSync = Completer<void>();
try {
2022-01-03 18:24:43 +00:00
await _pullDiff(silently);
// sync trash but consume error during initial launch.
// this is to ensure that we don't pause upload due to any error during
// the trash sync. Impact: We may end up re-uploading a file which was
// recently trashed.
await TrashSyncService.instance
.syncTrash()
.onError((e, s) => _logger.severe('trash sync failed', e, s));
2022-01-03 18:24:43 +00:00
final filesToBeUploaded = await _getFilesToBeUploaded();
final hasUploadedFiles = await _uploadFiles(filesToBeUploaded);
if (hasUploadedFiles) {
await _pullDiff(true);
2022-01-05 07:55:04 +00:00
_existingSync.complete();
_existingSync = null;
2022-01-03 18:32:56 +00:00
final hasMoreFilesToBackup = (await _getFilesToBeUploaded()).isNotEmpty;
2022-01-03 18:24:43 +00:00
if (hasMoreFilesToBackup && !_shouldThrottleSync()) {
// Skipping a resync to ensure that files that were ignored in this
// session are not processed now
sync();
} else {
2022-07-03 07:47:15 +00:00
Bus.instance.fire(SyncStatusUpdate(SyncStatus.completedBackup));
2022-01-03 18:24:43 +00:00
}
2022-01-05 07:55:04 +00:00
} else {
_existingSync.complete();
_existingSync = null;
}
} catch (e, s) {
_existingSync.complete();
_existingSync = null;
// rethrow whitelisted error so that UI status can be updated correctly.
if (e is UnauthorizedError ||
e is NoActiveSubscriptionError ||
e is WiFiUnavailableError ||
e is StorageLimitExceededError ||
e is SyncStopRequestedError) {
_logger.warning("Error executing remote sync", e);
2022-01-07 16:30:22 +00:00
rethrow;
} else {
_logger.severe("Error executing remote sync ", e, s);
2022-01-07 16:30:22 +00:00
}
}
}
2022-01-03 18:24:43 +00:00
Future<void> _pullDiff(bool silently) async {
2022-01-03 18:27:49 +00:00
final isFirstSync = !_collectionsService.hasSyncedCollections();
2022-01-03 18:24:43 +00:00
await _collectionsService.sync();
if (isFirstSync || _hasReSynced()) {
await _syncUpdatedCollections(silently);
} else {
final syncSinceTime = _getSinceTimeForReSync();
await _resyncAllCollectionsSinceTime(syncSinceTime);
}
if (!_hasReSynced()) {
await _markReSyncAsDone();
}
unawaited(_localFileUpdateService.markUpdatedFilesForReUpload());
2022-01-03 18:24:43 +00:00
}
Future<void> _syncUpdatedCollections(bool silently) async {
2022-07-03 09:49:33 +00:00
final updatedCollections =
await _collectionsService.getCollectionsToBeSynced();
if (updatedCollections.isNotEmpty && !silently) {
2022-07-03 07:47:15 +00:00
Bus.instance.fire(SyncStatusUpdate(SyncStatus.applyingRemoteDiff));
}
for (final c in updatedCollections) {
await _syncCollectionDiff(
2022-06-11 08:23:52 +00:00
c.id,
_collectionsService.getCollectionSyncTime(c.id),
);
await _collectionsService.setCollectionSyncTime(c.id, c.updationTime);
}
}
Future<void> _resyncAllCollectionsSinceTime(int sinceTime) async {
_logger.info('re-sync collections sinceTime: $sinceTime');
final collections = _collectionsService.getActiveCollections();
for (final c in collections) {
2022-06-11 08:23:52 +00:00
await _syncCollectionDiff(
c.id,
min(_collectionsService.getCollectionSyncTime(c.id), sinceTime),
);
await _collectionsService.setCollectionSyncTime(c.id, c.updationTime);
}
}
Future<void> _syncCollectionDiff(int collectionID, int sinceTime) async {
2022-07-03 09:49:33 +00:00
final diff =
await _diffFetcher.getEncryptedFilesDiff(collectionID, sinceTime);
if (diff.deletedFiles.isNotEmpty) {
final fileIDs = diff.deletedFiles.map((f) => f.uploadedFileID).toList();
2022-07-03 09:49:33 +00:00
final deletedFiles =
(await FilesDB.instance.getFilesFromIDs(fileIDs)).values.toList();
await FilesDB.instance.deleteFilesFromCollection(collectionID, fileIDs);
2022-06-11 08:23:52 +00:00
Bus.instance.fire(
CollectionUpdatedEvent(
collectionID,
deletedFiles,
type: EventType.deletedFromRemote,
),
);
Bus.instance.fire(
LocalPhotosUpdatedEvent(
deletedFiles,
type: EventType.deletedFromRemote,
),
);
}
if (diff.updatedFiles.isNotEmpty) {
await _storeDiff(diff.updatedFiles, collectionID);
2022-06-11 08:23:52 +00:00
_logger.info(
"Updated " +
diff.updatedFiles.length.toString() +
" files in collection " +
collectionID.toString(),
);
Bus.instance.fire(LocalPhotosUpdatedEvent(diff.updatedFiles));
2022-07-03 09:49:33 +00:00
Bus.instance
.fire(CollectionUpdatedEvent(collectionID, diff.updatedFiles));
}
if (diff.latestUpdatedAtTime > 0) {
await _collectionsService.setCollectionSyncTime(
2022-06-11 08:23:52 +00:00
collectionID,
diff.latestUpdatedAtTime,
);
}
2021-10-29 12:00:56 +00:00
if (diff.hasMore) {
2022-06-11 08:23:52 +00:00
return await _syncCollectionDiff(
collectionID,
_collectionsService.getCollectionSyncTime(collectionID),
);
}
}
2022-01-03 18:24:43 +00:00
Future<List<File>> _getFilesToBeUploaded() async {
final foldersToBackUp = Configuration.instance.getPathsToBackUp();
2021-07-21 20:58:07 +00:00
List<File> filesToBeUploaded;
2022-07-03 09:49:33 +00:00
if (LocalSyncService.instance.hasGrantedLimitedPermissions() &&
foldersToBackUp.isEmpty) {
filesToBeUploaded = await _db.getAllLocalFiles();
} else {
2022-07-03 09:49:33 +00:00
filesToBeUploaded =
await _db.getFilesToBeUploadedWithinFolders(foldersToBackUp);
}
2021-11-15 15:50:20 +00:00
if (!Configuration.instance.shouldBackupVideos() || _shouldThrottleSync()) {
2022-07-03 09:49:33 +00:00
filesToBeUploaded
.removeWhere((element) => element.fileType == FileType.video);
}
if (filesToBeUploaded.isNotEmpty) {
2021-10-21 11:56:02 +00:00
final int prevCount = filesToBeUploaded.length;
final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs;
2022-06-11 08:23:52 +00:00
filesToBeUploaded.removeWhere(
2022-07-03 09:49:33 +00:00
(file) =>
IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, file),
2022-06-11 08:23:52 +00:00
);
if (prevCount != filesToBeUploaded.length) {
2022-06-11 08:23:52 +00:00
_logger.info(
2022-07-03 09:49:33 +00:00
(prevCount - filesToBeUploaded.length).toString() +
" files were ignored for upload",
2022-06-11 08:23:52 +00:00
);
}
}
if (filesToBeUploaded.isEmpty) {
// look for files which user manually tried to back up but they are not
// uploaded yet. These files should ignore video backup & ignored files filter
2022-02-13 09:23:10 +00:00
filesToBeUploaded = await _db.getPendingManualUploads();
}
2022-03-20 09:05:43 +00:00
_sortByTimeAndType(filesToBeUploaded);
_logger.info(
2022-06-11 08:23:52 +00:00
filesToBeUploaded.length.toString() + " new files to be uploaded.",
);
2022-01-03 18:24:43 +00:00
return filesToBeUploaded;
}
2022-01-03 18:24:43 +00:00
Future<bool> _uploadFiles(List<File> filesToBeUploaded) async {
final updatedFileIDs = await _db.getUploadedFileIDsToBeUpdated();
_logger.info(updatedFileIDs.length.toString() + " files updated.");
final editedFiles = await _db.getEditedRemoteFiles();
_logger.info(editedFiles.length.toString() + " files edited.");
_completedUploads = 0;
2022-07-03 09:49:33 +00:00
int toBeUploaded =
filesToBeUploaded.length + updatedFileIDs.length + editedFiles.length;
if (toBeUploaded > 0) {
2022-07-03 07:47:15 +00:00
Bus.instance.fire(SyncStatusUpdate(SyncStatus.preparingForUpload));
// verify if files upload is allowed based on their subscription plan and
// storage limit. To avoid creating new endpoint, we are using
// fetchUploadUrls as alternative method.
await _uploader.fetchUploadURLs(toBeUploaded);
}
final List<Future> futures = [];
for (final uploadedFileID in updatedFileIDs) {
2022-07-03 09:49:33 +00:00
if (_shouldThrottleSync() &&
futures.length >= kMaximumPermissibleUploadsInThrottledMode) {
_logger
.info("Skipping some updated files as we are throttling uploads");
2021-11-15 15:50:20 +00:00
break;
}
final file = await _db.getUploadedFileInAnyCollection(uploadedFileID);
2021-11-27 15:04:21 +00:00
_uploadFile(file, file.collectionID, futures);
}
for (final file in filesToBeUploaded) {
2022-07-03 09:49:33 +00:00
if (_shouldThrottleSync() &&
futures.length >= kMaximumPermissibleUploadsInThrottledMode) {
2021-11-15 15:50:20 +00:00
_logger.info("Skipping some new files as we are throttling uploads");
break;
}
// prefer existing collection ID for manually uploaded files.
// See https://github.com/ente-io/frame/pull/187
2022-02-14 17:27:00 +00:00
final collectionID = file.collectionID ??
2022-07-03 09:49:33 +00:00
(await CollectionsService.instance
.getOrCreateForPath(file.deviceFolder))
.id;
2021-11-27 15:04:21 +00:00
_uploadFile(file, collectionID, futures);
}
for (final file in editedFiles) {
2022-07-03 09:49:33 +00:00
if (_shouldThrottleSync() &&
futures.length >= kMaximumPermissibleUploadsInThrottledMode) {
2021-11-15 15:50:20 +00:00
_logger.info("Skipping some edited files as we are throttling uploads");
break;
}
2021-11-27 15:04:21 +00:00
_uploadFile(file, file.collectionID, futures);
}
try {
await Future.wait(futures);
} on InvalidFileError {
// Do nothing
} on FileSystemException {
// Do nothing since it's caused mostly due to concurrency issues
// when the foreground app deletes temporary files, interrupting a background
// upload
} on LockAlreadyAcquiredError {
// Do nothing
} on SilentlyCancelUploadsError {
// Do nothing
} on UserCancelledUploadError {
// Do nothing
} catch (e) {
2021-07-21 20:58:07 +00:00
rethrow;
}
return _completedUploads > 0;
}
2021-11-27 15:04:21 +00:00
void _uploadFile(File file, int collectionID, List<Future> futures) {
2022-07-03 09:49:33 +00:00
final future = _uploader
.upload(file, collectionID)
.then((uploadedFile) => _onFileUploaded(uploadedFile));
2021-11-27 15:04:21 +00:00
futures.add(future);
}
2021-07-23 10:43:19 +00:00
Future<void> _onFileUploaded(File file) async {
Bus.instance.fire(CollectionUpdatedEvent(file.collectionID, [file]));
_completedUploads++;
2022-07-03 09:49:33 +00:00
final toBeUploadedInThisSession =
FileUploader.instance.getCurrentSessionUploadCount();
if (toBeUploadedInThisSession == 0) {
return;
}
2021-07-23 11:13:01 +00:00
if (_completedUploads > toBeUploadedInThisSession ||
_completedUploads < 0 ||
toBeUploadedInThisSession < 0) {
_logger.info(
2022-06-11 08:23:52 +00:00
"Incorrect sync status",
InvalidSyncStatusError(
"Tried to report $_completedUploads as "
"uploaded out of $toBeUploadedInThisSession",
),
);
2021-07-23 11:13:01 +00:00
return;
}
2022-06-11 08:23:52 +00:00
Bus.instance.fire(
SyncStatusUpdate(
2022-07-03 07:47:15 +00:00
SyncStatus.inProgress,
2022-06-11 08:23:52 +00:00
completed: _completedUploads,
total: toBeUploadedInThisSession,
),
);
}
Future _storeDiff(List<File> diff, int collectionID) async {
int existing = 0,
updated = 0,
remote = 0,
localButUpdatedOnRemote = 0,
localButAddedToNewCollectionOnRemote = 0;
bool hasAnyCreationTimeChanged = false;
List<File> toBeInserted = [];
int userID = Configuration.instance.getUserID();
for (File file in diff) {
final existingFiles = file.deviceFolder == null
? null
: await _db.getMatchingFiles(file.title, file.deviceFolder);
2022-07-03 09:49:33 +00:00
if (existingFiles == null ||
existingFiles.isEmpty ||
userID != file.ownerID) {
// File uploaded from a different device or uploaded by different user
// Other rare possibilities : The local file is present on
// device but it's not imported in local db due to missing permission
// after reinstall (iOS selected file permissions or user revoking
// permissions, or issue/delay in importing devices files.
file.localID = null;
toBeInserted.add(file);
remote++;
} else {
// File exists in ente db with same title & device folder
// Note: The file.generatedID might be already set inside
// [DiffFetcher.getEncryptedFilesDiff]
// Try to find existing file with same localID as remote file with a fallback
// to finding any existing file with localID. This is needed to handle
// case when localID for a file changes and the file is uploaded again in
// the same collection
final fileWithLocalID = existingFiles.firstWhere(
2022-07-03 09:49:33 +00:00
(e) =>
file.localID != null &&
e.localID != null &&
e.localID == file.localID,
2022-06-11 08:23:52 +00:00
orElse: () => existingFiles.firstWhere(
(e) => e.localID != null,
orElse: () => null,
),
);
if (fileWithLocalID != null) {
// File should ideally have the same localID
if (file.localID != null && file.localID != fileWithLocalID.localID) {
_logger.severe(
2022-06-11 08:23:52 +00:00
"unexpected mismatch in localIDs remote: ${file.toString()} and existing: ${fileWithLocalID.toString()}",
);
}
file.localID = fileWithLocalID.localID;
} else {
file.localID = null;
}
bool wasUploadedOnAPreviousInstallation =
existingFiles.length == 1 && existingFiles[0].collectionID == null;
if (wasUploadedOnAPreviousInstallation) {
file.generatedID = existingFiles[0].generatedID;
if (file.modificationTime != existingFiles[0].modificationTime) {
// File was updated since the app was uninstalled
// mark it for re-upload
2022-06-11 08:23:52 +00:00
_logger.info(
2022-06-29 04:56:11 +00:00
"re-upload because file was updated since last installation: "
"remoteFile: ${file.toString()}, localFile: ${existingFiles[0].toString()}",
2022-06-11 08:23:52 +00:00
);
file.modificationTime = existingFiles[0].modificationTime;
file.updationTime = null;
updated++;
} else {
existing++;
}
toBeInserted.add(file);
} else {
bool foundMatchingCollection = false;
for (final existingFile in existingFiles) {
if (file.collectionID == existingFile.collectionID &&
file.uploadedFileID == existingFile.uploadedFileID) {
// File was updated on remote
if (file.creationTime != existingFile.creationTime) {
hasAnyCreationTimeChanged = true;
}
foundMatchingCollection = true;
file.generatedID = existingFile.generatedID;
toBeInserted.add(file);
2021-08-09 06:21:09 +00:00
await clearCache(file);
localButUpdatedOnRemote++;
break;
}
}
if (!foundMatchingCollection) {
// Added to a new collection
toBeInserted.add(file);
localButAddedToNewCollectionOnRemote++;
}
}
}
}
await _db.insertMultiple(toBeInserted);
_logger.info(
"Diff to be deduplicated was: " +
diff.length.toString() +
" out of which \n" +
existing.toString() +
" was uploaded from device, \n" +
updated.toString() +
" was uploaded from device, but has been updated since and should be reuploaded, \n" +
remote.toString() +
" was uploaded from remote, \n" +
localButUpdatedOnRemote.toString() +
" was uploaded from device but updated on remote, and \n" +
localButAddedToNewCollectionOnRemote.toString() +
" was uploaded from device but added to a new collection on remote.",
);
if (hasAnyCreationTimeChanged) {
Bus.instance.fire(ForceReloadHomeGalleryEvent());
}
}
// return true if the client needs to re-sync the collections from previous
// version
bool _hasReSynced() {
2022-07-03 09:49:33 +00:00
return _prefs.containsKey(kHasSyncedEditTime) &&
_prefs.containsKey(kHasSyncedArchiveKey);
}
Future<void> _markReSyncAsDone() async {
await _prefs.setBool(kHasSyncedArchiveKey, true);
await _prefs.setBool(kHasSyncedEditTime, true);
}
int _getSinceTimeForReSync() {
// re-sync from archive feature time if the client still hasn't synced
// since the feature release.
if (!_prefs.containsKey(kHasSyncedArchiveKey)) {
return kArchiveFeatureReleaseTime;
}
return kEditTimeFeatureReleaseTime;
}
2021-11-15 15:50:20 +00:00
bool _shouldThrottleSync() {
return Platform.isIOS && !AppLifecycleService.instance.isForeground;
}
2021-12-02 05:00:27 +00:00
2022-03-20 09:05:43 +00:00
// _sortByTimeAndType moves videos to end and sort by creation time (desc).
// This is done to upload most recent photo first.
void _sortByTimeAndType(List<File> file) {
2021-12-02 05:00:27 +00:00
file.sort((first, second) {
2021-12-23 06:34:03 +00:00
if (first.fileType == second.fileType) {
2022-03-20 09:05:43 +00:00
return second.creationTime.compareTo(first.creationTime);
2021-12-02 05:00:27 +00:00
} else if (first.fileType == FileType.video) {
return 1;
} else {
return -1;
}
});
2022-05-26 10:04:12 +00:00
// move updated files towards the end
file.sort((first, second) {
2022-05-26 10:26:53 +00:00
if (first.updationTime == second.updationTime) {
2022-05-26 10:04:12 +00:00
return 0;
}
2022-05-26 10:26:53 +00:00
if (first.updationTime == -1) {
2022-05-26 10:04:12 +00:00
return 1;
} else {
return -1;
}
});
2021-12-02 05:00:27 +00:00
}
}