mirror of
https://github.com/immich-app/immich.git
synced 2026-05-22 23:12:32 -04:00
refactor: yeet old timeline (#27666)
* refactor: yank old timeline # Conflicts: # mobile/lib/presentation/pages/editing/drift_edit.page.dart # mobile/lib/providers/websocket.provider.dart # mobile/lib/routing/router.dart * more cleanup * remove native code * chore: bump sqlite-data version * remove old background tasks from BGTaskSchedulerPermittedIdentifiers * rebase --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
@@ -9,7 +9,6 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
||||
import 'package:immich_mobile/repositories/activity_api.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart' as immich_store;
|
||||
|
||||
class ActivityService with ErrorLoggerMixin {
|
||||
final ActivityApiRepository _activityApiRepository;
|
||||
@@ -60,20 +59,16 @@ class ActivityService with ErrorLoggerMixin {
|
||||
}
|
||||
|
||||
Future<AssetViewerRoute?> buildAssetViewerRoute(String assetId, WidgetRef ref) async {
|
||||
if (immich_store.Store.isBetaTimelineEnabled) {
|
||||
final asset = await _assetService.getRemoteAsset(assetId);
|
||||
if (asset == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
AssetViewer.setAsset(ref, asset);
|
||||
return AssetViewerRoute(
|
||||
initialIndex: 0,
|
||||
timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.albumActivities),
|
||||
currentAlbum: ref.read(currentRemoteAlbumProvider),
|
||||
);
|
||||
final asset = await _assetService.getRemoteAsset(assetId);
|
||||
if (asset == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
AssetViewer.setAsset(ref, asset);
|
||||
return AssetViewerRoute(
|
||||
initialIndex: 0,
|
||||
timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.albumActivities),
|
||||
currentAlbum: ref.read(currentRemoteAlbumProvider),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity;
|
||||
import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/album.repository.dart';
|
||||
import 'package:immich_mobile/repositories/album_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/services/entity.service.dart';
|
||||
import 'package:immich_mobile/services/sync.service.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
final albumServiceProvider = Provider(
|
||||
(ref) => AlbumService(
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(entityServiceProvider),
|
||||
ref.watch(albumRepositoryProvider),
|
||||
ref.watch(assetRepositoryProvider),
|
||||
ref.watch(backupAlbumRepositoryProvider),
|
||||
ref.watch(albumMediaRepositoryProvider),
|
||||
ref.watch(albumApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class AlbumService {
|
||||
final SyncService _syncService;
|
||||
final UserService _userService;
|
||||
final EntityService _entityService;
|
||||
final AlbumRepository _albumRepository;
|
||||
final AssetRepository _assetRepository;
|
||||
final BackupAlbumRepository _backupAlbumRepository;
|
||||
final AlbumMediaRepository _albumMediaRepository;
|
||||
final AlbumApiRepository _albumApiRepository;
|
||||
final Logger _log = Logger('AlbumService');
|
||||
Completer<bool> _localCompleter = Completer()..complete(false);
|
||||
Completer<bool> _remoteCompleter = Completer()..complete(false);
|
||||
|
||||
AlbumService(
|
||||
this._syncService,
|
||||
this._userService,
|
||||
this._entityService,
|
||||
this._albumRepository,
|
||||
this._assetRepository,
|
||||
this._backupAlbumRepository,
|
||||
this._albumMediaRepository,
|
||||
this._albumApiRepository,
|
||||
);
|
||||
|
||||
/// Checks all selected device albums for changes of albums and their assets
|
||||
/// Updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshDeviceAlbums() async {
|
||||
if (!_localCompleter.isCompleted) {
|
||||
// guard against concurrent calls
|
||||
_log.info("refreshDeviceAlbums is already in progress");
|
||||
return _localCompleter.future;
|
||||
}
|
||||
_localCompleter = Completer();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
final (selectedIds, excludedIds, onDevice) = await (
|
||||
_backupAlbumRepository.getIdsBySelection(BackupSelection.select).then((value) => value.toSet()),
|
||||
_backupAlbumRepository.getIdsBySelection(BackupSelection.exclude).then((value) => value.toSet()),
|
||||
_albumMediaRepository.getAll(),
|
||||
).wait;
|
||||
_log.info("Found ${onDevice.length} device albums");
|
||||
if (selectedIds.isEmpty) {
|
||||
final numLocal = await _albumRepository.count(local: true);
|
||||
if (numLocal > 0) {
|
||||
await _syncService.removeAllLocalAlbumsAndAssets();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Set<String>? excludedAssets;
|
||||
if (excludedIds.isNotEmpty) {
|
||||
if (Platform.isIOS) {
|
||||
// iOS and Android device album working principle differ significantly
|
||||
// on iOS, an asset can be in multiple albums
|
||||
// on Android, an asset can only be in exactly one album (folder!) at the same time
|
||||
// thus, on Android, excluding an album can be done by ignoring that album
|
||||
// however, on iOS, it it necessary to load the assets from all excluded
|
||||
// albums and check every asset from any selected album against the set
|
||||
// of excluded assets
|
||||
excludedAssets = await _loadExcludedAssetIds(onDevice, excludedIds);
|
||||
_log.info("Found ${excludedAssets.length} assets to exclude");
|
||||
}
|
||||
// remove all excluded albums
|
||||
onDevice.removeWhere((e) => excludedIds.contains(e.localId));
|
||||
_log.info("Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums");
|
||||
}
|
||||
|
||||
final allAlbum = onDevice.firstWhereOrNull((album) => album.isAll);
|
||||
final hasAll = allAlbum != null && selectedIds.contains(allAlbum.localId);
|
||||
if (hasAll) {
|
||||
if (Platform.isAndroid) {
|
||||
// remove the virtual "Recent" album and keep and individual albums
|
||||
// on Android, the virtual "Recent" `lastModified` value is always null
|
||||
onDevice.removeWhere((album) => album.isAll);
|
||||
_log.info("'Recents' is selected, keeping all individual albums");
|
||||
}
|
||||
} else {
|
||||
// keep only the explicitly selected albums
|
||||
onDevice.removeWhere((album) => !selectedIds.contains(album.localId));
|
||||
_log.info("'Recents' is not selected, keeping only selected albums");
|
||||
}
|
||||
changes = await _syncService.syncLocalAlbumAssetsToDb(onDevice, excludedAssets);
|
||||
_log.info("Syncing completed. Changes: $changes");
|
||||
} finally {
|
||||
_localCompleter.complete(changes);
|
||||
}
|
||||
dPrint(() => "refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<Set<String>> _loadExcludedAssetIds(List<Album> albums, Set<String> excludedAlbumIds) async {
|
||||
final Set<String> result = HashSet<String>();
|
||||
for (final batchAlbums in albums.where((album) => excludedAlbumIds.contains(album.localId)).slices(5)) {
|
||||
await batchAlbums
|
||||
.map((album) => _albumMediaRepository.getAssetIds(album.localId!).then((assetIds) => result.addAll(assetIds)))
|
||||
.wait;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Checks remote albums (owned if `isShared` is false) for changes,
|
||||
/// updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshRemoteAlbums() async {
|
||||
if (!_remoteCompleter.isCompleted) {
|
||||
// guard against concurrent calls
|
||||
return _remoteCompleter.future;
|
||||
}
|
||||
_remoteCompleter = Completer();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
final users = await _syncService.getUsersFromServer();
|
||||
if (users != null) {
|
||||
await _syncService.syncUsersFromServer(users);
|
||||
}
|
||||
final (sharedAlbum, ownedAlbum) = await (
|
||||
// Note: `shared: true` is required to get albums that don't belong to
|
||||
// us due to unusual behaviour on the API but this will also return our
|
||||
// own shared albums
|
||||
_albumApiRepository.getAll(shared: true),
|
||||
// Passing null (or nothing) for `shared` returns only albums that
|
||||
// explicitly belong to us
|
||||
_albumApiRepository.getAll(shared: null),
|
||||
).wait;
|
||||
|
||||
final albums = HashSet<Album>(equals: (a, b) => a.remoteId == b.remoteId, hashCode: (a) => a.remoteId.hashCode);
|
||||
|
||||
albums.addAll(sharedAlbum);
|
||||
albums.addAll(ownedAlbum);
|
||||
|
||||
changes = await _syncService.syncRemoteAlbumsToDb(albums.toList());
|
||||
} finally {
|
||||
_remoteCompleter.complete(changes);
|
||||
}
|
||||
dPrint(() => "refreshRemoteAlbums took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<Album?> createAlbum(
|
||||
String albumName,
|
||||
Iterable<Asset> assets, [
|
||||
Iterable<UserDto> sharedUsers = const [],
|
||||
]) async {
|
||||
final Album album = await _albumApiRepository.create(
|
||||
albumName,
|
||||
assetIds: assets.map((asset) => asset.remoteId!),
|
||||
sharedUserIds: sharedUsers.map((user) => user.id),
|
||||
);
|
||||
await _entityService.fillAlbumWithDatabaseEntities(album);
|
||||
return _albumRepository.create(album);
|
||||
}
|
||||
|
||||
/*
|
||||
* Creates names like Untitled, Untitled (1), Untitled (2), ...
|
||||
*/
|
||||
Future<String> _getNextAlbumName() async {
|
||||
const baseName = "Untitled";
|
||||
for (int round = 0; ; round++) {
|
||||
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
|
||||
|
||||
if (null == await _albumRepository.getByName(proposedName, owner: true)) {
|
||||
return proposedName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Album?> createAlbumWithGeneratedName(Iterable<Asset> assets) async {
|
||||
return createAlbum(await _getNextAlbumName(), assets, []);
|
||||
}
|
||||
|
||||
Future<AlbumAddAssetsResponse?> addAssets(Album album, Iterable<Asset> assets) async {
|
||||
try {
|
||||
final result = await _albumApiRepository.addAssets(album.remoteId!, assets.map((asset) => asset.remoteId!));
|
||||
|
||||
final List<Asset> addedAssets = result.added
|
||||
.map((id) => assets.firstWhere((asset) => asset.remoteId == id))
|
||||
.toList();
|
||||
|
||||
await _updateAssets(album.id, add: addedAssets);
|
||||
|
||||
return AlbumAddAssetsResponse(alreadyInAlbum: result.duplicates, successfullyAdded: addedAssets.length);
|
||||
} catch (e) {
|
||||
dPrint(() => "Error addAssets ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _updateAssets(int albumId, {List<Asset> add = const [], List<Asset> remove = const []}) =>
|
||||
_albumRepository.transaction(() async {
|
||||
final album = await _albumRepository.get(albumId);
|
||||
if (album == null) return;
|
||||
await _albumRepository.addAssets(album, add);
|
||||
await _albumRepository.removeAssets(album, remove);
|
||||
await _albumRepository.recalculateMetadata(album);
|
||||
await _albumRepository.update(album);
|
||||
});
|
||||
|
||||
Future<bool> setActivityStatus(Album album, bool enabled) async {
|
||||
try {
|
||||
final updatedAlbum = await _albumApiRepository.update(album.remoteId!, activityEnabled: enabled);
|
||||
album.activityEnabled = updatedAlbum.activityEnabled;
|
||||
await _albumRepository.update(album);
|
||||
return true;
|
||||
} catch (e) {
|
||||
dPrint(() => "Error setActivityEnabled ${e.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> deleteAlbum(Album album) async {
|
||||
try {
|
||||
final userId = _userService.getMyUser().id;
|
||||
if (album.owner.value?.isarId == fastHash(userId)) {
|
||||
await _albumApiRepository.delete(album.remoteId!);
|
||||
}
|
||||
if (album.shared) {
|
||||
final foreignAssets = await _assetRepository.getByAlbum(album, notOwnedBy: [userId]);
|
||||
await _albumRepository.delete(album.id);
|
||||
|
||||
final List<Album> albums = await _albumRepository.getAll(shared: true);
|
||||
final List<Asset> existing = [];
|
||||
for (Album album in albums) {
|
||||
existing.addAll(await _assetRepository.getByAlbum(album, notOwnedBy: [userId]));
|
||||
}
|
||||
final List<int> idsToRemove = _syncService.sharedAssetsToRemove(foreignAssets, existing);
|
||||
if (idsToRemove.isNotEmpty) {
|
||||
await _assetRepository.deleteByIds(idsToRemove);
|
||||
}
|
||||
} else {
|
||||
await _albumRepository.delete(album.id);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
dPrint(() => "Error deleteAlbum ${e.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> leaveAlbum(Album album) async {
|
||||
try {
|
||||
await _albumApiRepository.removeUser(album.remoteId!, userId: "me");
|
||||
return true;
|
||||
} catch (e) {
|
||||
dPrint(() => "Error leaveAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> removeAsset(Album album, Iterable<Asset> assets) async {
|
||||
try {
|
||||
final result = await _albumApiRepository.removeAssets(album.remoteId!, assets.map((asset) => asset.remoteId!));
|
||||
final toRemove = result.removed.map((id) => assets.firstWhere((asset) => asset.remoteId == id));
|
||||
await _updateAssets(album.id, remove: toRemove.toList());
|
||||
return true;
|
||||
} catch (e) {
|
||||
dPrint(() => "Error removeAssetFromAlbum ${e.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> removeUser(Album album, UserDto user) async {
|
||||
try {
|
||||
await _albumApiRepository.removeUser(album.remoteId!, userId: user.id);
|
||||
|
||||
album.sharedUsers.remove(entity.User.fromDto(user));
|
||||
await _albumRepository.removeUsers(album, [user]);
|
||||
final a = await _albumRepository.get(album.id);
|
||||
// trigger watcher
|
||||
await _albumRepository.update(a!);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
dPrint(() => "Error removeUser ${error.toString()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> addUsers(Album album, List<String> userIds) async {
|
||||
try {
|
||||
final updatedAlbum = await _albumApiRepository.addUsers(album.remoteId!, userIds);
|
||||
|
||||
album.sharedUsers.addAll(updatedAlbum.remoteUsers);
|
||||
album.shared = true;
|
||||
|
||||
await _albumRepository.addUsers(album, album.sharedUsers.map((u) => u.toDto()).toList());
|
||||
await _albumRepository.update(album);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
dPrint(() => "Error addUsers ${error.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> changeTitleAlbum(Album album, String newAlbumTitle) async {
|
||||
try {
|
||||
final updatedAlbum = await _albumApiRepository.update(album.remoteId!, name: newAlbumTitle);
|
||||
|
||||
album.name = updatedAlbum.name;
|
||||
await _albumRepository.update(album);
|
||||
return true;
|
||||
} catch (e) {
|
||||
dPrint(() => "Error changeTitleAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> changeDescriptionAlbum(Album album, String newAlbumDescription) async {
|
||||
try {
|
||||
final updatedAlbum = await _albumApiRepository.update(album.remoteId!, description: newAlbumDescription);
|
||||
|
||||
album.description = updatedAlbum.description;
|
||||
await _albumRepository.update(album);
|
||||
return true;
|
||||
} catch (e) {
|
||||
dPrint(() => "Error changeDescriptionAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Album?> getAlbumByName(String name, {bool? remote, bool? shared, bool? owner}) =>
|
||||
_albumRepository.getByName(name, remote: remote, shared: shared, owner: owner);
|
||||
|
||||
///
|
||||
/// Add the uploaded asset to the selected albums
|
||||
///
|
||||
Future<void> syncUploadAlbums(List<String> albumNames, List<String> assetIds) async {
|
||||
for (final albumName in albumNames) {
|
||||
Album? album = await getAlbumByName(albumName, remote: true, owner: true);
|
||||
album ??= await createAlbum(albumName, []);
|
||||
if (album != null && album.remoteId != null) {
|
||||
await _albumApiRepository.addAssets(album.remoteId!, assetIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Album>> getAllRemoteAlbums() async {
|
||||
return _albumRepository.getAll(remote: true);
|
||||
}
|
||||
|
||||
Future<List<Album>> getAllLocalAlbums() async {
|
||||
return _albumRepository.getAll(remote: false);
|
||||
}
|
||||
|
||||
Stream<List<Album>> watchRemoteAlbums() {
|
||||
return _albumRepository.watchRemoteAlbums();
|
||||
}
|
||||
|
||||
Stream<List<Album>> watchLocalAlbums() {
|
||||
return _albumRepository.watchLocalAlbums();
|
||||
}
|
||||
|
||||
/// Get album by Isar ID
|
||||
Future<Album?> getAlbumById(int id) {
|
||||
return _albumRepository.get(id);
|
||||
}
|
||||
|
||||
Future<Album?> getAlbumByRemoteId(String remoteId) {
|
||||
return _albumRepository.getByRemoteId(remoteId);
|
||||
}
|
||||
|
||||
Stream<Album?> watchAlbum(int id) {
|
||||
return _albumRepository.watchAlbum(id);
|
||||
}
|
||||
|
||||
Future<List<Album>> search(String searchTerm, QuickFilterMode filterMode) async {
|
||||
return _albumRepository.search(searchTerm, filterMode);
|
||||
}
|
||||
|
||||
Future<Album?> updateSortOrder(Album album, SortOrder order) async {
|
||||
try {
|
||||
final updateAlbum = await _albumApiRepository.update(album.remoteId!, sortOrder: order);
|
||||
album.sortOrder = updateAlbum.sortOrder;
|
||||
|
||||
return _albumRepository.update(album);
|
||||
} catch (error, stackTrace) {
|
||||
_log.severe("Error updating album sort order", error, stackTrace);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> clearTable() async {
|
||||
await _albumRepository.clearTable();
|
||||
}
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/repositories/etag.repository.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/backup.service.dart';
|
||||
import 'package:immich_mobile/services/sync.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
final assetServiceProvider = Provider(
|
||||
(ref) => AssetService(
|
||||
ref.watch(assetApiRepositoryProvider),
|
||||
ref.watch(assetRepositoryProvider),
|
||||
ref.watch(exifRepositoryProvider),
|
||||
ref.watch(userRepositoryProvider),
|
||||
ref.watch(etagRepositoryProvider),
|
||||
ref.watch(backupAlbumRepositoryProvider),
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(backupServiceProvider),
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class AssetService {
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final AssetRepository _assetRepository;
|
||||
final IsarExifRepository _exifInfoRepository;
|
||||
final IsarUserRepository _isarUserRepository;
|
||||
final ETagRepository _etagRepository;
|
||||
final BackupAlbumRepository _backupRepository;
|
||||
final ApiService _apiService;
|
||||
final SyncService _syncService;
|
||||
final BackupService _backupService;
|
||||
final AlbumService _albumService;
|
||||
final UserService _userService;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final log = Logger('AssetService');
|
||||
|
||||
AssetService(
|
||||
this._assetApiRepository,
|
||||
this._assetRepository,
|
||||
this._exifInfoRepository,
|
||||
this._isarUserRepository,
|
||||
this._etagRepository,
|
||||
this._backupRepository,
|
||||
this._apiService,
|
||||
this._syncService,
|
||||
this._backupService,
|
||||
this._albumService,
|
||||
this._userService,
|
||||
this._assetMediaRepository,
|
||||
);
|
||||
|
||||
/// Checks the server for updated assets and updates the local database if
|
||||
/// required. Returns `true` if there were any changes.
|
||||
Future<bool> refreshRemoteAssets() async {
|
||||
final syncedUserIds = await _etagRepository.getAllIds();
|
||||
final List<UserDto> syncedUsers = syncedUserIds.isEmpty
|
||||
? []
|
||||
: (await _isarUserRepository.getByUserIds(syncedUserIds)).nonNulls.toList();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
final bool changes = await _syncService.syncRemoteAssetsToDb(
|
||||
users: syncedUsers,
|
||||
getChangedAssets: _getRemoteAssetChanges,
|
||||
loadAssets: _getRemoteAssets,
|
||||
);
|
||||
dPrint(() => "refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// Returns `(null, null)` if changes are invalid -> requires full sync
|
||||
Future<(List<Asset>? toUpsert, List<String>? toDelete)> _getRemoteAssetChanges(
|
||||
List<UserDto> users,
|
||||
DateTime since,
|
||||
) async {
|
||||
final dto = AssetDeltaSyncDto(updatedAfter: since, userIds: users.map((e) => e.id).toList());
|
||||
final changes = await _apiService.syncApi.getDeltaSync(dto);
|
||||
return changes == null || changes.needsFullSync
|
||||
? (null, null)
|
||||
: (changes.upserted.map(Asset.remote).toList(), changes.deleted);
|
||||
}
|
||||
|
||||
/// Returns the list of people of the given asset id.
|
||||
// If the server is not reachable `null` is returned.
|
||||
Future<List<PersonWithFacesResponseDto>?> getRemotePeopleOfAsset(String remoteId) async {
|
||||
try {
|
||||
final AssetResponseDto? dto = await _apiService.assetsApi.getAssetInfo(remoteId);
|
||||
|
||||
return dto?.people;
|
||||
} catch (error, stack) {
|
||||
log.severe('Error while getting remote asset info: ${error.toString()}', error, stack);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `null` if the server state did not change, else list of assets
|
||||
Future<List<Asset>?> _getRemoteAssets(UserDto user, DateTime until) async {
|
||||
const int chunkSize = 10000;
|
||||
try {
|
||||
final List<Asset> allAssets = [];
|
||||
String? lastId;
|
||||
// will break on error or once all assets are loaded
|
||||
while (true) {
|
||||
final dto = AssetFullSyncDto(limit: chunkSize, updatedUntil: until, lastId: lastId, userId: user.id);
|
||||
log.fine("Requesting $chunkSize assets from $lastId");
|
||||
final List<AssetResponseDto>? assets = await _apiService.syncApi.getFullSyncForUser(dto);
|
||||
if (assets == null) return null;
|
||||
log.fine("Received ${assets.length} assets from ${assets.firstOrNull?.id} to ${assets.lastOrNull?.id}");
|
||||
allAssets.addAll(assets.map(Asset.remote));
|
||||
if (assets.length != chunkSize) break;
|
||||
lastId = assets.last.id;
|
||||
}
|
||||
return allAssets;
|
||||
} catch (error, stack) {
|
||||
log.severe('Error while getting remote assets', error, stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the exif information from the database. If there is none, loads
|
||||
/// the exif info from the server (remote assets only)
|
||||
Future<Asset> loadExif(Asset a) async {
|
||||
a.exifInfo ??= (await _exifInfoRepository.get(a.id));
|
||||
// fileSize is always filled on the server but not set on client
|
||||
if (a.exifInfo?.fileSize == null) {
|
||||
if (a.isRemote) {
|
||||
final dto = await _apiService.assetsApi.getAssetInfo(a.remoteId!);
|
||||
if (dto != null && dto.exifInfo != null) {
|
||||
final newExif = Asset.remote(dto).exifInfo!.copyWith(assetId: a.id);
|
||||
a.exifInfo = newExif;
|
||||
if (newExif != a.exifInfo) {
|
||||
if (a.isInDb) {
|
||||
await _assetRepository.transaction(() => _assetRepository.update(a));
|
||||
} else {
|
||||
dPrint(() => "[loadExif] parameter Asset is not from DB!");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO implement local exif info parsing
|
||||
}
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
Future<void> updateAssets(List<Asset> assets, UpdateAssetDto updateAssetDto) async {
|
||||
return await _apiService.assetsApi.updateAssets(
|
||||
AssetBulkUpdateDto(
|
||||
ids: assets.map((e) => e.remoteId!).toList(),
|
||||
dateTimeOriginal: updateAssetDto.dateTimeOriginal,
|
||||
isFavorite: updateAssetDto.isFavorite,
|
||||
visibility: updateAssetDto.visibility,
|
||||
latitude: updateAssetDto.latitude,
|
||||
longitude: updateAssetDto.longitude,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Asset>> changeFavoriteStatus(List<Asset> assets, bool isFavorite) async {
|
||||
try {
|
||||
await updateAssets(assets, UpdateAssetDto(isFavorite: isFavorite));
|
||||
|
||||
for (var element in assets) {
|
||||
element.isFavorite = isFavorite;
|
||||
}
|
||||
|
||||
await _syncService.upsertAssetsWithExif(assets);
|
||||
|
||||
return assets;
|
||||
} catch (error, stack) {
|
||||
log.severe("Error while changing favorite status", error, stack);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Asset>> changeArchiveStatus(List<Asset> assets, bool isArchived) async {
|
||||
try {
|
||||
await updateAssets(
|
||||
assets,
|
||||
UpdateAssetDto(visibility: isArchived ? AssetVisibility.archive : AssetVisibility.timeline),
|
||||
);
|
||||
|
||||
for (var element in assets) {
|
||||
element.isArchived = isArchived;
|
||||
element.visibility = isArchived ? AssetVisibilityEnum.archive : AssetVisibilityEnum.timeline;
|
||||
}
|
||||
|
||||
await _syncService.upsertAssetsWithExif(assets);
|
||||
|
||||
return assets;
|
||||
} catch (error, stack) {
|
||||
log.severe("Error while changing archive status", error, stack);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Asset>?> changeDateTime(List<Asset> assets, String updatedDt) async {
|
||||
try {
|
||||
await updateAssets(assets, UpdateAssetDto(dateTimeOriginal: updatedDt));
|
||||
|
||||
for (var element in assets) {
|
||||
element.fileCreatedAt = DateTime.parse(updatedDt);
|
||||
element.exifInfo = element.exifInfo?.copyWith(dateTimeOriginal: DateTime.parse(updatedDt));
|
||||
}
|
||||
|
||||
await _syncService.upsertAssetsWithExif(assets);
|
||||
|
||||
return assets;
|
||||
} catch (error, stack) {
|
||||
log.severe("Error while changing date/time status", error, stack);
|
||||
return Future.value(null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Asset>?> changeLocation(List<Asset> assets, LatLng location) async {
|
||||
try {
|
||||
await updateAssets(assets, UpdateAssetDto(latitude: location.latitude, longitude: location.longitude));
|
||||
|
||||
for (var element in assets) {
|
||||
element.exifInfo = element.exifInfo?.copyWith(latitude: location.latitude, longitude: location.longitude);
|
||||
}
|
||||
|
||||
await _syncService.upsertAssetsWithExif(assets);
|
||||
|
||||
return assets;
|
||||
} catch (error, stack) {
|
||||
log.severe("Error while changing location status", error, stack);
|
||||
return Future.value(null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> syncUploadedAssetToAlbums() async {
|
||||
try {
|
||||
final selectedAlbums = await _backupRepository.getAllBySelection(BackupSelection.select);
|
||||
final excludedAlbums = await _backupRepository.getAllBySelection(BackupSelection.exclude);
|
||||
|
||||
final candidates = await _backupService.buildUploadCandidates(
|
||||
selectedAlbums,
|
||||
excludedAlbums,
|
||||
useTimeFilter: false,
|
||||
);
|
||||
|
||||
await refreshRemoteAssets();
|
||||
final owner = _userService.getMyUser();
|
||||
final remoteAssets = await _assetRepository.getAll(ownerId: owner.id, state: AssetState.merged);
|
||||
|
||||
/// Map<AlbumName, [AssetId]>
|
||||
Map<String, List<String>> assetToAlbums = {};
|
||||
|
||||
for (BackupCandidate candidate in candidates) {
|
||||
final asset = remoteAssets.firstWhereOrNull((a) => a.localId == candidate.asset.localId);
|
||||
|
||||
if (asset != null) {
|
||||
for (final albumName in candidate.albumNames) {
|
||||
assetToAlbums.putIfAbsent(albumName, () => []).add(asset.remoteId!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upload assets to albums
|
||||
for (final entry in assetToAlbums.entries) {
|
||||
final albumName = entry.key;
|
||||
final assetIds = entry.value;
|
||||
|
||||
await _albumService.syncUploadAlbums([albumName], assetIds);
|
||||
}
|
||||
} catch (error, stack) {
|
||||
log.severe("Error while syncing uploaded asset to albums", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setDescription(Asset asset, String newDescription) async {
|
||||
final remoteAssetId = asset.remoteId;
|
||||
final localExifId = asset.exifInfo?.assetId;
|
||||
|
||||
// Guard [remoteAssetId] and [localExifId] null
|
||||
if (remoteAssetId == null || localExifId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await _assetApiRepository.update(remoteAssetId, description: newDescription);
|
||||
|
||||
final description = result.exifInfo?.description;
|
||||
|
||||
if (description != null) {
|
||||
var exifInfo = await _exifInfoRepository.get(localExifId);
|
||||
|
||||
if (exifInfo != null) {
|
||||
await _exifInfoRepository.update(exifInfo.copyWith(description: description));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> getDescription(Asset asset) async {
|
||||
final localExifId = asset.exifInfo?.assetId;
|
||||
|
||||
// Guard [remoteAssetId] and [localExifId] null
|
||||
if (localExifId == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
final exifInfo = await _exifInfoRepository.get(localExifId);
|
||||
|
||||
return exifInfo?.description ?? "";
|
||||
}
|
||||
|
||||
Future<double> getAspectRatio(Asset asset) async {
|
||||
if (asset.isRemote) {
|
||||
asset = await loadExif(asset);
|
||||
} else if (asset.isLocal) {
|
||||
await asset.localAsync;
|
||||
}
|
||||
|
||||
final aspectRatio = asset.aspectRatio;
|
||||
if (aspectRatio != null) {
|
||||
return aspectRatio;
|
||||
}
|
||||
|
||||
final width = asset.width;
|
||||
final height = asset.height;
|
||||
if (width != null && height != null) {
|
||||
// we don't know the orientation, so assume it's normal
|
||||
return width / height;
|
||||
}
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
Future<List<Asset>> getStackAssets(String stackId) {
|
||||
return _assetRepository.getStackAssets(stackId);
|
||||
}
|
||||
|
||||
Future<void> clearTable() {
|
||||
return _assetRepository.clearTable();
|
||||
}
|
||||
|
||||
/// Delete assets from local file system and unreference from the database
|
||||
Future<void> deleteLocalAssets(Iterable<Asset> assets) async {
|
||||
// Delete files from local gallery
|
||||
final candidates = assets.where((asset) => asset.isLocal);
|
||||
|
||||
final deletedIds = await _assetMediaRepository.deleteAll(candidates.map((asset) => asset.localId!).toList());
|
||||
|
||||
// Modify local database by removing the reference to the local assets
|
||||
if (deletedIds.isNotEmpty) {
|
||||
// Delete records from local database
|
||||
final isarIds = assets.where((asset) => asset.storage == AssetState.local).map((asset) => asset.id).toList();
|
||||
await _assetRepository.deleteByIds(isarIds);
|
||||
|
||||
// Modify Merged asset to be remote only
|
||||
final updatedAssets = assets.where((asset) => asset.storage == AssetState.merged).map((asset) {
|
||||
asset.localId = null;
|
||||
return asset;
|
||||
}).toList();
|
||||
|
||||
await _assetRepository.updateAll(updatedAssets);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete assets from the server and unreference from the database
|
||||
Future<void> deleteRemoteAssets(Iterable<Asset> assets, {bool shouldDeletePermanently = false}) async {
|
||||
final candidates = assets.where((a) => a.isRemote);
|
||||
|
||||
if (candidates.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _apiService.assetsApi.deleteAssets(
|
||||
AssetBulkDeleteDto(ids: candidates.map((a) => a.remoteId!).toList(), force: shouldDeletePermanently),
|
||||
);
|
||||
|
||||
/// Update asset info bassed on the deletion type.
|
||||
final payload = shouldDeletePermanently
|
||||
? assets.where((asset) => asset.storage == AssetState.merged).map((asset) {
|
||||
asset.remoteId = null;
|
||||
asset.visibility = AssetVisibilityEnum.timeline;
|
||||
return asset;
|
||||
})
|
||||
: assets.where((asset) => asset.isRemote).map((asset) {
|
||||
asset.isTrashed = true;
|
||||
return asset;
|
||||
});
|
||||
|
||||
await _assetRepository.transaction(() async {
|
||||
await _assetRepository.updateAll(payload.toList());
|
||||
|
||||
if (shouldDeletePermanently) {
|
||||
final remoteAssetIds = assets
|
||||
.where((asset) => asset.storage == AssetState.remote)
|
||||
.map((asset) => asset.id)
|
||||
.toList();
|
||||
await _assetRepository.deleteByIds(remoteAssetIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete assets on both local file system and the server.
|
||||
/// Unreference from the database.
|
||||
Future<void> deleteAssets(Iterable<Asset> assets, {bool shouldDeletePermanently = false}) async {
|
||||
final hasLocal = assets.any((asset) => asset.isLocal);
|
||||
final hasRemote = assets.any((asset) => asset.isRemote);
|
||||
|
||||
if (hasLocal) {
|
||||
await deleteLocalAssets(assets);
|
||||
}
|
||||
|
||||
if (hasRemote) {
|
||||
await deleteRemoteAssets(assets, shouldDeletePermanently: shouldDeletePermanently);
|
||||
}
|
||||
}
|
||||
|
||||
Stream<Asset?> watchAsset(int id, {bool fireImmediately = false}) {
|
||||
return _assetRepository.watchAsset(id, fireImmediately: fireImmediately);
|
||||
}
|
||||
|
||||
Future<List<Asset>> getRecentlyTakenAssets() {
|
||||
final me = _userService.getMyUser();
|
||||
return _assetRepository.getRecentlyTakenAssets(me.id);
|
||||
}
|
||||
|
||||
Future<List<Asset>> getMotionAssets() {
|
||||
final me = _userService.getMyUser();
|
||||
return _assetRepository.getMotionAssets(me.id);
|
||||
}
|
||||
|
||||
Future<void> setVisibility(List<Asset> assets, AssetVisibilityEnum visibility) async {
|
||||
await _assetApiRepository.updateVisibility(assets.map((asset) => asset.remoteId!).toList(), visibility);
|
||||
|
||||
final updatedAssets = assets.map((asset) {
|
||||
asset.visibility = visibility;
|
||||
return asset;
|
||||
}).toList();
|
||||
|
||||
await _assetRepository.updateAll(updatedAssets);
|
||||
}
|
||||
|
||||
Future<Asset?> getAssetByRemoteId(String remoteId) async {
|
||||
final assets = await _assetRepository.getAllByRemoteId([remoteId]);
|
||||
return assets.isNotEmpty ? assets.first : null;
|
||||
}
|
||||
}
|
||||
@@ -1,595 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/backup.service.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
import 'package:immich_mobile/utils/backup_progress.dart';
|
||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:path_provider_foundation/path_provider_foundation.dart';
|
||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||
|
||||
final backgroundServiceProvider = Provider((ref) => BackgroundService());
|
||||
|
||||
/// Background backup service
|
||||
class BackgroundService {
|
||||
static const String _portNameLock = "immichLock";
|
||||
static const MethodChannel _foregroundChannel = MethodChannel('immich/foregroundChannel');
|
||||
static const MethodChannel _backgroundChannel = MethodChannel('immich/backgroundChannel');
|
||||
static const notifyInterval = Duration(milliseconds: 400);
|
||||
bool _isBackgroundInitialized = false;
|
||||
Completer<void>? _cancellationToken;
|
||||
bool _canceledBySystem = false;
|
||||
int _wantsLockTime = 0;
|
||||
bool _hasLock = false;
|
||||
SendPort? _waitingIsolate;
|
||||
ReceivePort? _rp;
|
||||
bool _errorGracePeriodExceeded = true;
|
||||
int _uploadedAssetsCount = 0;
|
||||
int _assetsToUploadCount = 0;
|
||||
String _lastPrintedDetailContent = "";
|
||||
String? _lastPrintedDetailTitle;
|
||||
late final ThrottleProgressUpdate _throttledNotifiy = ThrottleProgressUpdate(_updateProgress, notifyInterval);
|
||||
late final ThrottleProgressUpdate _throttledDetailNotify = ThrottleProgressUpdate(
|
||||
_updateDetailProgress,
|
||||
notifyInterval,
|
||||
);
|
||||
|
||||
bool get isBackgroundInitialized {
|
||||
return _isBackgroundInitialized;
|
||||
}
|
||||
|
||||
/// Ensures that the background service is enqueued if enabled in settings
|
||||
Future<bool> resumeServiceIfEnabled() async {
|
||||
return await isBackgroundBackupEnabled() && await enableService();
|
||||
}
|
||||
|
||||
/// Enqueues the background service
|
||||
Future<bool> enableService({bool immediate = false}) async {
|
||||
try {
|
||||
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
|
||||
final String title = "backup_background_service_default_notification".tr();
|
||||
final bool ok = await _foregroundChannel.invokeMethod('enable', [callback.toRawHandle(), title, immediate]);
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the background service
|
||||
Future<bool> configureService({
|
||||
bool requireUnmetered = true,
|
||||
bool requireCharging = false,
|
||||
int triggerUpdateDelay = 5000,
|
||||
int triggerMaxDelay = 50000,
|
||||
}) async {
|
||||
try {
|
||||
final bool ok = await _foregroundChannel.invokeMethod('configure', [
|
||||
requireUnmetered,
|
||||
requireCharging,
|
||||
triggerUpdateDelay,
|
||||
triggerMaxDelay,
|
||||
]);
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels the background service (if currently running) and removes it from work queue
|
||||
Future<bool> disableService() async {
|
||||
try {
|
||||
final ok = await _foregroundChannel.invokeMethod('disable');
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the background service is enabled
|
||||
Future<bool> isBackgroundBackupEnabled() async {
|
||||
try {
|
||||
return await _foregroundChannel.invokeMethod("isEnabled");
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if battery optimizations are disabled
|
||||
Future<bool> isIgnoringBatteryOptimizations() async {
|
||||
// iOS does not need battery optimizations enabled
|
||||
if (Platform.isIOS) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return await _foregroundChannel.invokeMethod('isIgnoringBatteryOptimizations');
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Yet to be implemented
|
||||
Future<Uint8List?> digestFile(String path) {
|
||||
return _foregroundChannel.invokeMethod<Uint8List>("digestFile", [path]);
|
||||
}
|
||||
|
||||
Future<List<Uint8List?>?> digestFiles(List<String> paths) {
|
||||
return _foregroundChannel.invokeListMethod<Uint8List?>("digestFiles", paths);
|
||||
}
|
||||
|
||||
/// Updates the notification shown by the background service
|
||||
Future<bool?> _updateNotification({
|
||||
String? title,
|
||||
String? content,
|
||||
int progress = 0,
|
||||
int max = 0,
|
||||
bool indeterminate = false,
|
||||
bool isDetail = false,
|
||||
bool onlyIfFG = false,
|
||||
}) async {
|
||||
try {
|
||||
if (_isBackgroundInitialized) {
|
||||
return _backgroundChannel.invokeMethod<bool>('updateNotification', [
|
||||
title,
|
||||
content,
|
||||
progress,
|
||||
max,
|
||||
indeterminate,
|
||||
isDetail,
|
||||
onlyIfFG,
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
dPrint(() => "[_updateNotification] failed to communicate with plugin");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Shows a new priority notification
|
||||
Future<bool> _showErrorNotification({required String title, String? content, String? individualTag}) async {
|
||||
try {
|
||||
if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
|
||||
return await _backgroundChannel.invokeMethod('showError', [title, content, individualTag]);
|
||||
}
|
||||
} catch (error) {
|
||||
dPrint(() => "[_showErrorNotification] failed to communicate with plugin");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _clearErrorNotifications() async {
|
||||
try {
|
||||
if (_isBackgroundInitialized) {
|
||||
return await _backgroundChannel.invokeMethod('clearErrorNotifications');
|
||||
}
|
||||
} catch (error) {
|
||||
dPrint(() => "[_clearErrorNotifications] failed to communicate with plugin");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// await to ensure this thread (foreground or background) has exclusive access
|
||||
Future<bool> acquireLock() async {
|
||||
if (_hasLock) {
|
||||
dPrint(() => "WARNING: [acquireLock] called more than once");
|
||||
return true;
|
||||
}
|
||||
final int lockTime = Timeline.now;
|
||||
_wantsLockTime = lockTime;
|
||||
final ReceivePort rp = ReceivePort(_portNameLock);
|
||||
_rp = rp;
|
||||
final SendPort sp = rp.sendPort;
|
||||
|
||||
while (!IsolateNameServer.registerPortWithName(sp, _portNameLock)) {
|
||||
try {
|
||||
await _checkLockReleasedWithHeartbeat(lockTime);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
if (_wantsLockTime != lockTime) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_hasLock = true;
|
||||
rp.listen(_heartbeatListener);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _checkLockReleasedWithHeartbeat(final int lockTime) async {
|
||||
SendPort? other = IsolateNameServer.lookupPortByName(_portNameLock);
|
||||
if (other != null) {
|
||||
final ReceivePort tempRp = ReceivePort();
|
||||
final SendPort tempSp = tempRp.sendPort;
|
||||
final bs = tempRp.asBroadcastStream();
|
||||
while (_wantsLockTime == lockTime) {
|
||||
other.send(tempSp);
|
||||
final dynamic answer = await bs.first.timeout(const Duration(seconds: 3), onTimeout: () => null);
|
||||
if (_wantsLockTime != lockTime) {
|
||||
break;
|
||||
}
|
||||
if (answer == null) {
|
||||
// other isolate failed to answer, assuming it exited without releasing the lock
|
||||
if (other == IsolateNameServer.lookupPortByName(_portNameLock)) {
|
||||
IsolateNameServer.removePortNameMapping(_portNameLock);
|
||||
}
|
||||
break;
|
||||
} else if (answer == true) {
|
||||
// other isolate released the lock
|
||||
break;
|
||||
} else if (answer == false) {
|
||||
// other isolate is still active
|
||||
}
|
||||
final dynamic isFinished = await bs.first.timeout(const Duration(seconds: 3), onTimeout: () => false);
|
||||
if (isFinished == true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tempRp.close();
|
||||
}
|
||||
}
|
||||
|
||||
void _heartbeatListener(dynamic msg) {
|
||||
if (msg is SendPort) {
|
||||
_waitingIsolate = msg;
|
||||
msg.send(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// releases the exclusive access lock
|
||||
void releaseLock() {
|
||||
_wantsLockTime = 0;
|
||||
if (_hasLock) {
|
||||
IsolateNameServer.removePortNameMapping(_portNameLock);
|
||||
_waitingIsolate?.send(true);
|
||||
_waitingIsolate = null;
|
||||
_hasLock = false;
|
||||
}
|
||||
_rp?.close();
|
||||
_rp = null;
|
||||
}
|
||||
|
||||
void _setupBackgroundCallHandler() {
|
||||
_backgroundChannel.setMethodCallHandler(_callHandler);
|
||||
_isBackgroundInitialized = true;
|
||||
_backgroundChannel.invokeMethod('initialized');
|
||||
}
|
||||
|
||||
Future<bool> _callHandler(MethodCall call) async {
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
if (Platform.isIOS) {
|
||||
// NOTE: I'm not sure this is strictly necessary anymore, but
|
||||
// out of an abundance of caution, we will keep it in until someone
|
||||
// can say for sure
|
||||
PathProviderFoundation.registerWith();
|
||||
}
|
||||
switch (call.method) {
|
||||
case "backgroundProcessing":
|
||||
case "onAssetsChanged":
|
||||
try {
|
||||
unawaited(_clearErrorNotifications());
|
||||
|
||||
// iOS should time out after some threshold so it doesn't wait
|
||||
// indefinitely and can run later
|
||||
// Android is fine to wait here until the lock releases
|
||||
final waitForLock = Platform.isIOS
|
||||
? acquireLock().timeout(const Duration(seconds: 5), onTimeout: () => false)
|
||||
: acquireLock();
|
||||
|
||||
final bool hasAccess = await waitForLock;
|
||||
if (!hasAccess) {
|
||||
dPrint(() => "[_callHandler] could not acquire lock, exiting");
|
||||
return false;
|
||||
}
|
||||
|
||||
final translationsOk = await loadTranslations();
|
||||
if (!translationsOk) {
|
||||
dPrint(() => "[_callHandler] could not load translations");
|
||||
}
|
||||
|
||||
final bool ok = await _onAssetsChanged();
|
||||
return ok;
|
||||
} catch (error) {
|
||||
dPrint(() => error.toString());
|
||||
return false;
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
case "systemStop":
|
||||
_canceledBySystem = true;
|
||||
_cancellationToken?.complete();
|
||||
_cancellationToken = null;
|
||||
return true;
|
||||
default:
|
||||
dPrint(() => "Unknown method ${call.method}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _onAssetsChanged() async {
|
||||
final (isar, drift, logDb) = await Bootstrap.initDB();
|
||||
await Bootstrap.initDomain(isar, drift, logDb, shouldBufferLogs: false, listenStoreUpdates: false);
|
||||
|
||||
final ref = ProviderContainer(
|
||||
overrides: [
|
||||
dbProvider.overrideWithValue(isar),
|
||||
isarProvider.overrideWithValue(isar),
|
||||
driftProvider.overrideWith(driftOverride(drift)),
|
||||
],
|
||||
);
|
||||
|
||||
await ref.read(authServiceProvider).setOpenApiServiceEndpoint();
|
||||
dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}");
|
||||
|
||||
final selectedAlbums = await ref.read(backupAlbumRepositoryProvider).getAllBySelection(BackupSelection.select);
|
||||
final excludedAlbums = await ref.read(backupAlbumRepositoryProvider).getAllBySelection(BackupSelection.exclude);
|
||||
if (selectedAlbums.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await ref.read(fileMediaRepositoryProvider).enableBackgroundAccess();
|
||||
|
||||
do {
|
||||
final bool backupOk = await _runBackup(
|
||||
ref.read(backupServiceProvider),
|
||||
ref.read(appSettingsServiceProvider),
|
||||
selectedAlbums,
|
||||
excludedAlbums,
|
||||
);
|
||||
if (backupOk) {
|
||||
await Store.delete(StoreKey.backupFailedSince);
|
||||
final backupAlbums = [...selectedAlbums, ...excludedAlbums];
|
||||
backupAlbums.sortBy((e) => e.id);
|
||||
|
||||
final dbAlbums = await ref.read(backupAlbumRepositoryProvider).getAll(sort: BackupAlbumSort.id);
|
||||
final List<int> toDelete = [];
|
||||
final List<BackupAlbum> toUpsert = [];
|
||||
// stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state
|
||||
diffSortedListsSync(
|
||||
dbAlbums,
|
||||
backupAlbums,
|
||||
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
|
||||
both: (BackupAlbum a, BackupAlbum b) {
|
||||
a.lastBackup = a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup;
|
||||
toUpsert.add(a);
|
||||
return true;
|
||||
},
|
||||
onlyFirst: (BackupAlbum a) => toUpsert.add(a),
|
||||
onlySecond: (BackupAlbum b) => toDelete.add(b.isarId),
|
||||
);
|
||||
await ref.read(backupAlbumRepositoryProvider).deleteAll(toDelete);
|
||||
await ref.read(backupAlbumRepositoryProvider).updateAll(toUpsert);
|
||||
} else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
|
||||
await Store.put(StoreKey.backupFailedSince, DateTime.now());
|
||||
return false;
|
||||
}
|
||||
// Android should check for new assets added while performing backup
|
||||
} while (Platform.isAndroid && true == await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _runBackup(
|
||||
BackupService backupService,
|
||||
AppSettingsService settingsService,
|
||||
List<BackupAlbum> selectedAlbums,
|
||||
List<BackupAlbum> excludedAlbums,
|
||||
) async {
|
||||
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
|
||||
final bool notifyTotalProgress = settingsService.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
|
||||
final bool notifySingleProgress = settingsService.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
|
||||
|
||||
if (_canceledBySystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Set<BackupCandidate> toUpload = await backupService.buildUploadCandidates(selectedAlbums, excludedAlbums);
|
||||
|
||||
try {
|
||||
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
|
||||
} catch (e) {
|
||||
unawaited(
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_error_title".tr(),
|
||||
content: "backup_background_service_connection_failed_message".tr(),
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_canceledBySystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toUpload.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
_assetsToUploadCount = toUpload.length;
|
||||
_uploadedAssetsCount = 0;
|
||||
unawaited(
|
||||
_updateNotification(
|
||||
title: "backup_background_service_in_progress_notification".tr(),
|
||||
content: notifyTotalProgress ? formatAssetBackupProgress(_uploadedAssetsCount, _assetsToUploadCount) : null,
|
||||
progress: 0,
|
||||
max: notifyTotalProgress ? _assetsToUploadCount : 0,
|
||||
indeterminate: !notifyTotalProgress,
|
||||
onlyIfFG: !notifyTotalProgress,
|
||||
),
|
||||
);
|
||||
|
||||
_cancellationToken?.complete();
|
||||
_cancellationToken = Completer<void>();
|
||||
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
|
||||
|
||||
final bool ok = await backupService.backupAsset(
|
||||
toUpload,
|
||||
_cancellationToken!,
|
||||
pmProgressHandler: pmProgressHandler,
|
||||
onSuccess: (result) => _onAssetUploaded(shouldNotify: notifyTotalProgress),
|
||||
onProgress: (bytes, totalBytes) => _onProgress(bytes, totalBytes, shouldNotify: notifySingleProgress),
|
||||
onCurrentAsset: (asset) => _onSetCurrentBackupAsset(asset, shouldNotify: notifySingleProgress),
|
||||
onError: _onBackupError,
|
||||
isBackground: true,
|
||||
);
|
||||
|
||||
if (!ok && !_cancellationToken!.isCompleted) {
|
||||
unawaited(
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_error_title".tr(),
|
||||
content: "backup_background_service_backup_failed_message".tr(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
void _onAssetUploaded({bool shouldNotify = false}) {
|
||||
if (!shouldNotify) {
|
||||
return;
|
||||
}
|
||||
|
||||
_uploadedAssetsCount++;
|
||||
_throttledNotifiy();
|
||||
}
|
||||
|
||||
void _onProgress(int bytes, int totalBytes, {bool shouldNotify = false}) {
|
||||
if (!shouldNotify) {
|
||||
return;
|
||||
}
|
||||
|
||||
_throttledDetailNotify(progress: bytes, total: totalBytes);
|
||||
}
|
||||
|
||||
void _updateDetailProgress(String? title, int progress, int total) {
|
||||
final String msg = total > 0 ? humanReadableBytesProgress(progress, total) : "";
|
||||
// only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
|
||||
if (msg != _lastPrintedDetailContent || _lastPrintedDetailTitle != title) {
|
||||
_lastPrintedDetailContent = msg;
|
||||
_lastPrintedDetailTitle = title;
|
||||
_updateNotification(
|
||||
progress: total > 0 ? (progress * 1000) ~/ total : 0,
|
||||
max: 1000,
|
||||
isDetail: true,
|
||||
title: title,
|
||||
content: msg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateProgress(String? title, int progress, int total) {
|
||||
_updateNotification(
|
||||
progress: _uploadedAssetsCount,
|
||||
max: _assetsToUploadCount,
|
||||
title: title,
|
||||
content: formatAssetBackupProgress(_uploadedAssetsCount, _assetsToUploadCount),
|
||||
);
|
||||
}
|
||||
|
||||
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_upload_failure_notification".tr(
|
||||
namedArgs: {'filename': errorAssetInfo.fileName},
|
||||
),
|
||||
individualTag: errorAssetInfo.id,
|
||||
);
|
||||
}
|
||||
|
||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset, {bool shouldNotify = false}) {
|
||||
if (!shouldNotify) {
|
||||
return;
|
||||
}
|
||||
|
||||
_throttledDetailNotify.title = "backup_background_service_current_upload_notification".tr(
|
||||
namedArgs: {'filename': currentUploadAsset.fileName},
|
||||
);
|
||||
_throttledDetailNotify.progress = 0;
|
||||
_throttledDetailNotify.total = 0;
|
||||
}
|
||||
|
||||
bool _isErrorGracePeriodExceeded(AppSettingsService appSettingsService) {
|
||||
final int value = appSettingsService.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
|
||||
if (value == 0) {
|
||||
return true;
|
||||
} else if (value == 5) {
|
||||
return false;
|
||||
}
|
||||
final DateTime? failedSince = Store.tryGet(StoreKey.backupFailedSince);
|
||||
if (failedSince == null) {
|
||||
return false;
|
||||
}
|
||||
final Duration duration = DateTime.now().difference(failedSince);
|
||||
if (value == 1) {
|
||||
return duration > const Duration(minutes: 30);
|
||||
} else if (value == 2) {
|
||||
return duration > const Duration(hours: 2);
|
||||
} else if (value == 3) {
|
||||
return duration > const Duration(hours: 8);
|
||||
} else if (value == 4) {
|
||||
return duration > const Duration(hours: 24);
|
||||
}
|
||||
assert(false, "Invalid value");
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<DateTime?> getIOSBackupLastRun(IosBackgroundTask task) async {
|
||||
if (!Platform.isIOS) {
|
||||
return null;
|
||||
}
|
||||
// Seconds since last run
|
||||
final double? lastRun = task == IosBackgroundTask.fetch
|
||||
? await _foregroundChannel.invokeMethod('lastBackgroundFetchTime')
|
||||
: await _foregroundChannel.invokeMethod('lastBackgroundProcessingTime');
|
||||
if (lastRun == null) {
|
||||
return null;
|
||||
}
|
||||
final time = DateTime.fromMillisecondsSinceEpoch(lastRun.toInt() * 1000);
|
||||
return time;
|
||||
}
|
||||
|
||||
Future<int> getIOSBackupNumberOfProcesses() async {
|
||||
if (!Platform.isIOS) {
|
||||
return 0;
|
||||
}
|
||||
return await _foregroundChannel.invokeMethod('numberOfBackgroundProcesses');
|
||||
}
|
||||
|
||||
Future<bool> getIOSBackgroundAppRefreshEnabled() async {
|
||||
if (!Platform.isIOS) {
|
||||
return false;
|
||||
}
|
||||
return await _foregroundChannel.invokeMethod('backgroundAppRefreshEnabled');
|
||||
}
|
||||
}
|
||||
|
||||
enum IosBackgroundTask { fetch, processing }
|
||||
|
||||
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
||||
@pragma('vm:entry-point')
|
||||
void _nativeEntry() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
BackgroundService backgroundService = BackgroundService();
|
||||
backgroundService._setupBackgroundCallHandler();
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:permission_handler/permission_handler.dart' as pm;
|
||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
final backupServiceProvider = Provider(
|
||||
(ref) => BackupService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(albumMediaRepositoryProvider),
|
||||
ref.watch(fileMediaRepositoryProvider),
|
||||
ref.watch(assetRepositoryProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class BackupService {
|
||||
final ApiService _apiService;
|
||||
final Logger _log = Logger("BackupService");
|
||||
final AppSettingsService _appSetting;
|
||||
final AlbumService _albumService;
|
||||
final AlbumMediaRepository _albumMediaRepository;
|
||||
final FileMediaRepository _fileMediaRepository;
|
||||
final AssetRepository _assetRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
|
||||
BackupService(
|
||||
this._apiService,
|
||||
this._appSetting,
|
||||
this._albumService,
|
||||
this._albumMediaRepository,
|
||||
this._fileMediaRepository,
|
||||
this._assetRepository,
|
||||
this._assetMediaRepository,
|
||||
);
|
||||
|
||||
Future<List<String>?> getDeviceBackupAsset() async {
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
try {
|
||||
return await _apiService.assetsApi.getAllUserAssetsByDeviceId(deviceId);
|
||||
} catch (e) {
|
||||
dPrint(() => 'Error [getDeviceBackupAsset] ${e.toString()}');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveDuplicatedAssetIds(List<String> deviceAssetIds) =>
|
||||
_assetRepository.transaction(() => _assetRepository.upsertDuplicatedAssets(deviceAssetIds));
|
||||
|
||||
/// Get duplicated asset id from database
|
||||
Future<Set<String>> getDuplicatedAssetIds() async {
|
||||
final duplicates = await _assetRepository.getAllDuplicatedAssetIds();
|
||||
return duplicates.toSet();
|
||||
}
|
||||
|
||||
/// Returns all assets newer than the last successful backup per album
|
||||
/// if `useTimeFilter` is set to true, all assets will be returned
|
||||
Future<Set<BackupCandidate>> buildUploadCandidates(
|
||||
List<BackupAlbum> selectedBackupAlbums,
|
||||
List<BackupAlbum> excludedBackupAlbums, {
|
||||
bool useTimeFilter = true,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
|
||||
final Set<BackupCandidate> toAdd = await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedBackupAlbums,
|
||||
now,
|
||||
useTimeFilter: useTimeFilter,
|
||||
);
|
||||
|
||||
if (toAdd.isEmpty) return {};
|
||||
|
||||
final Set<BackupCandidate> toRemove = await _fetchAssetsAndUpdateLastBackup(
|
||||
excludedBackupAlbums,
|
||||
now,
|
||||
useTimeFilter: useTimeFilter,
|
||||
);
|
||||
|
||||
return toAdd.difference(toRemove);
|
||||
}
|
||||
|
||||
Future<Set<BackupCandidate>> _fetchAssetsAndUpdateLastBackup(
|
||||
List<BackupAlbum> backupAlbums,
|
||||
DateTime now, {
|
||||
bool useTimeFilter = true,
|
||||
}) async {
|
||||
Set<BackupCandidate> candidates = {};
|
||||
|
||||
for (final BackupAlbum backupAlbum in backupAlbums) {
|
||||
final Album localAlbum;
|
||||
try {
|
||||
localAlbum = await _albumMediaRepository.get(backupAlbum.id);
|
||||
} on StateError {
|
||||
// the album no longer exists
|
||||
continue;
|
||||
}
|
||||
|
||||
if (useTimeFilter && localAlbum.modifiedAt.isBefore(backupAlbum.lastBackup)) {
|
||||
continue;
|
||||
}
|
||||
final List<Asset> assets;
|
||||
try {
|
||||
assets = await _albumMediaRepository.getAssets(
|
||||
backupAlbum.id,
|
||||
modifiedFrom: useTimeFilter
|
||||
?
|
||||
// subtract 2 seconds to prevent missing assets due to rounding issues
|
||||
backupAlbum.lastBackup.subtract(const Duration(seconds: 2))
|
||||
: null,
|
||||
modifiedUntil: useTimeFilter ? now : null,
|
||||
);
|
||||
} on StateError {
|
||||
// either there are no assets matching the filter criteria OR the album no longer exists
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add album's name to the asset info
|
||||
for (final asset in assets) {
|
||||
List<String> albumNames = [localAlbum.name];
|
||||
|
||||
final existingAsset = candidates.firstWhereOrNull((candidate) => candidate.asset.localId == asset.localId);
|
||||
|
||||
if (existingAsset != null) {
|
||||
albumNames.addAll(existingAsset.albumNames);
|
||||
candidates.remove(existingAsset);
|
||||
}
|
||||
|
||||
candidates.add(BackupCandidate(asset: asset, albumNames: albumNames));
|
||||
}
|
||||
|
||||
backupAlbum.lastBackup = now;
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/// Returns a new list of assets not yet uploaded
|
||||
Future<Set<BackupCandidate>> removeAlreadyUploadedAssets(Set<BackupCandidate> candidates) async {
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
|
||||
candidates.removeWhere((candidate) => duplicatedAssetIds.contains(candidate.asset.localId));
|
||||
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
final Set<String> existing = {};
|
||||
try {
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
final CheckExistingAssetsResponseDto? duplicates = await _apiService.assetsApi.checkExistingAssets(
|
||||
CheckExistingAssetsDto(deviceAssetIds: candidates.map((c) => c.asset.localId!).toList(), deviceId: deviceId),
|
||||
);
|
||||
if (duplicates != null) {
|
||||
existing.addAll(duplicates.existingIds);
|
||||
}
|
||||
} on ApiException {
|
||||
// workaround for older server versions or when checking for too many assets at once
|
||||
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
|
||||
if (allAssetsInDatabase != null) {
|
||||
existing.addAll(allAssetsInDatabase);
|
||||
}
|
||||
}
|
||||
|
||||
if (existing.isNotEmpty) {
|
||||
candidates.removeWhere((c) => existing.contains(c.asset.localId));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
Future<bool> _checkPermissions() async {
|
||||
if (Platform.isAndroid && !(await pm.Permission.accessMediaLocation.status).isGranted) {
|
||||
// double check that permission is granted here, to guard against
|
||||
// uploading corrupt assets without EXIF information
|
||||
_log.warning(
|
||||
"Media location permission is not granted. "
|
||||
"Cannot access original assets for backup.",
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
|
||||
if (Platform.isIOS) {
|
||||
await _fileMediaRepository.requestExtendedPermissions();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Upload images before video assets for background tasks
|
||||
/// these are further sorted by using their creation date
|
||||
List<BackupCandidate> _sortPhotosFirst(List<BackupCandidate> candidates) {
|
||||
return candidates.sorted((a, b) {
|
||||
final cmp = a.asset.type.index - b.asset.type.index;
|
||||
if (cmp != 0) return cmp;
|
||||
return a.asset.fileCreatedAt.compareTo(b.asset.fileCreatedAt);
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> backupAsset(
|
||||
Iterable<BackupCandidate> assets,
|
||||
Completer<void> cancelToken, {
|
||||
bool isBackground = false,
|
||||
PMProgressHandler? pmProgressHandler,
|
||||
required void Function(SuccessUploadAsset result) onSuccess,
|
||||
required void Function(int bytes, int totalBytes) onProgress,
|
||||
required void Function(CurrentUploadAsset asset) onCurrentAsset,
|
||||
required void Function(ErrorUploadAsset error) onError,
|
||||
}) async {
|
||||
final bool isIgnoreIcloudAssets = _appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets);
|
||||
final shouldSyncAlbums = _appSetting.getSetting(AppSettingsEnum.syncAlbums);
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final List<String> duplicatedAssetIds = [];
|
||||
bool anyErrors = false;
|
||||
|
||||
final hasPermission = await _checkPermissions();
|
||||
if (!hasPermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<BackupCandidate> candidates = assets.toList();
|
||||
if (isBackground) {
|
||||
candidates = _sortPhotosFirst(candidates);
|
||||
}
|
||||
|
||||
for (final candidate in candidates) {
|
||||
final Asset asset = candidate.asset;
|
||||
File? file;
|
||||
File? livePhotoFile;
|
||||
|
||||
try {
|
||||
final isAvailableLocally = await asset.local!.isLocallyAvailable(isOrigin: true);
|
||||
|
||||
// Handle getting files from iCloud
|
||||
if (!isAvailableLocally && Platform.isIOS) {
|
||||
// Skip iCloud assets if the user has disabled this feature
|
||||
if (isIgnoreIcloudAssets) {
|
||||
continue;
|
||||
}
|
||||
|
||||
onCurrentAsset(
|
||||
CurrentUploadAsset(
|
||||
id: asset.localId!,
|
||||
fileCreatedAt: asset.fileCreatedAt.year == 1970 ? asset.fileModifiedAt : asset.fileCreatedAt,
|
||||
fileName: asset.fileName,
|
||||
fileType: _getAssetType(asset.type),
|
||||
iCloudAsset: true,
|
||||
),
|
||||
);
|
||||
|
||||
file = await asset.local!.loadFile(progressHandler: pmProgressHandler);
|
||||
if (asset.local!.isLivePhoto) {
|
||||
livePhotoFile = await asset.local!.loadFile(withSubtype: true, progressHandler: pmProgressHandler);
|
||||
}
|
||||
} else {
|
||||
file = await asset.local!.originFile.timeout(const Duration(seconds: 5));
|
||||
|
||||
if (asset.local!.isLivePhoto) {
|
||||
livePhotoFile = await asset.local!.originFileWithSubtype.timeout(const Duration(seconds: 5));
|
||||
}
|
||||
}
|
||||
|
||||
if (file != null) {
|
||||
String? originalFileName = await _assetMediaRepository.getOriginalFilename(asset.localId!);
|
||||
originalFileName ??= asset.fileName;
|
||||
|
||||
if (asset.local!.isLivePhoto) {
|
||||
if (livePhotoFile == null) {
|
||||
_log.warning("Failed to obtain motion part of the livePhoto - $originalFileName");
|
||||
}
|
||||
}
|
||||
|
||||
final fileStream = file.openRead();
|
||||
final assetRawUploadData = MultipartFile(
|
||||
"assetData",
|
||||
fileStream,
|
||||
file.lengthSync(),
|
||||
filename: originalFileName,
|
||||
);
|
||||
|
||||
final baseRequest = ProgressMultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$savedEndpoint/assets'),
|
||||
abortTrigger: cancelToken.future,
|
||||
onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
|
||||
);
|
||||
|
||||
baseRequest.fields['deviceAssetId'] = asset.localId!;
|
||||
baseRequest.fields['deviceId'] = deviceId;
|
||||
baseRequest.fields['fileCreatedAt'] = asset.fileCreatedAt.toUtc().toIso8601String();
|
||||
baseRequest.fields['fileModifiedAt'] = asset.fileModifiedAt.toUtc().toIso8601String();
|
||||
baseRequest.fields['isFavorite'] = asset.isFavorite.toString();
|
||||
baseRequest.fields['duration'] = asset.duration.toString();
|
||||
baseRequest.files.add(assetRawUploadData);
|
||||
|
||||
onCurrentAsset(
|
||||
CurrentUploadAsset(
|
||||
id: asset.localId!,
|
||||
fileCreatedAt: asset.fileCreatedAt.year == 1970 ? asset.fileModifiedAt : asset.fileCreatedAt,
|
||||
fileName: originalFileName,
|
||||
fileType: _getAssetType(asset.type),
|
||||
fileSize: file.lengthSync(),
|
||||
iCloudAsset: false,
|
||||
),
|
||||
);
|
||||
|
||||
String? livePhotoVideoId;
|
||||
if (asset.local!.isLivePhoto && livePhotoFile != null) {
|
||||
livePhotoVideoId = await uploadLivePhotoVideo(originalFileName, livePhotoFile, baseRequest, cancelToken);
|
||||
}
|
||||
|
||||
if (livePhotoVideoId != null) {
|
||||
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
|
||||
}
|
||||
|
||||
final response = await NetworkRepository.client.send(baseRequest);
|
||||
|
||||
final responseBody = jsonDecode(await response.stream.bytesToString());
|
||||
|
||||
if (![200, 201].contains(response.statusCode)) {
|
||||
final error = responseBody;
|
||||
final errorMessage = error['message'] ?? error['error'];
|
||||
|
||||
dPrint(
|
||||
() =>
|
||||
"Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}",
|
||||
);
|
||||
|
||||
onError(
|
||||
ErrorUploadAsset(
|
||||
asset: asset,
|
||||
id: asset.localId!,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileName: originalFileName,
|
||||
fileType: _getAssetType(candidate.asset.type),
|
||||
errorMessage: errorMessage,
|
||||
),
|
||||
);
|
||||
|
||||
if (errorMessage == "Quota has been exceeded!") {
|
||||
anyErrors = true;
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
bool isDuplicate = false;
|
||||
if (response.statusCode == 200) {
|
||||
isDuplicate = true;
|
||||
duplicatedAssetIds.add(asset.localId!);
|
||||
}
|
||||
|
||||
onSuccess(
|
||||
SuccessUploadAsset(
|
||||
candidate: candidate,
|
||||
remoteAssetId: responseBody['id'] as String,
|
||||
isDuplicate: isDuplicate,
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldSyncAlbums) {
|
||||
await _albumService.syncUploadAlbums(candidate.albumNames, [responseBody['id'] as String]);
|
||||
}
|
||||
}
|
||||
} on RequestAbortedException {
|
||||
dPrint(() => "Backup was cancelled by the user");
|
||||
anyErrors = true;
|
||||
break;
|
||||
} catch (error, stackTrace) {
|
||||
dPrint(() => "Error backup asset: ${error.toString()}: $stackTrace");
|
||||
anyErrors = true;
|
||||
continue;
|
||||
} finally {
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
await file?.delete();
|
||||
await livePhotoFile?.delete();
|
||||
} catch (e) {
|
||||
dPrint(() => "ERROR deleting file: ${e.toString()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicatedAssetIds.isNotEmpty) {
|
||||
await _saveDuplicatedAssetIds(duplicatedAssetIds);
|
||||
}
|
||||
|
||||
return !anyErrors;
|
||||
}
|
||||
|
||||
Future<String?> uploadLivePhotoVideo(
|
||||
String originalFileName,
|
||||
File? livePhotoVideoFile,
|
||||
MultipartRequest baseRequest,
|
||||
Completer cancelToken,
|
||||
) async {
|
||||
if (livePhotoVideoFile == null) {
|
||||
return null;
|
||||
}
|
||||
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoVideoFile.path));
|
||||
final fileStream = livePhotoVideoFile.openRead();
|
||||
final livePhotoRawUploadData = MultipartFile(
|
||||
"assetData",
|
||||
fileStream,
|
||||
livePhotoVideoFile.lengthSync(),
|
||||
filename: livePhotoTitle,
|
||||
);
|
||||
final livePhotoReq = ProgressMultipartRequest(baseRequest.method, baseRequest.url, abortTrigger: cancelToken.future)
|
||||
..headers.addAll(baseRequest.headers)
|
||||
..fields.addAll(baseRequest.fields);
|
||||
|
||||
livePhotoReq.files.add(livePhotoRawUploadData);
|
||||
|
||||
var response = await NetworkRepository.client.send(livePhotoReq);
|
||||
|
||||
var responseBody = jsonDecode(await response.stream.bytesToString());
|
||||
|
||||
if (![200, 201].contains(response.statusCode)) {
|
||||
var error = responseBody;
|
||||
|
||||
dPrint(
|
||||
() => "Error(${error['statusCode']}) uploading livePhoto for assetId | $livePhotoTitle | ${error['error']}",
|
||||
);
|
||||
}
|
||||
|
||||
return responseBody.containsKey('id') ? responseBody['id'] : null;
|
||||
}
|
||||
|
||||
String _getAssetType(AssetType assetType) => switch (assetType) {
|
||||
AssetType.audio => "AUDIO",
|
||||
AssetType.image => "IMAGE",
|
||||
AssetType.video => "VIDEO",
|
||||
AssetType.other => "OTHER",
|
||||
};
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/repositories/backup.repository.dart';
|
||||
|
||||
final backupAlbumServiceProvider = Provider<BackupAlbumService>((ref) {
|
||||
return BackupAlbumService(ref.watch(backupAlbumRepositoryProvider));
|
||||
});
|
||||
|
||||
class BackupAlbumService {
|
||||
final BackupAlbumRepository _backupAlbumRepository;
|
||||
|
||||
const BackupAlbumService(this._backupAlbumRepository);
|
||||
|
||||
Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort}) {
|
||||
return _backupAlbumRepository.getAll(sort: sort);
|
||||
}
|
||||
|
||||
Future<List<String>> getIdsBySelection(BackupSelection backup) {
|
||||
return _backupAlbumRepository.getIdsBySelection(backup);
|
||||
}
|
||||
|
||||
Future<List<BackupAlbum>> getAllBySelection(BackupSelection backup) {
|
||||
return _backupAlbumRepository.getAllBySelection(backup);
|
||||
}
|
||||
|
||||
Future<void> deleteAll(List<int> ids) {
|
||||
return _backupAlbumRepository.deleteAll(ids);
|
||||
}
|
||||
|
||||
Future<void> updateAll(List<BackupAlbum> backupAlbums) {
|
||||
return _backupAlbumRepository.updateAll(backupAlbums);
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
|
||||
/// Finds duplicates originating from missing EXIF information
|
||||
class BackupVerificationService {
|
||||
final UserService _userService;
|
||||
final FileMediaRepository _fileMediaRepository;
|
||||
final AssetRepository _assetRepository;
|
||||
final IsarExifRepository _exifInfoRepository;
|
||||
|
||||
const BackupVerificationService(
|
||||
this._userService,
|
||||
this._fileMediaRepository,
|
||||
this._assetRepository,
|
||||
this._exifInfoRepository,
|
||||
);
|
||||
|
||||
/// Returns at most [limit] assets that were backed up without exif
|
||||
Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
|
||||
final owner = _userService.getMyUser().id;
|
||||
final List<Asset> onlyLocal = await _assetRepository.getAll(ownerId: owner, state: AssetState.local, limit: limit);
|
||||
final List<Asset> remoteMatches = await _assetRepository.getMatches(
|
||||
assets: onlyLocal,
|
||||
ownerId: owner,
|
||||
state: AssetState.remote,
|
||||
limit: limit,
|
||||
);
|
||||
final List<Asset> localMatches = await _assetRepository.getMatches(
|
||||
assets: remoteMatches,
|
||||
ownerId: owner,
|
||||
state: AssetState.local,
|
||||
limit: limit,
|
||||
);
|
||||
|
||||
final List<Asset> deleteCandidates = [], originals = [];
|
||||
|
||||
await diffSortedLists(
|
||||
remoteMatches,
|
||||
localMatches,
|
||||
compare: (a, b) => a.fileName.compareTo(b.fileName),
|
||||
both: (a, b) async {
|
||||
a.exifInfo = await _exifInfoRepository.get(a.id);
|
||||
deleteCandidates.add(a);
|
||||
originals.add(b);
|
||||
return false;
|
||||
},
|
||||
onlyFirst: (a) {},
|
||||
onlySecond: (b) {},
|
||||
);
|
||||
final isolateToken = ServicesBinding.rootIsolateToken!;
|
||||
final List<Asset> toDelete;
|
||||
if (deleteCandidates.length > 10) {
|
||||
// performs 2 checks in parallel for a nice speedup
|
||||
final half = deleteCandidates.length ~/ 2;
|
||||
final lower = compute(_computeSaveToDelete, (
|
||||
deleteCandidates: deleteCandidates.slice(0, half),
|
||||
originals: originals.slice(0, half),
|
||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||
rootIsolateToken: isolateToken,
|
||||
fileMediaRepository: _fileMediaRepository,
|
||||
));
|
||||
final upper = compute(_computeSaveToDelete, (
|
||||
deleteCandidates: deleteCandidates.slice(half),
|
||||
originals: originals.slice(half),
|
||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||
rootIsolateToken: isolateToken,
|
||||
fileMediaRepository: _fileMediaRepository,
|
||||
));
|
||||
toDelete = await lower + await upper;
|
||||
} else {
|
||||
toDelete = await compute(_computeSaveToDelete, (
|
||||
deleteCandidates: deleteCandidates,
|
||||
originals: originals,
|
||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||
rootIsolateToken: isolateToken,
|
||||
fileMediaRepository: _fileMediaRepository,
|
||||
));
|
||||
}
|
||||
return toDelete;
|
||||
}
|
||||
|
||||
static Future<List<Asset>> _computeSaveToDelete(
|
||||
({
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> originals,
|
||||
String endpoint,
|
||||
RootIsolateToken rootIsolateToken,
|
||||
FileMediaRepository fileMediaRepository,
|
||||
})
|
||||
tuple,
|
||||
) async {
|
||||
assert(tuple.deleteCandidates.length == tuple.originals.length);
|
||||
final List<Asset> result = [];
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
|
||||
final (isar, drift, logDb) = await Bootstrap.initDB();
|
||||
await Bootstrap.initDomain(isar, drift, logDb);
|
||||
await tuple.fileMediaRepository.enableBackgroundAccess();
|
||||
final ApiService apiService = ApiService();
|
||||
apiService.setEndpoint(tuple.endpoint);
|
||||
for (int i = 0; i < tuple.deleteCandidates.length; i++) {
|
||||
if (await _compareAssets(tuple.deleteCandidates[i], tuple.originals[i], apiService)) {
|
||||
result.add(tuple.deleteCandidates[i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<bool> _compareAssets(Asset remote, Asset local, ApiService apiService) async {
|
||||
if (remote.checksum == local.checksum) return false;
|
||||
ExifInfo? exif = remote.exifInfo;
|
||||
if (exif != null && exif.latitude != null) return false;
|
||||
if (exif == null || exif.fileSize == null) {
|
||||
final dto = await apiService.assetsApi.getAssetInfo(remote.remoteId!);
|
||||
if (dto != null && dto.exifInfo != null) {
|
||||
exif = ExifDtoConverter.fromDto(dto.exifInfo!);
|
||||
}
|
||||
}
|
||||
final file = await local.local!.originFile;
|
||||
if (exif != null && file != null && exif.fileSize != null) {
|
||||
final origSize = await file.length();
|
||||
if (exif.fileSize! == origSize || exif.fileSize! != origSize) {
|
||||
final latLng = await local.local!.latlngAsync();
|
||||
|
||||
if (exif.latitude == null &&
|
||||
latLng.latitude != null &&
|
||||
(remote.fileCreatedAt.isAtSameMomentAs(local.fileCreatedAt) ||
|
||||
remote.fileModifiedAt.isAtSameMomentAs(local.fileModifiedAt) ||
|
||||
_sameExceptTimeZone(remote.fileCreatedAt, local.fileCreatedAt))) {
|
||||
if (remote.type == AssetType.video) {
|
||||
// it's very unlikely that a video of same length, filesize, name
|
||||
// and date is wrong match. Cannot easily compare videos anyway
|
||||
return true;
|
||||
}
|
||||
|
||||
// for images: make sure they are pixel-wise identical
|
||||
// (skip first few KBs containing metadata)
|
||||
final Uint64List localImage = _fakeDecodeImg(await file.readAsBytes());
|
||||
final res = await apiService.assetsApi.downloadAssetWithHttpInfo(remote.remoteId!);
|
||||
final Uint64List remoteImage = _fakeDecodeImg(res.bodyBytes);
|
||||
|
||||
final eq = const ListEquality().equals(remoteImage, localImage);
|
||||
return eq;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Uint64List _fakeDecodeImg(Uint8List bytes) {
|
||||
const headerLength = 131072; // assume header is at most 128 KB
|
||||
final start = bytes.length < headerLength * 2 ? (bytes.length ~/ (4 * 8)) * 8 : headerLength;
|
||||
return bytes.buffer.asUint64List(start);
|
||||
}
|
||||
|
||||
static bool _sameExceptTimeZone(DateTime a, DateTime b) {
|
||||
final ms = a.isAfter(b)
|
||||
? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch
|
||||
: b.millisecondsSinceEpoch - a.microsecondsSinceEpoch;
|
||||
final x = ms / (1000 * 60 * 30);
|
||||
final y = ms ~/ (1000 * 60 * 30);
|
||||
return y.toDouble() == x && y < 24;
|
||||
}
|
||||
}
|
||||
|
||||
final backupVerificationServiceProvider = Provider(
|
||||
(ref) => BackupVerificationService(
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(fileMediaRepositoryProvider),
|
||||
ref.watch(assetRepositoryProvider),
|
||||
ref.watch(exifRepositoryProvider),
|
||||
),
|
||||
);
|
||||
@@ -7,10 +7,7 @@ import 'package:immich_mobile/domain/services/memory.service.dart';
|
||||
import 'package:immich_mobile/domain/services/people.service.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider;
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
@@ -18,19 +15,9 @@ import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/services/asset.service.dart';
|
||||
import 'package:immich_mobile/services/memory.service.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||
|
||||
final deepLinkServiceProvider = Provider(
|
||||
(ref) => DeepLinkService(
|
||||
ref.watch(memoryServiceProvider),
|
||||
ref.watch(assetServiceProvider),
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(currentAssetProvider.notifier),
|
||||
ref.watch(currentAlbumProvider.notifier),
|
||||
// Below is used for beta timeline
|
||||
ref.watch(timelineFactoryProvider),
|
||||
ref.watch(beta_asset_provider.assetServiceProvider),
|
||||
ref.watch(remoteAlbumServiceProvider),
|
||||
@@ -41,14 +28,6 @@ final deepLinkServiceProvider = Provider(
|
||||
);
|
||||
|
||||
class DeepLinkService {
|
||||
/// TODO: Remove this when beta is default
|
||||
final MemoryService _memoryService;
|
||||
final AssetService _assetService;
|
||||
final AlbumService _albumService;
|
||||
final CurrentAsset _currentAsset;
|
||||
final CurrentAlbum _currentAlbum;
|
||||
|
||||
/// Used for beta timeline
|
||||
final TimelineFactory _betaTimelineFactory;
|
||||
final beta_asset_service.AssetService _betaAssetService;
|
||||
final RemoteAlbumService _betaRemoteAlbumService;
|
||||
@@ -58,11 +37,6 @@ class DeepLinkService {
|
||||
final UserDto? _currentUser;
|
||||
|
||||
const DeepLinkService(
|
||||
this._memoryService,
|
||||
this._assetService,
|
||||
this._albumService,
|
||||
this._currentAsset,
|
||||
this._currentAlbum,
|
||||
this._betaTimelineFactory,
|
||||
this._betaAssetService,
|
||||
this._betaRemoteAlbumService,
|
||||
@@ -75,7 +49,7 @@ class DeepLinkService {
|
||||
return DeepLink([
|
||||
// we need something to segue back to if the app was cold started
|
||||
// TODO: use MainTimelineRoute this when beta is default
|
||||
if (isColdStart) (Store.isBetaTimelineEnabled) ? const TabShellRoute() : const PhotosRoute(),
|
||||
if (isColdStart) const TabShellRoute(),
|
||||
route,
|
||||
]);
|
||||
}
|
||||
@@ -138,95 +112,52 @@ class DeepLinkService {
|
||||
}
|
||||
|
||||
Future<PageRouteInfo?> _buildMemoryDeepLink(String? memoryId) async {
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
List<DriftMemory> memories = [];
|
||||
List<DriftMemory> memories = [];
|
||||
|
||||
if (memoryId == null) {
|
||||
if (_currentUser == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
memories = await _betaMemoryService.getMemoryLane(_currentUser.id);
|
||||
} else {
|
||||
final memory = await _betaMemoryService.get(memoryId);
|
||||
if (memory != null) {
|
||||
memories = [memory];
|
||||
}
|
||||
}
|
||||
|
||||
if (memories.isEmpty) {
|
||||
if (memoryId == null) {
|
||||
if (_currentUser == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DriftMemoryRoute(memories: memories, memoryIndex: 0);
|
||||
memories = await _betaMemoryService.getMemoryLane(_currentUser.id);
|
||||
} else {
|
||||
// TODO: Remove this when beta is default
|
||||
if (memoryId == null) {
|
||||
return null;
|
||||
final memory = await _betaMemoryService.get(memoryId);
|
||||
if (memory != null) {
|
||||
memories = [memory];
|
||||
}
|
||||
final memory = await _memoryService.getMemoryById(memoryId);
|
||||
|
||||
if (memory == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MemoryRoute(memories: [memory], memoryIndex: 0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageRouteInfo?> _buildAssetDeepLink(String assetId, WidgetRef ref) async {
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
final asset = await _betaAssetService.getRemoteAsset(assetId);
|
||||
if (asset == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
AssetViewer.setAsset(ref, asset);
|
||||
return AssetViewerRoute(
|
||||
initialIndex: 0,
|
||||
timelineService: _betaTimelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
|
||||
);
|
||||
} else {
|
||||
// TODO: Remove this when beta is default
|
||||
final asset = await _assetService.getAssetByRemoteId(assetId);
|
||||
if (asset == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
_currentAsset.set(asset);
|
||||
final renderList = await RenderList.fromAssets([asset], GroupAssetsBy.auto);
|
||||
|
||||
return GalleryViewerRoute(renderList: renderList, initialIndex: 0, heroOffset: 0, showStack: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageRouteInfo?> _buildAlbumDeepLink(String albumId) async {
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
final album = await _betaRemoteAlbumService.get(albumId);
|
||||
|
||||
if (album == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RemoteAlbumRoute(album: album);
|
||||
} else {
|
||||
// TODO: Remove this when beta is default
|
||||
final album = await _albumService.getAlbumByRemoteId(albumId);
|
||||
|
||||
if (album == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
_currentAlbum.set(album);
|
||||
return AlbumViewerRoute(albumId: album.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageRouteInfo?> _buildActivityDeepLink(String albumId) async {
|
||||
if (Store.isBetaTimelineEnabled == false) {
|
||||
if (memories.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DriftMemoryRoute(memories: memories, memoryIndex: 0);
|
||||
}
|
||||
|
||||
Future<PageRouteInfo?> _buildAssetDeepLink(String assetId, WidgetRef ref) async {
|
||||
final asset = await _betaAssetService.getRemoteAsset(assetId);
|
||||
if (asset == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
AssetViewer.setAsset(ref, asset);
|
||||
return AssetViewerRoute(
|
||||
initialIndex: 0,
|
||||
timelineService: _betaTimelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
|
||||
);
|
||||
}
|
||||
|
||||
Future<PageRouteInfo?> _buildAlbumDeepLink(String albumId) async {
|
||||
final album = await _betaRemoteAlbumService.get(albumId);
|
||||
|
||||
if (album == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RemoteAlbumRoute(album: album);
|
||||
}
|
||||
|
||||
Future<PageRouteInfo?> _buildActivityDeepLink(String albumId) async {
|
||||
final album = await _betaRemoteAlbumService.get(albumId);
|
||||
|
||||
if (album == null || album.isActivityEnabled == false) {
|
||||
@@ -237,10 +168,6 @@ class DeepLinkService {
|
||||
}
|
||||
|
||||
Future<PageRouteInfo?> _buildPeopleDeepLink(String personId) async {
|
||||
if (Store.isBetaTimelineEnabled == false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final person = await _betaPeopleService.get(personId);
|
||||
|
||||
if (person == null) {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
final deviceServiceProvider = Provider((ref) => const DeviceService());
|
||||
|
||||
class DeviceService {
|
||||
const DeviceService();
|
||||
|
||||
createDeviceId() {
|
||||
return FlutterUdid.consistentUdid;
|
||||
}
|
||||
|
||||
/// Returns the device ID from local storage or creates a new one if not found.
|
||||
///
|
||||
/// This method first attempts to retrieve the device ID from the local store using
|
||||
/// [StoreKey.deviceId]. If no device ID is found (returns null), it generates a
|
||||
/// new device ID by calling [createDeviceId].
|
||||
///
|
||||
/// Returns a [String] representing the device's unique identifier.
|
||||
String getDeviceId() {
|
||||
return Store.tryGet(StoreKey.deviceId) ?? createDeviceId();
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,9 @@ import 'dart:io';
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final downloadServiceProvider = Provider(
|
||||
@@ -54,7 +49,7 @@ class DownloadService {
|
||||
final title = task.filename;
|
||||
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
|
||||
try {
|
||||
final Asset? resultAsset = await _fileMediaRepository.saveImageWithFile(
|
||||
final resultAsset = await _fileMediaRepository.saveImageWithFile(
|
||||
filePath,
|
||||
title: title,
|
||||
relativePath: relativePath,
|
||||
@@ -76,7 +71,7 @@ class DownloadService {
|
||||
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
|
||||
final file = File(filePath);
|
||||
try {
|
||||
final Asset? resultAsset = await _fileMediaRepository.saveVideo(file, title: title, relativePath: relativePath);
|
||||
final resultAsset = await _fileMediaRepository.saveVideo(file, title: title, relativePath: relativePath);
|
||||
return resultAsset != null;
|
||||
} catch (error, stack) {
|
||||
_log.severe("Error saving video", error, stack);
|
||||
@@ -136,62 +131,6 @@ class DownloadService {
|
||||
Future<bool> cancelDownload(String id) async {
|
||||
return await FileDownloader().cancelTaskWithId(id);
|
||||
}
|
||||
|
||||
Future<List<bool>> downloadAll(List<Asset> assets) async {
|
||||
return await _downloadRepository.downloadAll(assets.expand(_createDownloadTasks).toList());
|
||||
}
|
||||
|
||||
Future<void> download(Asset asset) async {
|
||||
final tasks = _createDownloadTasks(asset);
|
||||
await _downloadRepository.downloadAll(tasks);
|
||||
}
|
||||
|
||||
List<DownloadTask> _createDownloadTasks(Asset asset) {
|
||||
if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) {
|
||||
return [
|
||||
_buildDownloadTask(
|
||||
asset.remoteId!,
|
||||
asset.fileName,
|
||||
group: kDownloadGroupLivePhoto,
|
||||
metadata: LivePhotosMetadata(part: LivePhotosPart.image, id: asset.remoteId!).toJson(),
|
||||
),
|
||||
_buildDownloadTask(
|
||||
asset.livePhotoVideoId!,
|
||||
asset.fileName.toUpperCase().replaceAll(RegExp(r"\.(JPG|HEIC)$"), '.MOV'),
|
||||
group: kDownloadGroupLivePhoto,
|
||||
metadata: LivePhotosMetadata(part: LivePhotosPart.video, id: asset.remoteId!).toJson(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (asset.remoteId == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
_buildDownloadTask(
|
||||
asset.remoteId!,
|
||||
asset.fileName,
|
||||
group: asset.isImage ? kDownloadGroupImage : kDownloadGroupVideo,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
DownloadTask _buildDownloadTask(String id, String filename, {String? group, String? metadata}) {
|
||||
final path = r'/assets/{id}/original'.replaceAll('{id}', id);
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
|
||||
return DownloadTask(
|
||||
taskId: id,
|
||||
url: serverEndpoint + path,
|
||||
headers: headers,
|
||||
filename: filename,
|
||||
updates: Updates.statusAndProgress,
|
||||
group: group ?? '',
|
||||
metaData: metadata ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TaskRecord _findTaskRecord(List<TaskRecord> records, String livePhotosId, LivePhotosPart part) {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
|
||||
class EntityService {
|
||||
final AssetRepository _assetRepository;
|
||||
final IsarUserRepository _isarUserRepository;
|
||||
const EntityService(this._assetRepository, this._isarUserRepository);
|
||||
|
||||
Future<Album> fillAlbumWithDatabaseEntities(Album album) async {
|
||||
final ownerId = album.ownerId;
|
||||
if (ownerId != null) {
|
||||
// replace owner with user from database
|
||||
final user = await _isarUserRepository.getByUserId(ownerId);
|
||||
album.owner.value = user == null ? null : User.fromDto(user);
|
||||
}
|
||||
final thumbnailAssetId = album.remoteThumbnailAssetId ?? album.thumbnail.value?.remoteId;
|
||||
if (thumbnailAssetId != null) {
|
||||
// set thumbnail with asset from database
|
||||
album.thumbnail.value = await _assetRepository.getByRemoteId(thumbnailAssetId);
|
||||
}
|
||||
if (album.remoteUsers.isNotEmpty) {
|
||||
// replace all users with users from database
|
||||
final users = await _isarUserRepository.getByUserIds(album.remoteUsers.map((user) => user.id).toList());
|
||||
album.sharedUsers.clear();
|
||||
album.sharedUsers.addAll(users.nonNulls.map(User.fromDto));
|
||||
album.shared = true;
|
||||
}
|
||||
if (album.remoteAssets.isNotEmpty) {
|
||||
// replace all assets with assets from database
|
||||
final assets = await _assetRepository.getAllByRemoteId(album.remoteAssets.map((asset) => asset.remoteId!));
|
||||
album.assets.clear();
|
||||
album.assets.addAll(assets);
|
||||
}
|
||||
return album;
|
||||
}
|
||||
}
|
||||
|
||||
final entityServiceProvider = Provider(
|
||||
(ref) => EntityService(ref.watch(assetRepositoryProvider), ref.watch(userRepositoryProvider)),
|
||||
);
|
||||
@@ -1,14 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/repositories/etag.repository.dart';
|
||||
|
||||
final etagServiceProvider = Provider((ref) => ETagService(ref.watch(etagRepositoryProvider)));
|
||||
|
||||
class ETagService {
|
||||
final ETagRepository _eTagRepository;
|
||||
|
||||
const ETagService(this._eTagRepository);
|
||||
|
||||
Future<void> clearTable() {
|
||||
return _eTagRepository.clearTable();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
|
||||
|
||||
final exifServiceProvider = Provider((ref) => ExifService(ref.watch(exifRepositoryProvider)));
|
||||
|
||||
class ExifService {
|
||||
final IsarExifRepository _exifInfoRepository;
|
||||
|
||||
const ExifService(this._exifInfoRepository);
|
||||
|
||||
Future<void> clearTable() {
|
||||
return _exifInfoRepository.deleteAll();
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/device_asset.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/device_asset.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/device_asset.provider.dart';
|
||||
import 'package:immich_mobile/services/background.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class HashService {
|
||||
HashService({
|
||||
required IsarDeviceAssetRepository deviceAssetRepository,
|
||||
required BackgroundService backgroundService,
|
||||
this.batchSizeLimit = kBatchHashSizeLimit,
|
||||
int? batchFileLimit,
|
||||
}) : _deviceAssetRepository = deviceAssetRepository,
|
||||
_backgroundService = backgroundService,
|
||||
batchFileLimit = batchFileLimit ?? kBatchHashFileLimit;
|
||||
|
||||
final IsarDeviceAssetRepository _deviceAssetRepository;
|
||||
final BackgroundService _backgroundService;
|
||||
final int batchSizeLimit;
|
||||
final int batchFileLimit;
|
||||
final _log = Logger('HashService');
|
||||
|
||||
/// Processes a list of local [Asset]s, storing their hash and returning only those
|
||||
/// that were successfully hashed. Hashes are looked up in a DB table
|
||||
/// [DeviceAsset] by local id. Only missing entries are newly hashed and added to the DB table.
|
||||
Future<List<Asset>> hashAssets(List<Asset> assets) async {
|
||||
assets.sort(Asset.compareByLocalId);
|
||||
|
||||
// Get and sort DB entries - guaranteed to be a subset of assets
|
||||
final hashesInDB = await _deviceAssetRepository.getByIds(assets.map((a) => a.localId!).toList());
|
||||
hashesInDB.sort((a, b) => a.assetId.compareTo(b.assetId));
|
||||
|
||||
int dbIndex = 0;
|
||||
int bytesProcessed = 0;
|
||||
final hashedAssets = <Asset>[];
|
||||
final toBeHashed = <_AssetPath>[];
|
||||
final toBeDeleted = <String>[];
|
||||
|
||||
for (int assetIndex = 0; assetIndex < assets.length; assetIndex++) {
|
||||
final asset = assets[assetIndex];
|
||||
DeviceAsset? matchingDbEntry;
|
||||
|
||||
if (dbIndex < hashesInDB.length) {
|
||||
final deviceAsset = hashesInDB[dbIndex];
|
||||
if (deviceAsset.assetId == asset.localId) {
|
||||
matchingDbEntry = deviceAsset;
|
||||
dbIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingDbEntry != null &&
|
||||
matchingDbEntry.hash.isNotEmpty &&
|
||||
matchingDbEntry.modifiedTime.isAtSameMomentAs(asset.fileModifiedAt)) {
|
||||
// Reuse the existing hash
|
||||
hashedAssets.add(asset.copyWith(checksum: base64.encode(matchingDbEntry.hash)));
|
||||
continue;
|
||||
}
|
||||
|
||||
final file = await _tryGetAssetFile(asset);
|
||||
if (file == null) {
|
||||
// Can't access file, delete any DB entry
|
||||
if (matchingDbEntry != null) {
|
||||
toBeDeleted.add(matchingDbEntry.assetId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
bytesProcessed += await file.length();
|
||||
toBeHashed.add(_AssetPath(asset: asset, path: file.path));
|
||||
|
||||
if (_shouldProcessBatch(toBeHashed.length, bytesProcessed)) {
|
||||
hashedAssets.addAll(await _processBatch(toBeHashed, toBeDeleted));
|
||||
toBeHashed.clear();
|
||||
toBeDeleted.clear();
|
||||
bytesProcessed = 0;
|
||||
}
|
||||
}
|
||||
assert(dbIndex == hashesInDB.length, "All hashes should've been processed");
|
||||
|
||||
// Process any remaining files
|
||||
if (toBeHashed.isNotEmpty) {
|
||||
hashedAssets.addAll(await _processBatch(toBeHashed, toBeDeleted));
|
||||
}
|
||||
|
||||
// Clean up deleted references
|
||||
if (toBeDeleted.isNotEmpty) {
|
||||
await _deviceAssetRepository.deleteIds(toBeDeleted);
|
||||
}
|
||||
|
||||
return hashedAssets;
|
||||
}
|
||||
|
||||
bool _shouldProcessBatch(int assetCount, int bytesProcessed) =>
|
||||
assetCount >= batchFileLimit || bytesProcessed >= batchSizeLimit;
|
||||
|
||||
Future<File?> _tryGetAssetFile(Asset asset) async {
|
||||
try {
|
||||
final file = await asset.local!.originFile;
|
||||
if (file == null) {
|
||||
_log.warning(
|
||||
"Failed to get file for asset ${asset.localId ?? '<N/A>'}, name: ${asset.fileName}, created on: ${asset.fileCreatedAt}, skipping",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
} catch (error, stackTrace) {
|
||||
_log.warning(
|
||||
"Error getting file to hash for asset ${asset.localId ?? '<N/A>'}, name: ${asset.fileName}, created on: ${asset.fileCreatedAt}, skipping",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes a batch of files and returns a list of successfully hashed assets after saving
|
||||
/// them in [DeviceAssetToHash] for future retrieval
|
||||
Future<List<Asset>> _processBatch(List<_AssetPath> toBeHashed, List<String> toBeDeleted) async {
|
||||
_log.info("Hashing ${toBeHashed.length} files");
|
||||
final hashes = await _hashFiles(toBeHashed.map((e) => e.path).toList());
|
||||
assert(
|
||||
hashes.length == toBeHashed.length,
|
||||
"Number of Hashes returned from platform should be the same as the input",
|
||||
);
|
||||
|
||||
final hashedAssets = <Asset>[];
|
||||
final toBeAdded = <DeviceAsset>[];
|
||||
|
||||
for (final (index, hash) in hashes.indexed) {
|
||||
final asset = toBeHashed.elementAtOrNull(index)?.asset;
|
||||
if (asset != null && hash?.length == 20) {
|
||||
hashedAssets.add(asset.copyWith(checksum: base64.encode(hash!)));
|
||||
toBeAdded.add(DeviceAsset(assetId: asset.localId!, hash: hash, modifiedTime: asset.fileModifiedAt));
|
||||
} else {
|
||||
_log.warning("Failed to hash file ${asset?.localId ?? '<null>'}");
|
||||
if (asset != null) {
|
||||
toBeDeleted.add(asset.localId!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the DB for future retrieval
|
||||
await _deviceAssetRepository.transaction(() async {
|
||||
await _deviceAssetRepository.updateAll(toBeAdded);
|
||||
await _deviceAssetRepository.deleteIds(toBeDeleted);
|
||||
});
|
||||
|
||||
_log.fine("Hashed ${hashedAssets.length}/${toBeHashed.length} assets");
|
||||
return hashedAssets;
|
||||
}
|
||||
|
||||
/// Hashes the given files and returns a list of the same length.
|
||||
/// Files that could not be hashed will have a `null` value
|
||||
Future<List<Uint8List?>> _hashFiles(List<String> paths) async {
|
||||
try {
|
||||
final hashes = await _backgroundService.digestFiles(paths);
|
||||
if (hashes != null) {
|
||||
return hashes;
|
||||
}
|
||||
_log.severe("Hashing ${paths.length} files failed");
|
||||
} catch (e, s) {
|
||||
_log.severe("Error occurred while hashing assets", e, s);
|
||||
}
|
||||
return List.filled(paths.length, null);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetPath {
|
||||
final Asset asset;
|
||||
final String path;
|
||||
|
||||
const _AssetPath({required this.asset, required this.path});
|
||||
|
||||
_AssetPath copyWith({Asset? asset, String? path}) {
|
||||
return _AssetPath(asset: asset ?? this.asset, path: path ?? this.path);
|
||||
}
|
||||
}
|
||||
|
||||
final hashServiceProvider = Provider(
|
||||
(ref) => HashService(
|
||||
deviceAssetRepository: ref.watch(deviceAssetRepositoryProvider),
|
||||
backgroundService: ref.watch(backgroundServiceProvider),
|
||||
),
|
||||
);
|
||||
@@ -1,118 +0,0 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
final localNotificationService = Provider(
|
||||
(ref) => LocalNotificationService(ref.watch(notificationPermissionProvider), ref),
|
||||
);
|
||||
|
||||
class LocalNotificationService {
|
||||
final FlutterLocalNotificationsPlugin _localNotificationPlugin = FlutterLocalNotificationsPlugin();
|
||||
final PermissionStatus _permissionStatus;
|
||||
final Ref ref;
|
||||
|
||||
LocalNotificationService(this._permissionStatus, this.ref);
|
||||
|
||||
static const manualUploadNotificationID = 4;
|
||||
static const manualUploadDetailedNotificationID = 5;
|
||||
static const manualUploadChannelName = 'Manual Asset Upload';
|
||||
static const manualUploadChannelID = 'immich/manualUpload';
|
||||
static const manualUploadChannelNameDetailed = 'Manual Asset Upload Detailed';
|
||||
static const manualUploadDetailedChannelID = 'immich/manualUploadDetailed';
|
||||
static const cancelUploadActionID = 'cancel_upload';
|
||||
|
||||
Future<void> setup() async {
|
||||
const androidSetting = AndroidInitializationSettings('@drawable/notification_icon');
|
||||
const iosSetting = DarwinInitializationSettings();
|
||||
|
||||
const initSettings = InitializationSettings(android: androidSetting, iOS: iosSetting);
|
||||
|
||||
await _localNotificationPlugin.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onDidReceiveForegroundNotificationResponse,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showOrUpdateNotification(
|
||||
int id,
|
||||
String title,
|
||||
String body,
|
||||
AndroidNotificationDetails androidNotificationDetails,
|
||||
DarwinNotificationDetails iosNotificationDetails,
|
||||
) async {
|
||||
final notificationDetails = NotificationDetails(android: androidNotificationDetails, iOS: iosNotificationDetails);
|
||||
|
||||
if (_permissionStatus == PermissionStatus.granted) {
|
||||
await _localNotificationPlugin.show(id, title, body, notificationDetails);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> closeNotification(int id) {
|
||||
return _localNotificationPlugin.cancel(id);
|
||||
}
|
||||
|
||||
Future<void> showOrUpdateManualUploadStatus(
|
||||
String title,
|
||||
String body, {
|
||||
bool? isDetailed,
|
||||
bool? presentBanner,
|
||||
bool? showActions,
|
||||
int? maxProgress,
|
||||
int? progress,
|
||||
}) {
|
||||
var notificationlId = manualUploadNotificationID;
|
||||
var androidChannelID = manualUploadChannelID;
|
||||
var androidChannelName = manualUploadChannelName;
|
||||
// Separate Notification for Info/Alerts and Progress
|
||||
if (isDetailed != null && isDetailed) {
|
||||
notificationlId = manualUploadDetailedNotificationID;
|
||||
androidChannelID = manualUploadDetailedChannelID;
|
||||
androidChannelName = manualUploadChannelNameDetailed;
|
||||
}
|
||||
// Progress notification
|
||||
final androidNotificationDetails = (maxProgress != null && progress != null)
|
||||
? AndroidNotificationDetails(
|
||||
androidChannelID,
|
||||
androidChannelName,
|
||||
ticker: title,
|
||||
showProgress: true,
|
||||
onlyAlertOnce: true,
|
||||
maxProgress: maxProgress,
|
||||
progress: progress,
|
||||
indeterminate: false,
|
||||
playSound: false,
|
||||
priority: Priority.low,
|
||||
importance: Importance.low,
|
||||
ongoing: true,
|
||||
actions: (showActions ?? false)
|
||||
? <AndroidNotificationAction>[
|
||||
const AndroidNotificationAction(cancelUploadActionID, 'Cancel', showsUserInterface: true),
|
||||
]
|
||||
: null,
|
||||
)
|
||||
// Non-progress notification
|
||||
: AndroidNotificationDetails(androidChannelID, androidChannelName, playSound: false);
|
||||
|
||||
final iosNotificationDetails = DarwinNotificationDetails(
|
||||
presentBadge: true,
|
||||
presentList: true,
|
||||
presentBanner: presentBanner,
|
||||
);
|
||||
|
||||
return _showOrUpdateNotification(notificationlId, title, body, androidNotificationDetails, iosNotificationDetails);
|
||||
}
|
||||
|
||||
void _onDidReceiveForegroundNotificationResponse(NotificationResponse notificationResponse) {
|
||||
// Handle notification actions
|
||||
switch (notificationResponse.actionId) {
|
||||
case cancelUploadActionID:
|
||||
{
|
||||
dPrint(() => "User cancelled manual upload operation");
|
||||
ref.read(manualUploadProvider.notifier).cancelBackup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/memories/memory.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final memoryServiceProvider = StateProvider<MemoryService>((ref) {
|
||||
return MemoryService(ref.watch(apiServiceProvider), ref.watch(assetRepositoryProvider));
|
||||
});
|
||||
|
||||
class MemoryService {
|
||||
final log = Logger("MemoryService");
|
||||
|
||||
final ApiService _apiService;
|
||||
final AssetRepository _assetRepository;
|
||||
|
||||
MemoryService(this._apiService, this._assetRepository);
|
||||
|
||||
Future<List<Memory>?> getMemoryLane() async {
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final data = await _apiService.memoriesApi.searchMemories(
|
||||
for_: DateTime.utc(now.year, now.month, now.day, 0, 0, 0),
|
||||
);
|
||||
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Memory> memories = [];
|
||||
|
||||
for (final memory in data) {
|
||||
final dbAssets = await _assetRepository.getAllByRemoteId(memory.assets.map((e) => e.id));
|
||||
final yearsAgo = now.year - memory.data.year;
|
||||
if (dbAssets.isNotEmpty) {
|
||||
final String title = 'years_ago'.t(args: {'years': yearsAgo.toString()});
|
||||
memories.add(Memory(title: title, assets: dbAssets));
|
||||
}
|
||||
}
|
||||
|
||||
return memories.isNotEmpty ? memories : null;
|
||||
} catch (error, stack) {
|
||||
log.severe("Cannot get memories", error, stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Memory?> getMemoryById(String id) async {
|
||||
try {
|
||||
final memoryResponse = await _apiService.memoriesApi.getMemory(id);
|
||||
|
||||
if (memoryResponse == null) {
|
||||
return null;
|
||||
}
|
||||
final dbAssets = await _assetRepository.getAllByRemoteId(memoryResponse.assets.map((e) => e.id));
|
||||
if (dbAssets.isEmpty) {
|
||||
log.warning("No assets found for memory with ID: $id");
|
||||
return null;
|
||||
}
|
||||
final yearsAgo = DateTime.now().year - memoryResponse.data.year;
|
||||
final String title = 'years_ago'.t(args: {'years': yearsAgo.toString()});
|
||||
|
||||
return Memory(title: title, assets: dbAssets);
|
||||
} catch (error, stack) {
|
||||
log.severe("Cannot get memory with ID: $id", error, stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/partner.repository.dart';
|
||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final partnerServiceProvider = Provider(
|
||||
(ref) => PartnerService(
|
||||
ref.watch(partnerApiRepositoryProvider),
|
||||
ref.watch(userRepositoryProvider),
|
||||
ref.watch(partnerRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class PartnerService {
|
||||
final PartnerApiRepository _partnerApiRepository;
|
||||
final PartnerRepository _partnerRepository;
|
||||
final IsarUserRepository _isarUserRepository;
|
||||
final Logger _log = Logger("PartnerService");
|
||||
|
||||
PartnerService(this._partnerApiRepository, this._isarUserRepository, this._partnerRepository);
|
||||
|
||||
Future<List<UserDto>> getSharedWith() async {
|
||||
return _partnerRepository.getSharedWith();
|
||||
}
|
||||
|
||||
Future<List<UserDto>> getSharedBy() async {
|
||||
return _partnerRepository.getSharedBy();
|
||||
}
|
||||
|
||||
Stream<List<UserDto>> watchSharedWith() {
|
||||
return _partnerRepository.watchSharedWith();
|
||||
}
|
||||
|
||||
Stream<List<UserDto>> watchSharedBy() {
|
||||
return _partnerRepository.watchSharedBy();
|
||||
}
|
||||
|
||||
Future<bool> removePartner(UserDto partner) async {
|
||||
try {
|
||||
await _partnerApiRepository.delete(partner.id);
|
||||
await _isarUserRepository.update(partner.copyWith(isPartnerSharedBy: false));
|
||||
} catch (e) {
|
||||
_log.warning("Failed to remove partner ${partner.id}", e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> addPartner(UserDto partner) async {
|
||||
try {
|
||||
await _partnerApiRepository.create(partner.id);
|
||||
await _isarUserRepository.update(partner.copyWith(isPartnerSharedBy: true));
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.warning("Failed to add partner ${partner.id}", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> updatePartner(UserDto partner, {required bool inTimeline}) async {
|
||||
try {
|
||||
final dto = await _partnerApiRepository.update(partner.id, inTimeline: inTimeline);
|
||||
await _isarUserRepository.update(partner.copyWith(inTimeline: dto.inTimeline));
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.warning("Failed to update partner ${partner.id}", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/person_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
@@ -10,19 +7,12 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
part 'person.service.g.dart';
|
||||
|
||||
@riverpod
|
||||
PersonService personService(Ref ref) => PersonService(
|
||||
ref.watch(personApiRepositoryProvider),
|
||||
ref.watch(assetApiRepositoryProvider),
|
||||
ref.read(assetRepositoryProvider),
|
||||
);
|
||||
PersonService personService(Ref ref) => PersonService(ref.watch(personApiRepositoryProvider));
|
||||
|
||||
class PersonService {
|
||||
final Logger _log = Logger("PersonService");
|
||||
final PersonApiRepository _personApiRepository;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final AssetRepository _assetRepository;
|
||||
|
||||
PersonService(this._personApiRepository, this._assetApiRepository, this._assetRepository);
|
||||
PersonService(this._personApiRepository);
|
||||
|
||||
Future<List<PersonDto>> getAllPeople() async {
|
||||
try {
|
||||
@@ -33,16 +23,6 @@ class PersonService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Asset>> getPersonAssets(String id) async {
|
||||
try {
|
||||
final assets = await _assetApiRepository.search(personIds: [id]);
|
||||
return await _assetRepository.getAllByRemoteId(assets.map((a) => a.remoteId!));
|
||||
} catch (error, stack) {
|
||||
_log.severe("Error while fetching person assets", error, stack);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<PersonDto?> updateName(String id, String name) async {
|
||||
try {
|
||||
return await _personApiRepository.update(id, name: name);
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ part of 'person.service.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$personServiceHash() => r'10883bccc6c402205e6785cf9ee6cd7142cd0983';
|
||||
String _$personServiceHash() => r'646e38d764c52e63d9fca86992e440f34196d519';
|
||||
|
||||
/// See also [personService].
|
||||
@ProviderFor(personService)
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/models/search/search_result.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
final searchServiceProvider = Provider(
|
||||
(ref) => SearchService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(assetRepositoryProvider),
|
||||
ref.watch(searchApiRepositoryProvider),
|
||||
),
|
||||
(ref) => SearchService(ref.watch(apiServiceProvider), ref.watch(searchApiRepositoryProvider)),
|
||||
);
|
||||
|
||||
class SearchService {
|
||||
final ApiService _apiService;
|
||||
final AssetRepository _assetRepository;
|
||||
final SearchApiRepository _searchApiRepository;
|
||||
|
||||
final _log = Logger("SearchService");
|
||||
SearchService(this._apiService, this._assetRepository, this._searchApiRepository);
|
||||
SearchService(this._apiService, this._searchApiRepository);
|
||||
|
||||
Future<List<String>?> getSearchSuggestions(
|
||||
SearchSuggestionType type, {
|
||||
@@ -48,24 +39,6 @@ class SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<SearchResult?> search(SearchFilter filter, int page) async {
|
||||
try {
|
||||
final response = await _searchApiRepository.search(filter, page);
|
||||
|
||||
if (response == null || response.assets.items.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SearchResult(
|
||||
assets: await _assetRepository.getAllByRemoteId(response.assets.items.map((e) => e.id)),
|
||||
nextPage: response.assets.nextPage?.toInt(),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
_log.severe("Failed to search for assets", error, stackTrace);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<SearchExploreResponseDto>?> getExploreData() async {
|
||||
try {
|
||||
return await _apiService.searchApi.getExploreData();
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
final shareServiceProvider = Provider((ref) => ShareService(ref.watch(apiServiceProvider)));
|
||||
|
||||
class ShareService {
|
||||
final ApiService _apiService;
|
||||
final Logger _log = Logger("ShareService");
|
||||
|
||||
ShareService(this._apiService);
|
||||
|
||||
Future<bool> shareAsset(Asset asset, BuildContext context) async {
|
||||
return await shareAssets([asset], context);
|
||||
}
|
||||
|
||||
Future<bool> shareAssets(List<Asset> assets, BuildContext context) async {
|
||||
try {
|
||||
final downloadedXFiles = <XFile>[];
|
||||
|
||||
for (var asset in assets) {
|
||||
if (asset.isLocal) {
|
||||
// Prefer local assets to share
|
||||
File? f = await asset.local!.originFile;
|
||||
downloadedXFiles.add(XFile(f!.path));
|
||||
} else if (asset.isRemote) {
|
||||
// Download remote asset otherwise
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final fileName = asset.fileName;
|
||||
final tempFile = await File('${tempDir.path}/$fileName').create();
|
||||
final res = await _apiService.assetsApi.downloadAssetWithHttpInfo(asset.remoteId!);
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
_log.severe("Asset download for ${asset.fileName} failed", res.toLoggerString());
|
||||
continue;
|
||||
}
|
||||
|
||||
tempFile.writeAsBytesSync(res.bodyBytes);
|
||||
downloadedXFiles.add(XFile(tempFile.path));
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadedXFiles.isEmpty) {
|
||||
_log.warning("No asset can be retrieved for share");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (downloadedXFiles.length != assets.length) {
|
||||
_log.warning("Partial share - Requested: ${assets.length}, Sharing: ${downloadedXFiles.length}");
|
||||
}
|
||||
|
||||
final size = MediaQuery.of(context).size;
|
||||
unawaited(
|
||||
Share.shareXFiles(
|
||||
downloadedXFiles,
|
||||
sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
_log.severe("Share failed", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
class StackService {
|
||||
const StackService(this._api, this._assetRepository);
|
||||
|
||||
final ApiService _api;
|
||||
final AssetRepository _assetRepository;
|
||||
|
||||
Future<StackResponseDto?> getStack(String stackId) async {
|
||||
try {
|
||||
return _api.stacksApi.getStack(stackId);
|
||||
} catch (error) {
|
||||
dPrint(() => "Error while fetching stack: $error");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<StackResponseDto?> createStack(List<String> assetIds) async {
|
||||
try {
|
||||
return _api.stacksApi.createStack(StackCreateDto(assetIds: assetIds));
|
||||
} catch (error) {
|
||||
dPrint(() => "Error while creating stack: $error");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<StackResponseDto?> updateStack(String stackId, String primaryAssetId) async {
|
||||
try {
|
||||
return await _api.stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: primaryAssetId));
|
||||
} catch (error) {
|
||||
dPrint(() => "Error while updating stack children: $error");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> deleteStack(String stackId, List<Asset> assets) async {
|
||||
try {
|
||||
await _api.stacksApi.deleteStack(stackId);
|
||||
|
||||
// Update local database to trigger rerendering
|
||||
final List<Asset> removeAssets = [];
|
||||
for (final asset in assets) {
|
||||
asset.stackId = null;
|
||||
asset.stackPrimaryAssetId = null;
|
||||
asset.stackCount = 0;
|
||||
|
||||
removeAssets.add(asset);
|
||||
}
|
||||
await _assetRepository.transaction(() => _assetRepository.updateAll(removeAssets));
|
||||
} catch (error) {
|
||||
dPrint(() => "Error while deleting stack: $error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final stackServiceProvider = Provider(
|
||||
(ref) => StackService(ref.watch(apiServiceProvider), ref.watch(assetRepositoryProvider)),
|
||||
);
|
||||
@@ -1,945 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/etag.entity.dart';
|
||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/album.repository.dart';
|
||||
import 'package:immich_mobile/repositories/album_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/etag.repository.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/partner.repository.dart';
|
||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/entity.service.dart';
|
||||
import 'package:immich_mobile/services/hash.service.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:immich_mobile/utils/datetime_comparison.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final syncServiceProvider = Provider(
|
||||
(ref) => SyncService(
|
||||
ref.watch(hashServiceProvider),
|
||||
ref.watch(entityServiceProvider),
|
||||
ref.watch(albumMediaRepositoryProvider),
|
||||
ref.watch(albumApiRepositoryProvider),
|
||||
ref.watch(albumRepositoryProvider),
|
||||
ref.watch(assetRepositoryProvider),
|
||||
ref.watch(exifRepositoryProvider),
|
||||
ref.watch(partnerRepositoryProvider),
|
||||
ref.watch(userRepositoryProvider),
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(etagRepositoryProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(localFilesManagerRepositoryProvider),
|
||||
ref.watch(partnerApiRepositoryProvider),
|
||||
ref.watch(userApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class SyncService {
|
||||
final HashService _hashService;
|
||||
final EntityService _entityService;
|
||||
final AlbumMediaRepository _albumMediaRepository;
|
||||
final AlbumApiRepository _albumApiRepository;
|
||||
final AlbumRepository _albumRepository;
|
||||
final AssetRepository _assetRepository;
|
||||
final IsarExifRepository _exifInfoRepository;
|
||||
final IsarUserRepository _isarUserRepository;
|
||||
final UserService _userService;
|
||||
final PartnerRepository _partnerRepository;
|
||||
final ETagRepository _eTagRepository;
|
||||
final PartnerApiRepository _partnerApiRepository;
|
||||
final UserApiRepository _userApiRepository;
|
||||
final AsyncMutex _lock = AsyncMutex();
|
||||
final Logger _log = Logger('SyncService');
|
||||
final AppSettingsService _appSettingsService;
|
||||
final LocalFilesManagerRepository _localFilesManager;
|
||||
|
||||
SyncService(
|
||||
this._hashService,
|
||||
this._entityService,
|
||||
this._albumMediaRepository,
|
||||
this._albumApiRepository,
|
||||
this._albumRepository,
|
||||
this._assetRepository,
|
||||
this._exifInfoRepository,
|
||||
this._partnerRepository,
|
||||
this._isarUserRepository,
|
||||
this._userService,
|
||||
this._eTagRepository,
|
||||
this._appSettingsService,
|
||||
this._localFilesManager,
|
||||
this._partnerApiRepository,
|
||||
this._userApiRepository,
|
||||
);
|
||||
|
||||
// public methods:
|
||||
|
||||
/// Syncs users from the server to the local database
|
||||
/// Returns `true`if there were any changes
|
||||
Future<bool> syncUsersFromServer(List<UserDto> users) => _lock.run(() => _syncUsersFromServer(users));
|
||||
|
||||
/// Syncs remote assets owned by the logged-in user to the DB
|
||||
/// Returns `true` if there were any changes
|
||||
Future<bool> syncRemoteAssetsToDb({
|
||||
required List<UserDto> users,
|
||||
required Future<(List<Asset>? toUpsert, List<String>? toDelete)> Function(List<UserDto> users, DateTime since)
|
||||
getChangedAssets,
|
||||
required FutureOr<List<Asset>?> Function(UserDto user, DateTime until) loadAssets,
|
||||
}) => _lock.run(
|
||||
() async =>
|
||||
await _syncRemoteAssetChanges(users, getChangedAssets) ??
|
||||
await _syncRemoteAssetsFull(getUsersFromServer, loadAssets),
|
||||
);
|
||||
|
||||
/// Syncs remote albums to the database
|
||||
/// returns `true` if there were any changes
|
||||
Future<bool> syncRemoteAlbumsToDb(List<Album> remote) => _lock.run(() => _syncRemoteAlbumsToDb(remote));
|
||||
|
||||
/// Syncs all device albums and their assets to the database
|
||||
/// Returns `true` if there were any changes
|
||||
Future<bool> syncLocalAlbumAssetsToDb(List<Album> onDevice, [Set<String>? excludedAssets]) =>
|
||||
_lock.run(() => _syncLocalAlbumAssetsToDb(onDevice, excludedAssets));
|
||||
|
||||
/// returns all Asset IDs that are not contained in the existing list
|
||||
List<int> sharedAssetsToRemove(List<Asset> deleteCandidates, List<Asset> existing) {
|
||||
if (deleteCandidates.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
deleteCandidates.sort(Asset.compareById);
|
||||
existing.sort(Asset.compareById);
|
||||
return _diffAssets(existing, deleteCandidates, compare: Asset.compareById).$3.map((e) => e.id).toList();
|
||||
}
|
||||
|
||||
/// Syncs a new asset to the db. Returns `true` if successful
|
||||
Future<bool> syncNewAssetToDb(Asset newAsset) => _lock.run(() => _syncNewAssetToDb(newAsset));
|
||||
|
||||
Future<bool> removeAllLocalAlbumsAndAssets() => _lock.run(_removeAllLocalAlbumsAndAssets);
|
||||
|
||||
// private methods:
|
||||
|
||||
/// Syncs users from the server to the local database
|
||||
/// Returns `true`if there were any changes
|
||||
Future<bool> _syncUsersFromServer(List<UserDto> users) async {
|
||||
users.sortBy((u) => u.id);
|
||||
final dbUsers = await _isarUserRepository.getAll(sortBy: SortUserBy.id);
|
||||
final List<String> toDelete = [];
|
||||
final List<UserDto> toUpsert = [];
|
||||
final changes = diffSortedListsSync(
|
||||
users,
|
||||
dbUsers,
|
||||
compare: (UserDto a, UserDto b) => a.id.compareTo(b.id),
|
||||
both: (UserDto a, UserDto b) {
|
||||
if ((a.updatedAt == null && b.updatedAt != null) ||
|
||||
(a.updatedAt != null && b.updatedAt == null) ||
|
||||
(a.updatedAt != null && b.updatedAt != null && !a.updatedAt!.isAtSameMomentAs(b.updatedAt!)) ||
|
||||
a.isPartnerSharedBy != b.isPartnerSharedBy ||
|
||||
a.isPartnerSharedWith != b.isPartnerSharedWith ||
|
||||
a.inTimeline != b.inTimeline) {
|
||||
toUpsert.add(a);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onlyFirst: (UserDto a) => toUpsert.add(a),
|
||||
onlySecond: (UserDto b) => toDelete.add(b.id),
|
||||
);
|
||||
if (changes) {
|
||||
await _isarUserRepository.transaction(() async {
|
||||
await _isarUserRepository.delete(toDelete);
|
||||
await _isarUserRepository.updateAll(toUpsert);
|
||||
});
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// Syncs a new asset to the db. Returns `true` if successful
|
||||
Future<bool> _syncNewAssetToDb(Asset a) async {
|
||||
final Asset? inDb = await _assetRepository.getByOwnerIdChecksum(a.ownerId, a.checksum);
|
||||
if (inDb != null) {
|
||||
// unify local/remote assets by replacing the
|
||||
// local-only asset in the DB with a local&remote asset
|
||||
a = inDb.updatedCopy(a);
|
||||
}
|
||||
try {
|
||||
await _assetRepository.update(a);
|
||||
} catch (e) {
|
||||
_log.severe("Failed to put new asset into db", e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Efficiently syncs assets via changes. Returns `null` when a full sync is required.
|
||||
Future<bool?> _syncRemoteAssetChanges(
|
||||
List<UserDto> users,
|
||||
Future<(List<Asset>? toUpsert, List<String>? toDelete)> Function(List<UserDto> users, DateTime since)
|
||||
getChangedAssets,
|
||||
) async {
|
||||
final currentUser = _userService.getMyUser();
|
||||
final DateTime? since = (await _eTagRepository.get(currentUser.id))?.time?.toUtc();
|
||||
if (since == null) return null;
|
||||
final DateTime now = DateTime.now();
|
||||
final (toUpsert, toDelete) = await getChangedAssets(users, since);
|
||||
if (toUpsert == null || toDelete == null) {
|
||||
await _clearUserAssetsETag(users);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (toDelete.isNotEmpty) {
|
||||
await handleRemoteAssetRemoval(toDelete);
|
||||
}
|
||||
if (toUpsert.isNotEmpty) {
|
||||
final (_, updated) = await _linkWithExistingFromDb(toUpsert);
|
||||
await upsertAssetsWithExif(updated);
|
||||
}
|
||||
if (toUpsert.isNotEmpty || toDelete.isNotEmpty) {
|
||||
await _updateUserAssetsETag(users, now);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
_log.severe("Failed to sync remote assets to db", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _moveToTrashMatchedAssets(Iterable<String> idsToDelete) async {
|
||||
final List<Asset> localAssets = await _assetRepository.getAllLocal();
|
||||
final List<Asset> matchedAssets = localAssets.where((asset) => idsToDelete.contains(asset.remoteId)).toList();
|
||||
|
||||
final mediaUrls = await Future.wait(matchedAssets.map((asset) => asset.local?.getMediaUrl() ?? Future.value(null)));
|
||||
|
||||
await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
}
|
||||
|
||||
/// Deletes remote-only assets, updates merged assets to be local-only
|
||||
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) async {
|
||||
return _assetRepository.transaction(() async {
|
||||
await _assetRepository.deleteAllByRemoteId(idsToDelete, state: AssetState.remote);
|
||||
final merged = await _assetRepository.getAllByRemoteId(idsToDelete, state: AssetState.merged);
|
||||
if (Platform.isAndroid && _appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) {
|
||||
await _moveToTrashMatchedAssets(idsToDelete);
|
||||
}
|
||||
if (merged.isEmpty) return;
|
||||
for (final Asset asset in merged) {
|
||||
asset.remoteId = null;
|
||||
asset.isTrashed = false;
|
||||
}
|
||||
await _assetRepository.updateAll(merged);
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<UserDto>> _getAllAccessibleUsers() async {
|
||||
final sharedWith = (await _partnerRepository.getSharedWith()).toSet();
|
||||
sharedWith.add(_userService.getMyUser());
|
||||
return sharedWith.toList();
|
||||
}
|
||||
|
||||
/// Syncs assets by loading and comparing all assets from the server.
|
||||
Future<bool> _syncRemoteAssetsFull(
|
||||
FutureOr<List<UserDto>?> Function() refreshUsers,
|
||||
FutureOr<List<Asset>?> Function(UserDto user, DateTime until) loadAssets,
|
||||
) async {
|
||||
final serverUsers = await refreshUsers();
|
||||
if (serverUsers == null) {
|
||||
_log.warning("_syncRemoteAssetsFull aborted because user refresh failed");
|
||||
return false;
|
||||
}
|
||||
await _syncUsersFromServer(serverUsers);
|
||||
final List<UserDto> users = await _getAllAccessibleUsers();
|
||||
bool changes = false;
|
||||
for (UserDto u in users) {
|
||||
changes |= await _syncRemoteAssetsForUser(u, loadAssets);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<bool> _syncRemoteAssetsForUser(
|
||||
UserDto user,
|
||||
FutureOr<List<Asset>?> Function(UserDto user, DateTime until) loadAssets,
|
||||
) async {
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
final List<Asset>? remote = await loadAssets(user, now);
|
||||
if (remote == null) {
|
||||
return false;
|
||||
}
|
||||
final List<Asset> inDb = await _assetRepository.getAll(ownerId: user.id, sortBy: AssetSort.checksum);
|
||||
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
|
||||
|
||||
remote.sort(Asset.compareByChecksum);
|
||||
|
||||
// filter our duplicates that might be introduced by the chunked retrieval
|
||||
remote.uniqueConsecutive(compare: Asset.compareByChecksum);
|
||||
|
||||
final (toAdd, toUpdate, toRemove) = _diffAssets(remote, inDb, remote: true);
|
||||
if (toAdd.isEmpty && toUpdate.isEmpty && toRemove.isEmpty) {
|
||||
await _updateUserAssetsETag([user], now);
|
||||
return false;
|
||||
}
|
||||
final idsToDelete = toRemove.map((e) => e.id).toList();
|
||||
try {
|
||||
await _assetRepository.deleteByIds(idsToDelete);
|
||||
await upsertAssetsWithExif(toAdd + toUpdate);
|
||||
} catch (e) {
|
||||
_log.severe("Failed to sync remote assets to db", e);
|
||||
}
|
||||
await _updateUserAssetsETag([user], now);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _updateUserAssetsETag(List<UserDto> users, DateTime time) {
|
||||
final etags = users.map((u) => ETag(id: u.id, time: time)).toList();
|
||||
return _eTagRepository.upsertAll(etags);
|
||||
}
|
||||
|
||||
Future<void> _clearUserAssetsETag(List<UserDto> users) {
|
||||
final ids = users.map((u) => u.id).toList();
|
||||
return _eTagRepository.deleteByIds(ids);
|
||||
}
|
||||
|
||||
/// Syncs remote albums to the database
|
||||
/// returns `true` if there were any changes
|
||||
Future<bool> _syncRemoteAlbumsToDb(List<Album> remoteAlbums) async {
|
||||
remoteAlbums.sortBy((e) => e.remoteId!);
|
||||
|
||||
final List<Album> dbAlbums = await _albumRepository.getAll(remote: true, sortBy: AlbumSort.remoteId);
|
||||
|
||||
final List<Asset> toDelete = [];
|
||||
final List<Asset> existing = [];
|
||||
|
||||
final bool changes = await diffSortedLists(
|
||||
remoteAlbums,
|
||||
dbAlbums,
|
||||
compare: (remoteAlbum, dbAlbum) => remoteAlbum.remoteId!.compareTo(dbAlbum.remoteId!),
|
||||
both: (remoteAlbum, dbAlbum) => _syncRemoteAlbum(remoteAlbum, dbAlbum, toDelete, existing),
|
||||
onlyFirst: (remoteAlbum) => _addAlbumFromServer(remoteAlbum, existing),
|
||||
onlySecond: (dbAlbum) => _removeAlbumFromDb(dbAlbum, toDelete),
|
||||
);
|
||||
|
||||
if (toDelete.isNotEmpty) {
|
||||
final List<int> idsToRemove = sharedAssetsToRemove(toDelete, existing);
|
||||
if (idsToRemove.isNotEmpty) {
|
||||
await _assetRepository.deleteByIds(idsToRemove);
|
||||
}
|
||||
} else {
|
||||
assert(toDelete.isEmpty);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// syncs albums from the server to the local database (does not support
|
||||
/// syncing changes from local back to server)
|
||||
/// accumulates
|
||||
Future<bool> _syncRemoteAlbum(Album dto, Album album, List<Asset> deleteCandidates, List<Asset> existing) async {
|
||||
if (!_hasRemoteAlbumChanged(dto, album)) {
|
||||
return false;
|
||||
}
|
||||
// loadDetails (/api/album/:id) will not include lastModifiedAssetTimestamp,
|
||||
// i.e. it will always be null. Save it here.
|
||||
final originalDto = dto;
|
||||
dto = await _albumApiRepository.get(dto.remoteId!);
|
||||
|
||||
final assetsInDb = await _assetRepository.getByAlbum(album, sortBy: AssetSort.ownerIdChecksum);
|
||||
assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!");
|
||||
final List<Asset> assetsOnRemote = dto.remoteAssets.toList();
|
||||
assetsOnRemote.sort(Asset.compareByOwnerChecksum);
|
||||
final (toAdd, toUpdate, toUnlink) = _diffAssets(assetsOnRemote, assetsInDb, compare: Asset.compareByOwnerChecksum);
|
||||
|
||||
// update shared users
|
||||
final List<UserDto> sharedUsers = album.sharedUsers.map((u) => u.toDto()).toList(growable: false);
|
||||
sharedUsers.sort((a, b) => a.id.compareTo(b.id));
|
||||
final List<UserDto> users = dto.remoteUsers.map((u) => u.toDto()).toList()..sort((a, b) => a.id.compareTo(b.id));
|
||||
final List<String> userIdsToAdd = [];
|
||||
final List<UserDto> usersToUnlink = [];
|
||||
diffSortedListsSync(
|
||||
users,
|
||||
sharedUsers,
|
||||
compare: (UserDto a, UserDto b) => a.id.compareTo(b.id),
|
||||
both: (a, b) => false,
|
||||
onlyFirst: (UserDto a) => userIdsToAdd.add(a.id),
|
||||
onlySecond: (UserDto a) => usersToUnlink.add(a),
|
||||
);
|
||||
|
||||
// for shared album: put missing album assets into local DB
|
||||
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
|
||||
await upsertAssetsWithExif(updated);
|
||||
final assetsToLink = existingInDb + updated;
|
||||
final usersToLink = await _isarUserRepository.getByUserIds(userIdsToAdd);
|
||||
|
||||
album.name = dto.name;
|
||||
album.description = dto.description;
|
||||
album.shared = dto.shared;
|
||||
album.createdAt = dto.createdAt;
|
||||
album.modifiedAt = dto.modifiedAt;
|
||||
album.startDate = dto.startDate;
|
||||
album.endDate = dto.endDate;
|
||||
album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp;
|
||||
album.shared = dto.shared;
|
||||
album.activityEnabled = dto.activityEnabled;
|
||||
album.sortOrder = dto.sortOrder;
|
||||
|
||||
final remoteThumbnailAssetId = dto.remoteThumbnailAssetId;
|
||||
if (remoteThumbnailAssetId != null && album.thumbnail.value?.remoteId != remoteThumbnailAssetId) {
|
||||
album.thumbnail.value = await _assetRepository.getByRemoteId(remoteThumbnailAssetId);
|
||||
}
|
||||
|
||||
// write & commit all changes to DB
|
||||
try {
|
||||
await _assetRepository.transaction(() async {
|
||||
await _assetRepository.updateAll(toUpdate);
|
||||
await _albumRepository.addUsers(album, usersToLink.nonNulls.toList());
|
||||
await _albumRepository.removeUsers(album, usersToUnlink);
|
||||
await _albumRepository.addAssets(album, assetsToLink);
|
||||
await _albumRepository.removeAssets(album, toUnlink);
|
||||
await _albumRepository.recalculateMetadata(album);
|
||||
await _albumRepository.update(album);
|
||||
});
|
||||
_log.info("Synced changes of remote album ${album.name} to DB");
|
||||
} catch (e) {
|
||||
_log.severe("Failed to sync remote album to database", e);
|
||||
}
|
||||
|
||||
if (album.shared || dto.shared) {
|
||||
final userId = (_userService.getMyUser()).id;
|
||||
final foreign = await _assetRepository.getByAlbum(album, notOwnedBy: [userId]);
|
||||
existing.addAll(foreign);
|
||||
|
||||
// delete assets in DB unless they belong to this user or part of some other shared album
|
||||
final isarUserId = fastHash(userId);
|
||||
deleteCandidates.addAll(toUnlink.where((a) => a.ownerId != isarUserId));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Adds a remote album to the database while making sure to add any foreign
|
||||
/// (shared) assets to the database beforehand
|
||||
/// accumulates assets already existing in the database
|
||||
Future<void> _addAlbumFromServer(Album album, List<Asset> existing) async {
|
||||
if (album.remoteAssetCount != album.remoteAssets.length) {
|
||||
album = await _albumApiRepository.get(album.remoteId!);
|
||||
}
|
||||
if (album.remoteAssetCount == album.remoteAssets.length) {
|
||||
// in case an album contains assets not yet present in local DB:
|
||||
// put missing album assets into local DB
|
||||
final (existingInDb, updated) = await _linkWithExistingFromDb(album.remoteAssets.toList());
|
||||
existing.addAll(existingInDb);
|
||||
await upsertAssetsWithExif(updated);
|
||||
|
||||
await _entityService.fillAlbumWithDatabaseEntities(album);
|
||||
await _albumRepository.create(album);
|
||||
} else {
|
||||
_log.warning(
|
||||
"Failed to add album from server: assetCount ${album.remoteAssetCount} != "
|
||||
"asset array length ${album.remoteAssets.length} for album ${album.name}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulates all suitable album assets to the `deleteCandidates` and
|
||||
/// removes the album from the database.
|
||||
Future<void> _removeAlbumFromDb(Album album, List<Asset> deleteCandidates) async {
|
||||
if (album.isLocal) {
|
||||
_log.info("Removing local album $album from DB");
|
||||
// delete assets in DB unless they are remote or part of some other album
|
||||
deleteCandidates.addAll(await _assetRepository.getByAlbum(album, state: AssetState.local));
|
||||
} else if (album.shared) {
|
||||
// delete assets in DB unless they belong to this user or are part of some other shared album or belong to a partner
|
||||
final userIds = (await _getAllAccessibleUsers()).map((user) => user.id);
|
||||
final orphanedAssets = await _assetRepository.getByAlbum(album, notOwnedBy: userIds);
|
||||
deleteCandidates.addAll(orphanedAssets);
|
||||
}
|
||||
try {
|
||||
await _albumRepository.delete(album.id);
|
||||
_log.info("Removed local album $album from DB");
|
||||
} catch (e) {
|
||||
_log.severe("Failed to remove local album $album from DB", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Syncs all device albums and their assets to the database
|
||||
/// Returns `true` if there were any changes
|
||||
Future<bool> _syncLocalAlbumAssetsToDb(List<Album> onDevice, [Set<String>? excludedAssets]) async {
|
||||
onDevice.sort((a, b) => a.localId!.compareTo(b.localId!));
|
||||
final inDb = await _albumRepository.getAll(remote: false, sortBy: AlbumSort.localId);
|
||||
final List<Asset> deleteCandidates = [];
|
||||
final List<Asset> existing = [];
|
||||
final bool anyChanges = await diffSortedLists(
|
||||
onDevice,
|
||||
inDb,
|
||||
compare: (Album a, Album b) => a.localId!.compareTo(b.localId!),
|
||||
both: (Album a, Album b) => _syncAlbumInDbAndOnDevice(a, b, deleteCandidates, existing, excludedAssets),
|
||||
onlyFirst: (Album a) => _addAlbumFromDevice(a, existing, excludedAssets),
|
||||
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
|
||||
);
|
||||
_log.fine("Syncing all local albums almost done. Collected ${deleteCandidates.length} asset candidates to delete");
|
||||
final (toDelete, toUpdate) = _handleAssetRemoval(deleteCandidates, existing, remote: false);
|
||||
_log.fine("${toDelete.length} assets to delete, ${toUpdate.length} to update");
|
||||
if (toDelete.isNotEmpty || toUpdate.isNotEmpty) {
|
||||
await _assetRepository.transaction(() async {
|
||||
await _assetRepository.deleteByIds(toDelete);
|
||||
await _assetRepository.updateAll(toUpdate);
|
||||
});
|
||||
_log.info("Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB");
|
||||
}
|
||||
return anyChanges;
|
||||
}
|
||||
|
||||
/// Syncs the device album to the album in the database
|
||||
/// returns `true` if there were any changes
|
||||
/// Accumulates asset candidates to delete and those already existing in DB
|
||||
Future<bool> _syncAlbumInDbAndOnDevice(
|
||||
Album deviceAlbum,
|
||||
Album dbAlbum,
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> existing, [
|
||||
Set<String>? excludedAssets,
|
||||
bool forceRefresh = false,
|
||||
]) async {
|
||||
_log.info("Syncing a local album to DB: ${deviceAlbum.name}");
|
||||
if (!forceRefresh && !await _hasAlbumChangeOnDevice(deviceAlbum, dbAlbum)) {
|
||||
_log.info("Local album ${deviceAlbum.name} has not changed. Skipping sync.");
|
||||
return false;
|
||||
}
|
||||
_log.info("Local album ${deviceAlbum.name} has changed. Syncing...");
|
||||
if (!forceRefresh && excludedAssets == null && await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) {
|
||||
_log.info("Fast synced local album ${deviceAlbum.name} to DB");
|
||||
return true;
|
||||
}
|
||||
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
|
||||
final inDb = await _assetRepository.getByAlbum(
|
||||
dbAlbum,
|
||||
ownerId: (_userService.getMyUser()).id,
|
||||
sortBy: AssetSort.checksum,
|
||||
);
|
||||
|
||||
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
|
||||
final int assetCountOnDevice = await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
|
||||
final List<Asset> onDevice = await _getHashedAssets(deviceAlbum, excludedAssets: excludedAssets);
|
||||
_removeDuplicates(onDevice);
|
||||
// _removeDuplicates sorts `onDevice` by checksum
|
||||
final (toAdd, toUpdate, toDelete) = _diffAssets(onDevice, inDb);
|
||||
if (toAdd.isEmpty &&
|
||||
toUpdate.isEmpty &&
|
||||
toDelete.isEmpty &&
|
||||
dbAlbum.name == deviceAlbum.name &&
|
||||
dbAlbum.description == deviceAlbum.description &&
|
||||
dbAlbum.modifiedAt.isAtSameMomentAs(deviceAlbum.modifiedAt)) {
|
||||
// changes only affeted excluded albums
|
||||
_log.info("Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.");
|
||||
if (assetCountOnDevice != (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount))?.assetCount) {
|
||||
await _eTagRepository.upsertAll([ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: assetCountOnDevice)]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
_log.info(
|
||||
"Syncing local album ${deviceAlbum.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
|
||||
);
|
||||
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
|
||||
_log.info(
|
||||
"Linking assets to add with existing from db. ${existingInDb.length} existing, ${updated.length} to update",
|
||||
);
|
||||
deleteCandidates.addAll(toDelete);
|
||||
existing.addAll(existingInDb);
|
||||
dbAlbum.name = deviceAlbum.name;
|
||||
dbAlbum.description = deviceAlbum.description;
|
||||
dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
|
||||
if (dbAlbum.thumbnail.value != null && toDelete.contains(dbAlbum.thumbnail.value)) {
|
||||
dbAlbum.thumbnail.value = null;
|
||||
}
|
||||
try {
|
||||
await _assetRepository.transaction(() async {
|
||||
await _assetRepository.updateAll(updated + toUpdate);
|
||||
await _albumRepository.addAssets(dbAlbum, existingInDb + updated);
|
||||
await _albumRepository.removeAssets(dbAlbum, toDelete);
|
||||
await _albumRepository.recalculateMetadata(dbAlbum);
|
||||
await _albumRepository.update(dbAlbum);
|
||||
await _eTagRepository.upsertAll([ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: assetCountOnDevice)]);
|
||||
});
|
||||
_log.info("Synced changes of local album ${deviceAlbum.name} to DB");
|
||||
} catch (e) {
|
||||
_log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// fast path for common case: only new assets were added to device album
|
||||
/// returns `true` if successful, else `false`
|
||||
Future<bool> _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async {
|
||||
if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) {
|
||||
_log.info("Local album ${deviceAlbum.name} has not changed. Skipping sync.");
|
||||
return false;
|
||||
}
|
||||
final int totalOnDevice = await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
|
||||
final int lastKnownTotal = (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount))?.assetCount ?? 0;
|
||||
if (totalOnDevice <= lastKnownTotal) {
|
||||
_log.info("Local album ${deviceAlbum.name} totalOnDevice is less than lastKnownTotal. Skipping sync.");
|
||||
return false;
|
||||
}
|
||||
final List<Asset> newAssets = await _getHashedAssets(
|
||||
deviceAlbum,
|
||||
modifiedFrom: dbAlbum.modifiedAt.add(const Duration(seconds: 1)),
|
||||
modifiedUntil: deviceAlbum.modifiedAt,
|
||||
);
|
||||
|
||||
if (totalOnDevice != lastKnownTotal + newAssets.length) {
|
||||
_log.info(
|
||||
"Local album ${deviceAlbum.name} totalOnDevice is not equal to lastKnownTotal + newAssets.length. Skipping sync.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
|
||||
_removeDuplicates(newAssets);
|
||||
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
|
||||
try {
|
||||
await _assetRepository.transaction(() async {
|
||||
await _assetRepository.updateAll(updated);
|
||||
await _albumRepository.addAssets(dbAlbum, existingInDb + updated);
|
||||
await _albumRepository.recalculateMetadata(dbAlbum);
|
||||
await _albumRepository.update(dbAlbum);
|
||||
await _eTagRepository.upsertAll([ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice)]);
|
||||
});
|
||||
_log.info("Fast synced local album ${deviceAlbum.name} to DB");
|
||||
} catch (e) {
|
||||
_log.severe("Failed to fast sync local album ${deviceAlbum.name} to DB", e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Adds a new album from the device to the database and Accumulates all
|
||||
/// assets already existing in the database to the list of `existing` assets
|
||||
Future<void> _addAlbumFromDevice(Album album, List<Asset> existing, [Set<String>? excludedAssets]) async {
|
||||
_log.info("Adding a new local album to DB: ${album.name}");
|
||||
final assets = await _getHashedAssets(album, excludedAssets: excludedAssets);
|
||||
_removeDuplicates(assets);
|
||||
final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
|
||||
_log.info("${existingInDb.length} assets already existed in DB, to upsert ${updated.length}");
|
||||
await upsertAssetsWithExif(updated);
|
||||
existing.addAll(existingInDb);
|
||||
album.assets.addAll(existingInDb);
|
||||
album.assets.addAll(updated);
|
||||
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
|
||||
album.thumbnail.value = thumb;
|
||||
try {
|
||||
await _albumRepository.create(album);
|
||||
final int assetCount = await _albumMediaRepository.getAssetCount(album.localId!);
|
||||
await _eTagRepository.upsertAll([ETag(id: album.eTagKeyAssetCount, assetCount: assetCount)]);
|
||||
_log.info("Added a new local album to DB: ${album.name}");
|
||||
} catch (e) {
|
||||
_log.severe("Failed to add new local album ${album.name} to DB", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a tuple (existing, updated)
|
||||
Future<(List<Asset> existing, List<Asset> updated)> _linkWithExistingFromDb(List<Asset> assets) async {
|
||||
if (assets.isEmpty) return ([].cast<Asset>(), [].cast<Asset>());
|
||||
|
||||
final List<Asset?> inDb = await _assetRepository.getAllByOwnerIdChecksum(
|
||||
assets.map((a) => a.ownerId).toInt64List(),
|
||||
assets.map((a) => a.checksum).toList(growable: false),
|
||||
);
|
||||
assert(inDb.length == assets.length);
|
||||
final List<Asset> existing = [], toUpsert = [];
|
||||
for (int i = 0; i < assets.length; i++) {
|
||||
final Asset? b = inDb[i];
|
||||
if (b == null) {
|
||||
toUpsert.add(assets[i]);
|
||||
continue;
|
||||
}
|
||||
if (b.canUpdate(assets[i])) {
|
||||
final updated = b.updatedCopy(assets[i]);
|
||||
assert(updated.isInDb);
|
||||
toUpsert.add(updated);
|
||||
} else {
|
||||
existing.add(b);
|
||||
}
|
||||
}
|
||||
assert(existing.length + toUpsert.length == assets.length);
|
||||
return (existing, toUpsert);
|
||||
}
|
||||
|
||||
Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
|
||||
final trashMediaUrls = <String>[];
|
||||
|
||||
for (final asset in assetsList) {
|
||||
if (asset.isTrashed) {
|
||||
final mediaUrl = await asset.local?.getMediaUrl();
|
||||
if (mediaUrl == null) {
|
||||
_log.warning("Failed to get media URL for asset ${asset.name} while moving to trash");
|
||||
continue;
|
||||
}
|
||||
trashMediaUrls.add(mediaUrl);
|
||||
} else {
|
||||
await _localFilesManager.restoreFromTrash(asset.fileName, asset.type.index);
|
||||
}
|
||||
}
|
||||
|
||||
if (trashMediaUrls.isNotEmpty) {
|
||||
await _localFilesManager.moveToTrash(trashMediaUrls);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts or updates the assets in the database with their ExifInfo (if any)
|
||||
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
|
||||
if (assets.isEmpty) return;
|
||||
|
||||
if (Platform.isAndroid && _appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) {
|
||||
await _toggleTrashStatusForAssets(assets);
|
||||
}
|
||||
|
||||
try {
|
||||
await _assetRepository.transaction(() async {
|
||||
await _assetRepository.updateAll(assets);
|
||||
for (final Asset added in assets) {
|
||||
added.exifInfo = added.exifInfo?.copyWith(assetId: added.id);
|
||||
}
|
||||
final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList();
|
||||
await _exifInfoRepository.updateAll(exifInfos);
|
||||
});
|
||||
_log.info("Upserted ${assets.length} assets into the DB");
|
||||
} catch (e) {
|
||||
_log.severe("Failed to upsert ${assets.length} assets into the DB", e);
|
||||
// give details on the errors
|
||||
assets.sort(Asset.compareByOwnerChecksum);
|
||||
final inDb = await _assetRepository.getAllByOwnerIdChecksum(
|
||||
assets.map((e) => e.ownerId).toInt64List(),
|
||||
assets.map((e) => e.checksum).toList(growable: false),
|
||||
);
|
||||
for (int i = 0; i < assets.length; i++) {
|
||||
final Asset a = assets[i];
|
||||
final Asset? b = inDb[i];
|
||||
if (b == null) {
|
||||
if (!a.isInDb) {
|
||||
_log.warning("Trying to update an asset that does not exist in DB:\n$a");
|
||||
}
|
||||
} else if (a.id != b.id) {
|
||||
_log.warning("Trying to insert another asset with the same checksum+owner. In DB:\n$b\nTo insert:\n$a");
|
||||
}
|
||||
}
|
||||
for (int i = 1; i < assets.length; i++) {
|
||||
if (Asset.compareByOwnerChecksum(assets[i - 1], assets[i]) == 0) {
|
||||
_log.warning("Trying to insert duplicate assets:\n${assets[i - 1]}\n${assets[i]}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all assets that were successfully hashed
|
||||
Future<List<Asset>> _getHashedAssets(
|
||||
Album album, {
|
||||
int start = 0,
|
||||
int end = 0x7fffffffffffffff,
|
||||
DateTime? modifiedFrom,
|
||||
DateTime? modifiedUntil,
|
||||
Set<String>? excludedAssets,
|
||||
}) async {
|
||||
final entities = await _albumMediaRepository.getAssets(
|
||||
album.localId!,
|
||||
start: start,
|
||||
end: end,
|
||||
modifiedFrom: modifiedFrom,
|
||||
modifiedUntil: modifiedUntil,
|
||||
);
|
||||
final filtered = excludedAssets == null
|
||||
? entities
|
||||
: entities.where((e) => !excludedAssets.contains(e.localId!)).toList();
|
||||
return _hashService.hashAssets(filtered);
|
||||
}
|
||||
|
||||
List<Asset> _removeDuplicates(List<Asset> assets) {
|
||||
final int before = assets.length;
|
||||
assets.sort(Asset.compareByOwnerChecksumCreatedModified);
|
||||
assets.uniqueConsecutive(compare: Asset.compareByOwnerChecksum, onDuplicate: (a, b) => {});
|
||||
final int duplicates = before - assets.length;
|
||||
if (duplicates > 0) {
|
||||
_log.warning("Ignored $duplicates duplicate assets on device");
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
/// returns `true` if the albums differ on the surface
|
||||
Future<bool> _hasAlbumChangeOnDevice(Album deviceAlbum, Album dbAlbum) async {
|
||||
return deviceAlbum.name != dbAlbum.name ||
|
||||
deviceAlbum.description != dbAlbum.description ||
|
||||
!deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) ||
|
||||
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) !=
|
||||
(await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount))?.assetCount;
|
||||
}
|
||||
|
||||
Future<bool> _removeAllLocalAlbumsAndAssets() async {
|
||||
try {
|
||||
final assets = await _assetRepository.getAllLocal();
|
||||
final (toDelete, toUpdate) = _handleAssetRemoval(assets, [], remote: false);
|
||||
await _assetRepository.transaction(() async {
|
||||
await _assetRepository.deleteByIds(toDelete);
|
||||
await _assetRepository.updateAll(toUpdate);
|
||||
await _albumRepository.deleteAllLocal();
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.severe("Failed to remove all local albums and assets", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<UserDto>?> getUsersFromServer() async {
|
||||
List<UserDto>? users;
|
||||
try {
|
||||
users = await _userApiRepository.getAll();
|
||||
} catch (e) {
|
||||
_log.warning("Failed to fetch users", e);
|
||||
users = null;
|
||||
}
|
||||
final List<UserDto> sharedBy = await _partnerApiRepository.getAll(Direction.sharedByMe);
|
||||
final List<UserDto> sharedWith = await _partnerApiRepository.getAll(Direction.sharedWithMe);
|
||||
|
||||
if (users == null) {
|
||||
_log.warning("Failed to refresh users");
|
||||
return null;
|
||||
}
|
||||
|
||||
users.sortBy((u) => u.id);
|
||||
sharedBy.sortBy((u) => u.id);
|
||||
sharedWith.sortBy((u) => u.id);
|
||||
|
||||
final updatedSharedBy = <UserDto>[];
|
||||
|
||||
diffSortedListsSync(
|
||||
users,
|
||||
sharedBy,
|
||||
compare: (UserDto a, UserDto b) => a.id.compareTo(b.id),
|
||||
both: (UserDto a, UserDto b) {
|
||||
updatedSharedBy.add(a.copyWith(isPartnerSharedBy: true));
|
||||
return true;
|
||||
},
|
||||
onlyFirst: (UserDto a) => updatedSharedBy.add(a),
|
||||
onlySecond: (UserDto b) => updatedSharedBy.add(b),
|
||||
);
|
||||
|
||||
final updatedSharedWith = <UserDto>[];
|
||||
|
||||
diffSortedListsSync(
|
||||
updatedSharedBy,
|
||||
sharedWith,
|
||||
compare: (UserDto a, UserDto b) => a.id.compareTo(b.id),
|
||||
both: (UserDto a, UserDto b) {
|
||||
updatedSharedWith.add(a.copyWith(inTimeline: b.inTimeline, isPartnerSharedWith: true));
|
||||
return true;
|
||||
},
|
||||
onlyFirst: (UserDto a) => updatedSharedWith.add(a),
|
||||
onlySecond: (UserDto b) => updatedSharedWith.add(b),
|
||||
);
|
||||
|
||||
return updatedSharedWith;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a triple(toAdd, toUpdate, toRemove)
|
||||
(List<Asset> toAdd, List<Asset> toUpdate, List<Asset> toRemove) _diffAssets(
|
||||
List<Asset> assets,
|
||||
List<Asset> inDb, {
|
||||
bool? remote,
|
||||
int Function(Asset, Asset) compare = Asset.compareByChecksum,
|
||||
}) {
|
||||
// fast paths for trivial cases: reduces memory usage during initial sync etc.
|
||||
if (assets.isEmpty && inDb.isEmpty) {
|
||||
return const ([], [], []);
|
||||
} else if (assets.isEmpty && remote == null) {
|
||||
// remove all from database
|
||||
return (const [], const [], inDb);
|
||||
} else if (inDb.isEmpty) {
|
||||
// add all assets
|
||||
return (assets, const [], const []);
|
||||
}
|
||||
|
||||
final List<Asset> toAdd = [];
|
||||
final List<Asset> toUpdate = [];
|
||||
final List<Asset> toRemove = [];
|
||||
diffSortedListsSync(
|
||||
inDb,
|
||||
assets,
|
||||
compare: compare,
|
||||
both: (Asset a, Asset b) {
|
||||
if (a.canUpdate(b)) {
|
||||
toUpdate.add(a.updatedCopy(b));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onlyFirst: (Asset a) {
|
||||
if (remote == true && a.isLocal) {
|
||||
if (a.remoteId != null) {
|
||||
a.remoteId = null;
|
||||
toUpdate.add(a);
|
||||
}
|
||||
} else if (remote == false && a.isRemote) {
|
||||
if (a.isLocal) {
|
||||
a.localId = null;
|
||||
toUpdate.add(a);
|
||||
}
|
||||
} else {
|
||||
toRemove.add(a);
|
||||
}
|
||||
},
|
||||
onlySecond: (Asset b) => toAdd.add(b),
|
||||
);
|
||||
return (toAdd, toUpdate, toRemove);
|
||||
}
|
||||
|
||||
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
|
||||
(List<int> toDelete, List<Asset> toUpdate) _handleAssetRemoval(
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> existing, {
|
||||
bool? remote,
|
||||
}) {
|
||||
if (deleteCandidates.isEmpty) {
|
||||
return const ([], []);
|
||||
}
|
||||
deleteCandidates.sort(Asset.compareById);
|
||||
deleteCandidates.uniqueConsecutive(compare: Asset.compareById);
|
||||
existing.sort(Asset.compareById);
|
||||
existing.uniqueConsecutive(compare: Asset.compareById);
|
||||
final (tooAdd, toUpdate, toRemove) = _diffAssets(
|
||||
existing,
|
||||
deleteCandidates,
|
||||
compare: Asset.compareById,
|
||||
remote: remote,
|
||||
);
|
||||
assert(tooAdd.isEmpty, "toAdd should be empty in _handleAssetRemoval");
|
||||
return (toRemove.map((e) => e.id).toList(), toUpdate);
|
||||
}
|
||||
|
||||
/// returns `true` if the albums differ on the surface
|
||||
bool _hasRemoteAlbumChanged(Album remoteAlbum, Album dbAlbum) {
|
||||
return remoteAlbum.remoteAssetCount != dbAlbum.assetCount ||
|
||||
remoteAlbum.name != dbAlbum.name ||
|
||||
remoteAlbum.description != dbAlbum.description ||
|
||||
remoteAlbum.remoteThumbnailAssetId != dbAlbum.thumbnail.value?.remoteId ||
|
||||
remoteAlbum.shared != dbAlbum.shared ||
|
||||
remoteAlbum.remoteUsers.length != dbAlbum.sharedUsers.length ||
|
||||
!remoteAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) ||
|
||||
!isAtSameMomentAs(remoteAlbum.startDate, dbAlbum.startDate) ||
|
||||
!isAtSameMomentAs(remoteAlbum.endDate, dbAlbum.endDate) ||
|
||||
!isAtSameMomentAs(remoteAlbum.lastModifiedAssetTimestamp, dbAlbum.lastModifiedAssetTimestamp);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||
|
||||
final timelineServiceProvider = Provider<TimelineService>((ref) {
|
||||
return TimelineService(
|
||||
ref.watch(timelineRepositoryProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(userServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class TimelineService {
|
||||
final TimelineRepository _timelineRepository;
|
||||
final AppSettingsService _appSettingsService;
|
||||
final UserService _userService;
|
||||
|
||||
const TimelineService(this._timelineRepository, this._appSettingsService, this._userService);
|
||||
|
||||
Future<List<String>> getTimelineUserIds() async {
|
||||
final me = _userService.getMyUser();
|
||||
return _timelineRepository.getTimelineUserIds(me.id);
|
||||
}
|
||||
|
||||
Stream<List<String>> watchTimelineUserIds() async* {
|
||||
final me = _userService.getMyUser();
|
||||
yield* _timelineRepository.watchTimelineUsers(me.id);
|
||||
}
|
||||
|
||||
Stream<RenderList> watchHomeTimeline(String userId) {
|
||||
return _timelineRepository.watchHomeTimeline(userId, _getGroupByOption());
|
||||
}
|
||||
|
||||
Stream<RenderList> watchMultiUsersTimeline(List<String> userIds) {
|
||||
return _timelineRepository.watchMultiUsersTimeline(userIds, _getGroupByOption());
|
||||
}
|
||||
|
||||
Stream<RenderList> watchArchiveTimeline() async* {
|
||||
final user = _userService.getMyUser();
|
||||
|
||||
yield* _timelineRepository.watchArchiveTimeline(user.id);
|
||||
}
|
||||
|
||||
Stream<RenderList> watchFavoriteTimeline() async* {
|
||||
final user = _userService.getMyUser();
|
||||
|
||||
yield* _timelineRepository.watchFavoriteTimeline(user.id);
|
||||
}
|
||||
|
||||
Stream<RenderList> watchAlbumTimeline(Album album) async* {
|
||||
yield* _timelineRepository.watchAlbumTimeline(album, _getGroupByOption());
|
||||
}
|
||||
|
||||
Stream<RenderList> watchTrashTimeline() async* {
|
||||
final user = _userService.getMyUser();
|
||||
|
||||
yield* _timelineRepository.watchTrashTimeline(user.id);
|
||||
}
|
||||
|
||||
Stream<RenderList> watchAllVideosTimeline() {
|
||||
final user = _userService.getMyUser();
|
||||
|
||||
return _timelineRepository.watchAllVideosTimeline(user.id);
|
||||
}
|
||||
|
||||
Future<RenderList> getTimelineFromAssets(List<Asset> assets, GroupAssetsBy? groupBy) {
|
||||
GroupAssetsBy groupOption = GroupAssetsBy.none;
|
||||
if (groupBy == null) {
|
||||
groupOption = _getGroupByOption();
|
||||
} else {
|
||||
groupOption = groupBy;
|
||||
}
|
||||
|
||||
return _timelineRepository.getTimelineFromAssets(assets, groupOption);
|
||||
}
|
||||
|
||||
Stream<RenderList> watchAssetSelectionTimeline() async* {
|
||||
final user = _userService.getMyUser();
|
||||
|
||||
yield* _timelineRepository.watchAssetSelectionTimeline(user.id);
|
||||
}
|
||||
|
||||
GroupAssetsBy _getGroupByOption() {
|
||||
return GroupAssetsBy.values[_appSettingsService.getSetting(AppSettingsEnum.groupAssetsBy)];
|
||||
}
|
||||
|
||||
Stream<RenderList> watchLockedTimelineProvider() async* {
|
||||
final user = _userService.getMyUser();
|
||||
|
||||
yield* _timelineRepository.watchLockedTimeline(user.id, _getGroupByOption());
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final trashServiceProvider = Provider<TrashService>((ref) {
|
||||
return TrashService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(assetRepositoryProvider),
|
||||
ref.watch(userServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class TrashService {
|
||||
final ApiService _apiService;
|
||||
final AssetRepository _assetRepository;
|
||||
final UserService _userService;
|
||||
|
||||
const TrashService(this._apiService, this._assetRepository, this._userService);
|
||||
|
||||
Future<void> restoreAssets(Iterable<Asset> assetList) async {
|
||||
final remoteAssets = assetList.where((a) => a.isRemote);
|
||||
await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteAssets.map((e) => e.remoteId!).toList()));
|
||||
|
||||
final updatedAssets = remoteAssets.map((asset) {
|
||||
asset.isTrashed = false;
|
||||
return asset;
|
||||
}).toList();
|
||||
|
||||
await _assetRepository.updateAll(updatedAssets);
|
||||
}
|
||||
|
||||
Future<void> emptyTrash() async {
|
||||
final user = _userService.getMyUser();
|
||||
|
||||
await _apiService.trashApi.emptyTrash();
|
||||
|
||||
final trashedAssets = await _assetRepository.getTrashAssets(user.id);
|
||||
final ids = trashedAssets.map((e) => e.remoteId!).toList();
|
||||
|
||||
await _assetRepository.transaction(() async {
|
||||
await _assetRepository.deleteAllByRemoteId(ids, state: AssetState.remote);
|
||||
|
||||
final merged = await _assetRepository.getAllByRemoteId(ids, state: AssetState.merged);
|
||||
if (merged.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final Asset asset in merged) {
|
||||
asset.remoteId = null;
|
||||
asset.isTrashed = false;
|
||||
}
|
||||
|
||||
await _assetRepository.updateAll(merged);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> restoreTrash() async {
|
||||
final user = _userService.getMyUser();
|
||||
|
||||
await _apiService.trashApi.restoreTrash();
|
||||
|
||||
final trashedAssets = await _assetRepository.getTrashAssets(user.id);
|
||||
final updatedAssets = trashedAssets.map((asset) {
|
||||
asset.isTrashed = false;
|
||||
return asset;
|
||||
}).toList();
|
||||
|
||||
await _assetRepository.updateAll(updatedAssets);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user