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:
shenlong
2026-04-15 23:00:27 +05:30
committed by GitHub
parent 6dd6053222
commit 79fccdbee0
367 changed files with 332 additions and 50870 deletions
+9 -14
View File
@@ -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),
);
}
}
-425
View File
@@ -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();
}
}
-465
View File
@@ -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;
}
}
-595
View File
@@ -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();
}
-473
View File
@@ -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),
),
);
+36 -109
View File
@@ -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) {
-25
View File
@@ -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();
}
}
+2 -63
View File
@@ -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) {
-44
View File
@@ -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)),
);
-14
View File
@@ -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();
}
}
-15
View File
@@ -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();
}
}
-191
View File
@@ -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();
}
}
}
}
-71
View File
@@ -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;
}
}
}
-73
View File
@@ -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;
}
}
+2 -22
View File
@@ -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
View File
@@ -6,7 +6,7 @@ part of 'person.service.dart';
// RiverpodGenerator
// **************************************************************************
String _$personServiceHash() => r'10883bccc6c402205e6785cf9ee6cd7142cd0983';
String _$personServiceHash() => r'646e38d764c52e63d9fca86992e440f34196d519';
/// See also [personService].
@ProviderFor(personService)
+3 -30
View File
@@ -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();
-74
View File
@@ -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;
}
}
-64
View File
@@ -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)),
);
-945
View File
@@ -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);
}
-98
View File
@@ -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());
}
}
-75
View File
@@ -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);
}
}