import 'dart:async'; import 'dart:io'; import 'package:computer/computer.dart'; import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/db/device_files_db.dart'; import 'package:photos/db/files_db.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/services/app_lifecycle_service.dart'; import 'package:photos/services/local/local_sync_util.dart'; import 'package:shared_preferences/shared_preferences.dart'; class LocalSyncService { final _logger = Logger("LocalSyncService"); final _db = FilesDB.instance; final Computer _computer = Computer(); SharedPreferences _prefs; Completer _existingSync; static const kDbUpdationTimeKey = "db_updation_time"; static const kHasCompletedFirstImportKey = "has_completed_firstImport"; static const kHasGrantedPermissionsKey = "has_granted_permissions"; static const kPermissionStateKey = "permission_state"; static const kEditedFileIDsKey = "edited_file_ids"; static const kDownloadedFileIDsKey = "downloaded_file_ids"; // Adding `_2` as a suffic to pull files that were earlier ignored due to permission errors // See https://github.com/CaiJingLong/flutter_photo_manager/issues/589 static const kInvalidFileIDsKey = "invalid_file_ids_2"; LocalSyncService._privateConstructor(); static final LocalSyncService instance = LocalSyncService._privateConstructor(); Future init() async { _prefs = await SharedPreferences.getInstance(); if (!AppLifecycleService.instance.isForeground) { await PhotoManager.setIgnorePermissionCheck(true); } await _computer.turnOn(workersCount: 1); if (hasGrantedPermissions()) { _registerChangeCallback(); } } Future sync() async { if (!_prefs.containsKey(kHasGrantedPermissionsKey)) { _logger.info("Skipping local sync since permission has not been granted"); return; } if (Platform.isAndroid && AppLifecycleService.instance.isForeground) { final permissionState = await PhotoManager.requestPermissionExtend(); if (permissionState != PermissionState.authorized) { _logger.severe( "sync requested with invalid permission", permissionState.toString(), ); return; } } if (_existingSync != null) { _logger.warning("Sync already in progress, skipping."); return _existingSync.future; } _existingSync = Completer(); final existingLocalFileIDs = await _db.getExistingLocalFileIDs(); _logger.info( existingLocalFileIDs.length.toString() + " localIDs were discovered", ); final editedFileIDs = _getEditedFileIDs().toSet(); final downloadedFileIDs = _getDownloadedFileIDs().toSet(); final syncStartTime = DateTime.now().microsecondsSinceEpoch; final lastDBUpdationTime = _prefs.getInt(kDbUpdationTimeKey) ?? 0; final startTime = DateTime.now().microsecondsSinceEpoch; if (lastDBUpdationTime != 0) { await _loadAndStorePhotos( lastDBUpdationTime, syncStartTime, existingLocalFileIDs, editedFileIDs, downloadedFileIDs, ); } else { // Load from 0 - 01.01.2010 Bus.instance.fire(SyncStatusUpdate(SyncStatus.startedFirstGalleryImport)); var startTime = 0; var toYear = 2010; var toTime = DateTime(toYear).microsecondsSinceEpoch; while (toTime < syncStartTime) { await _loadAndStorePhotos( startTime, toTime, existingLocalFileIDs, editedFileIDs, downloadedFileIDs, ); startTime = toTime; toYear++; toTime = DateTime(toYear).microsecondsSinceEpoch; } await _loadAndStorePhotos( startTime, syncStartTime, existingLocalFileIDs, editedFileIDs, downloadedFileIDs, ); } if (!_prefs.containsKey(kHasCompletedFirstImportKey) || !_prefs.getBool(kHasCompletedFirstImportKey)) { await _prefs.setBool(kHasCompletedFirstImportKey, true); _logger.fine("first gallery import finished"); Bus.instance .fire(SyncStatusUpdate(SyncStatus.completedFirstGalleryImport)); } final endTime = DateTime.now().microsecondsSinceEpoch; final duration = Duration(microseconds: endTime - startTime); _logger.info("Load took " + duration.inMilliseconds.toString() + "ms"); _existingSync.complete(); _existingSync = null; } Future syncAll() async { final sTime = DateTime.now().microsecondsSinceEpoch; final localAssets = await getAllLocalAssets(); final eTime = DateTime.now().microsecondsSinceEpoch; final d = Duration(microseconds: eTime - sTime); _logger.info( "Loading from the beginning returned " + localAssets.length.toString() + " assets and took " + d.inMilliseconds.toString() + "ms", ); final existingIDs = await _db.getExistingLocalFileIDs(); final invalidIDs = _getInvalidFileIDs().toSet(); final unsyncedFiles = await getUnsyncedFiles(localAssets, existingIDs, invalidIDs, _computer); if (unsyncedFiles.isNotEmpty) { await _db.insertMultiple(unsyncedFiles); _logger.info( "Inserted " + unsyncedFiles.length.toString() + " unsynced files.", ); _updatePathsToBackup(unsyncedFiles); Bus.instance.fire(LocalPhotosUpdatedEvent(unsyncedFiles)); return true; } return false; } Future trackEditedFile(File file) async { final editedIDs = _getEditedFileIDs(); editedIDs.add(file.localID); await _prefs.setStringList(kEditedFileIDsKey, editedIDs); } List _getEditedFileIDs() { if (_prefs.containsKey(kEditedFileIDsKey)) { return _prefs.getStringList(kEditedFileIDsKey); } else { List editedIDs = []; return editedIDs; } } Future trackDownloadedFile(String localID) async { final downloadedIDs = _getDownloadedFileIDs(); downloadedIDs.add(localID); await _prefs.setStringList(kDownloadedFileIDsKey, downloadedIDs); } List _getDownloadedFileIDs() { if (_prefs.containsKey(kDownloadedFileIDsKey)) { return _prefs.getStringList(kDownloadedFileIDsKey); } else { return []; } } Future trackInvalidFile(File file) async { final invalidIDs = _getInvalidFileIDs(); invalidIDs.add(file.localID); await _prefs.setStringList(kInvalidFileIDsKey, invalidIDs); } List _getInvalidFileIDs() { if (_prefs.containsKey(kInvalidFileIDsKey)) { return _prefs.getStringList(kInvalidFileIDsKey); } else { return []; } } bool hasGrantedPermissions() { return _prefs.getBool(kHasGrantedPermissionsKey) ?? false; } bool hasGrantedLimitedPermissions() { return _prefs.getString(kPermissionStateKey) == PermissionState.limited.toString(); } Future onPermissionGranted(PermissionState state) async { await _prefs.setBool(kHasGrantedPermissionsKey, true); await _prefs.setString(kPermissionStateKey, state.toString()); _registerChangeCallback(); } bool hasCompletedFirstImport() { return _prefs.getBool(kHasCompletedFirstImportKey) ?? false; } // Warning: resetLocalSync should only be used for testing imported related // changes Future resetLocalSync() async { assert(kDebugMode, "only available in debug mode"); await FilesDB.instance.deleteDB(); for (var element in [ kHasCompletedFirstImportKey, kDbUpdationTimeKey, kDownloadedFileIDsKey, kEditedFileIDsKey ]) { await _prefs.remove(element); } } Future _loadAndStorePhotos( int fromTime, int toTime, Set existingLocalFileIDs, Set editedFileIDs, Set downloadedFileIDs, ) async { _logger.info( "Loading photos from " + DateTime.fromMicrosecondsSinceEpoch(fromTime).toString() + " to " + DateTime.fromMicrosecondsSinceEpoch(toTime).toString(), ); final deviceFiles = await getDeviceFiles(fromTime, toTime, _computer); final List files = deviceFiles.item2; unawaited(FilesDB.instance.insertDeviceFiles(files)); unawaited(FilesDB.instance.insertOrUpdatePathName(deviceFiles.item1)); if (files.isNotEmpty) { _logger.info("Fetched " + files.length.toString() + " files."); final updatedFiles = files .where((file) => existingLocalFileIDs.contains(file.localID)) .toList(); updatedFiles.removeWhere((file) => editedFileIDs.contains(file.localID)); updatedFiles .removeWhere((file) => downloadedFileIDs.contains(file.localID)); if (updatedFiles.isNotEmpty) { _logger.info( updatedFiles.length.toString() + " local files were updated.", ); } for (final file in updatedFiles) { await captureUpdateLogs(file); await _db.updateUploadedFile( file.localID, file.title, file.location, file.creationTime, file.modificationTime, null, ); } final List allFiles = []; allFiles.addAll(files); files.removeWhere((file) => existingLocalFileIDs.contains(file.localID)); await _db.insertMultiple(files); _logger.info("Inserted " + files.length.toString() + " files."); _updatePathsToBackup(files); Bus.instance.fire(LocalPhotosUpdatedEvent(allFiles)); } await _prefs.setInt(kDbUpdationTimeKey, toTime); } // _captureUpdateLogs is a helper method to log details // about the file which is being marked for re-upload Future captureUpdateLogs(File file) async { _logger.info( 're-upload locally updated file ${file.toString()}', ); try { if (Platform.isIOS) { var assetEntity = await AssetEntity.fromId(file.localID); if (assetEntity != null) { var isLocallyAvailable = await assetEntity.isLocallyAvailable(isOrigin: true); _logger.info( 're-upload asset ${file.toString()} with localAvailableFlag ' '$isLocallyAvailable and fav ${assetEntity.isFavorite}', ); } else { _logger .info('re-upload failed to fetch assetInfo ${file.toString()}'); } } } catch (ignore) { //ignore } } void _updatePathsToBackup(List files) { if (Configuration.instance.hasSelectedAllFoldersForBackup()) { final pathsToBackup = Configuration.instance.getPathsToBackUp(); final newFilePaths = files.map((file) => file.deviceFolder).toList(); pathsToBackup.addAll(newFilePaths); Configuration.instance.setPathsToBackUp(pathsToBackup); } } void _registerChangeCallback() { // In case of iOS limit permission, this call back is fired immediately // after file selection dialog is dismissed. PhotoManager.addChangeCallback((value) async { _logger.info("Something changed on disk"); if (_existingSync != null) { await _existingSync.future; } if (hasGrantedLimitedPermissions()) { syncAll(); } else { sync(); } }); PhotoManager.startChangeNotify(); } }