Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

View file

@ -0,0 +1,255 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
import 'package:riverpod_annotation/riverpod_annotation.dart';
final actionServiceProvider = Provider<ActionService>(
(ref) => ActionService(
ref.watch(assetApiRepositoryProvider),
ref.watch(remoteAssetRepositoryProvider),
ref.watch(localAssetRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(remoteAlbumRepository),
ref.watch(trashedLocalAssetRepository),
ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
),
);
class ActionService {
final AssetApiRepository _assetApiRepository;
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
const ActionService(
this._assetApiRepository,
this._remoteAssetRepository,
this._localAssetRepository,
this._albumApiRepository,
this._remoteAlbumRepository,
this._trashedLocalAssetRepository,
this._assetMediaRepository,
this._downloadRepository,
);
Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
}
Future<void> favorite(List<String> remoteIds) async {
await _assetApiRepository.updateFavorite(remoteIds, true);
await _remoteAssetRepository.updateFavorite(remoteIds, true);
}
Future<void> unFavorite(List<String> remoteIds) async {
await _assetApiRepository.updateFavorite(remoteIds, false);
await _remoteAssetRepository.updateFavorite(remoteIds, false);
}
Future<void> archive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
}
Future<void> unArchive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
// Ask user if they want to delete local copies
if (localIds.isNotEmpty) {
await _deleteLocalAssets(localIds);
}
}
Future<void> removeFromLockFolder(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
Future<void> trash(List<String> remoteIds) async {
await _assetApiRepository.delete(remoteIds, false);
await _remoteAssetRepository.trash(remoteIds);
}
Future<void> restoreTrash(List<String> ids) async {
await _assetApiRepository.restoreTrash(ids);
await _remoteAssetRepository.restoreTrash(ids);
}
Future<void> trashRemoteAndDeleteLocal(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.delete(remoteIds, false);
await _remoteAssetRepository.trash(remoteIds);
if (localIds.isNotEmpty) {
await _deleteLocalAssets(localIds);
}
}
Future<void> deleteRemoteAndLocal(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.delete(remoteIds, true);
await _remoteAssetRepository.delete(remoteIds);
if (localIds.isNotEmpty) {
await _deleteLocalAssets(localIds);
}
}
Future<int> deleteLocal(List<String> localIds) async {
return await _deleteLocalAssets(localIds);
}
Future<bool> editLocation(List<String> remoteIds, BuildContext context) async {
maplibre.LatLng? initialLatLng;
if (remoteIds.length == 1) {
final exif = await _remoteAssetRepository.getExif(remoteIds[0]);
if (exif?.latitude != null && exif?.longitude != null) {
initialLatLng = maplibre.LatLng(exif!.latitude!, exif.longitude!);
}
}
final location = await showLocationPicker(context: context, initialLatLng: initialLatLng);
if (location == null) {
return false;
}
await _assetApiRepository.updateLocation(remoteIds, location);
await _remoteAssetRepository.updateLocation(remoteIds, location);
return true;
}
Future<bool> editDateTime(List<String> remoteIds, BuildContext context) async {
DateTime? initialDate;
String? timeZone;
Duration? offset;
if (remoteIds.length == 1) {
final assetId = remoteIds.first;
final asset = await _remoteAssetRepository.get(assetId);
if (asset == null) {
return false;
}
final exifData = await _remoteAssetRepository.getExif(assetId);
// Use EXIF timezone information if available (matching web app and display behavior)
DateTime dt = asset.createdAt.toLocal();
offset = dt.timeZoneOffset;
if (exifData?.dateTimeOriginal != null) {
timeZone = exifData!.timeZone;
(dt, offset) = applyTimezoneOffset(dateTime: exifData.dateTimeOriginal!, timeZone: exifData.timeZone);
}
initialDate = dt;
}
final dateTime = await showDateTimePicker(
context: context,
initialDateTime: initialDate,
initialTZ: timeZone,
initialTZOffset: offset,
);
if (dateTime == null) {
return false;
}
// convert dateTime to DateTime object
final parsedDateTime = DateTime.parse(dateTime);
await _assetApiRepository.updateDateTime(remoteIds, parsedDateTime);
await _remoteAssetRepository.updateDateTime(remoteIds, parsedDateTime);
return true;
}
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
if (result.removed.isNotEmpty) {
await _remoteAlbumRepository.removeAssets(albumId, result.removed);
}
return result.removed.length;
}
Future<bool> updateDescription(String assetId, String description) async {
// update remote first, then local to ensure consistency
await _assetApiRepository.updateDescription(assetId, description);
await _remoteAssetRepository.updateDescription(assetId, description);
return true;
}
Future<bool> updateRating(String assetId, int rating) async {
// update remote first, then local to ensure consistency
await _assetApiRepository.updateRating(assetId, rating);
await _remoteAssetRepository.updateRating(assetId, rating);
return true;
}
Future<void> stack(String userId, List<String> remoteIds) async {
final stack = await _assetApiRepository.stack(remoteIds);
await _remoteAssetRepository.stack(userId, stack);
}
Future<void> unStack(List<String> stackIds) async {
await _remoteAssetRepository.unStack(stackIds);
await _assetApiRepository.unStack(stackIds);
}
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context) {
return _assetMediaRepository.shareAssets(assets, context);
}
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
return _downloadRepository.downloadAllAssets(assets);
}
Future<int> _deleteLocalAssets(List<String> localIds) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isEmpty) {
return 0;
}
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
} else {
await _localAssetRepository.delete(deletedIds);
}
return deletedIds.length;
}
}

View file

@ -0,0 +1,79 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/errors.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
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;
final TimelineFactory _timelineFactory;
final AssetService _assetService;
@override
final Logger logger = Logger("ActivityService");
ActivityService(this._activityApiRepository, this._timelineFactory, this._assetService);
Future<List<Activity>> getAllActivities(String albumId, {String? assetId}) async {
return logError(
() => _activityApiRepository.getAll(albumId, assetId: assetId),
defaultValue: [],
errorMessage: "Failed to get all activities for album $albumId",
);
}
Future<ActivityStats> getStatistics(String albumId, {String? assetId}) async {
return logError(
() => _activityApiRepository.getStats(albumId, assetId: assetId),
defaultValue: const ActivityStats(comments: 0),
errorMessage: "Failed to statistics for album $albumId",
);
}
Future<bool> removeActivity(String id) async {
return logError(
() async {
try {
await _activityApiRepository.delete(id);
} on NoResponseDtoError {
return true;
}
return true;
},
defaultValue: false,
errorMessage: "Failed to delete activity",
);
}
AsyncFuture<Activity> addActivity(String albumId, ActivityType type, {String? assetId, String? comment}) async {
return guardError(
() => _activityApiRepository.create(albumId, type, assetId: assetId, comment: comment),
errorMessage: "Failed to create $type for album $albumId",
);
}
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),
);
}
return null;
}
}

View file

@ -0,0 +1,425 @@
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();
}
}

View file

@ -0,0 +1,215 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class ApiService implements Authentication {
late ApiClient _apiClient;
late UsersApi usersApi;
late AuthenticationApi authenticationApi;
late AuthenticationApi oAuthApi;
late AlbumsApi albumsApi;
late AssetsApi assetsApi;
late SearchApi searchApi;
late ServerApi serverInfoApi;
late MapApi mapApi;
late PartnersApi partnersApi;
late PeopleApi peopleApi;
late SharedLinksApi sharedLinksApi;
late SyncApi syncApi;
late SystemConfigApi systemConfigApi;
late ActivitiesApi activitiesApi;
late DownloadApi downloadApi;
late TrashApi trashApi;
late StacksApi stacksApi;
late ViewsApi viewApi;
late MemoriesApi memoriesApi;
late SessionsApi sessionsApi;
ApiService() {
// The below line ensures that the api clients are initialized when the service is instantiated
// This is required to avoid late initialization errors when the clients are access before the endpoint is resolved
setEndpoint('');
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
if (endpoint != null && endpoint.isNotEmpty) {
setEndpoint(endpoint);
}
}
String? _accessToken;
final _log = Logger("ApiService");
setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint, authentication: this);
_setUserAgentHeader();
if (_accessToken != null) {
setAccessToken(_accessToken!);
}
usersApi = UsersApi(_apiClient);
authenticationApi = AuthenticationApi(_apiClient);
oAuthApi = AuthenticationApi(_apiClient);
albumsApi = AlbumsApi(_apiClient);
assetsApi = AssetsApi(_apiClient);
serverInfoApi = ServerApi(_apiClient);
searchApi = SearchApi(_apiClient);
mapApi = MapApi(_apiClient);
partnersApi = PartnersApi(_apiClient);
peopleApi = PeopleApi(_apiClient);
sharedLinksApi = SharedLinksApi(_apiClient);
syncApi = SyncApi(_apiClient);
systemConfigApi = SystemConfigApi(_apiClient);
activitiesApi = ActivitiesApi(_apiClient);
downloadApi = DownloadApi(_apiClient);
trashApi = TrashApi(_apiClient);
stacksApi = StacksApi(_apiClient);
viewApi = ViewsApi(_apiClient);
memoriesApi = MemoriesApi(_apiClient);
sessionsApi = SessionsApi(_apiClient);
}
Future<void> _setUserAgentHeader() async {
final userAgent = await getUserAgentString();
_apiClient.addDefaultHeader('User-Agent', userAgent);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async {
final endpoint = await resolveEndpoint(serverUrl);
setEndpoint(endpoint);
// Save in local database for next startup
await Store.put(StoreKey.serverEndpoint, endpoint);
return endpoint;
}
/// Takes a server URL and attempts to resolve the API endpoint.
///
/// Input: [schema://]host[:port][/path]
/// schema - optional (default: https)
/// host - required
/// port - optional (default: based on schema)
/// path - optional
Future<String> resolveEndpoint(String serverUrl) async {
String url = sanitizeUrl(serverUrl);
// Check for /.well-known/immich
final wellKnownEndpoint = await _getWellKnownEndpoint(url);
if (wellKnownEndpoint.isNotEmpty) {
url = sanitizeUrl(wellKnownEndpoint);
}
if (!await _isEndpointAvailable(url)) {
throw ApiException(503, "Server is not reachable");
}
// Otherwise, assume the URL provided is the api endpoint
return url;
}
Future<bool> _isEndpointAvailable(String serverUrl) async {
if (!serverUrl.endsWith('/api')) {
serverUrl += '/api';
}
try {
await setEndpoint(serverUrl);
await serverInfoApi.pingServer().timeout(const Duration(seconds: 5));
} on TimeoutException catch (_) {
return false;
} on SocketException catch (_) {
return false;
} catch (error, stackTrace) {
_log.severe("Error while checking server availability", error, stackTrace);
return false;
}
return true;
}
Future<String> _getWellKnownEndpoint(String baseUrl) async {
final Client client = Client();
try {
var headers = {"Accept": "application/json"};
headers.addAll(getRequestHeaders());
final res = await client
.get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers)
.timeout(const Duration(seconds: 5));
if (res.statusCode == 200) {
final data = jsonDecode(res.body);
final endpoint = data['api']['endpoint'].toString();
if (endpoint.startsWith('/')) {
// Full URL is relative to base
return "$baseUrl$endpoint";
}
return endpoint;
}
} catch (e) {
dPrint(() => "Could not locate /.well-known/immich at $baseUrl");
}
return "";
}
Future<void> setAccessToken(String accessToken) async {
_accessToken = accessToken;
await Store.put(StoreKey.accessToken, accessToken);
}
Future<void> setDeviceInfoHeader() async {
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
if (Platform.isIOS) {
final iosInfo = await deviceInfoPlugin.iosInfo;
authenticationApi.apiClient.addDefaultHeader('deviceModel', iosInfo.utsname.machine);
authenticationApi.apiClient.addDefaultHeader('deviceType', 'iOS');
} else if (Platform.isAndroid) {
final androidInfo = await deviceInfoPlugin.androidInfo;
authenticationApi.apiClient.addDefaultHeader('deviceModel', androidInfo.model);
authenticationApi.apiClient.addDefaultHeader('deviceType', 'Android');
} else {
authenticationApi.apiClient.addDefaultHeader('deviceModel', 'Unknown');
authenticationApi.apiClient.addDefaultHeader('deviceType', 'Unknown');
}
}
static Map<String, String> getRequestHeaders() {
var accessToken = Store.get(StoreKey.accessToken, "");
var customHeadersStr = Store.get(StoreKey.customHeaders, "");
var header = <String, String>{};
if (accessToken.isNotEmpty) {
header['x-immich-user-token'] = accessToken;
}
if (customHeadersStr.isEmpty) {
return header;
}
var customHeaders = jsonDecode(customHeadersStr) as Map;
customHeaders.forEach((key, value) {
header[key] = value;
});
return header;
}
@override
Future<void> applyToParams(List<QueryParam> queryParams, Map<String, String> headerParams) {
return Future<void>(() {
var headers = ApiService.getRequestHeaders();
headerParams.addAll(headers);
});
}
ApiClient get apiClient => _apiClient;
}

View file

@ -0,0 +1,80 @@
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> {
loadPreview<bool>(StoreKey.loadPreview, "loadPreview", true),
loadOriginal<bool>(StoreKey.loadOriginal, "loadOriginal", false),
themeMode<String>(StoreKey.themeMode, "themeMode", "system"), // "light","dark","system"
primaryColor<String>(StoreKey.primaryColor, "primaryColor", defaultColorPresetName),
dynamicTheme<bool>(StoreKey.dynamicTheme, "dynamicTheme", false),
colorfulInterface<bool>(StoreKey.colorfulInterface, "colorfulInterface", true),
tilesPerRow<int>(StoreKey.tilesPerRow, "tilesPerRow", 4),
dynamicLayout<bool>(StoreKey.dynamicLayout, "dynamicLayout", false),
groupAssetsBy<int>(StoreKey.groupAssetsBy, "groupBy", 0),
uploadErrorNotificationGracePeriod<int>(
StoreKey.uploadErrorNotificationGracePeriod,
"uploadErrorNotificationGracePeriod",
2,
),
backgroundBackupTotalProgress<bool>(StoreKey.backgroundBackupTotalProgress, "backgroundBackupTotalProgress", true),
backgroundBackupSingleProgress<bool>(
StoreKey.backgroundBackupSingleProgress,
"backgroundBackupSingleProgress",
false,
),
storageIndicator<bool>(StoreKey.storageIndicator, "storageIndicator", true),
thumbnailCacheSize<int>(StoreKey.thumbnailCacheSize, "thumbnailCacheSize", 10000),
imageCacheSize<int>(StoreKey.imageCacheSize, "imageCacheSize", 350),
albumThumbnailCacheSize<int>(StoreKey.albumThumbnailCacheSize, "albumThumbnailCacheSize", 200),
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, "loadOriginalVideo", false),
autoPlayVideo<bool>(StoreKey.autoPlayVideo, "autoPlayVideo", true),
mapThemeMode<int>(StoreKey.mapThemeMode, null, 0),
mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false),
mapwithPartners<bool>(StoreKey.mapwithPartners, null, false),
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
ignoreIcloudAssets<bool>(StoreKey.ignoreIcloudAssets, null, false),
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
autoEndpointSwitching<bool>(StoreKey.autoEndpointSwitching, null, false),
photoManagerCustomFilter<bool>(StoreKey.photoManagerCustomFilter, null, true),
betaTimeline<bool>(StoreKey.betaTimeline, null, true),
enableBackup<bool>(StoreKey.enableBackup, null, false),
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
cleanupKeepFavorites<bool>(StoreKey.cleanupKeepFavorites, null, true),
cleanupKeepMediaType<int>(StoreKey.cleanupKeepMediaType, null, 0),
cleanupKeepAlbumIds<String>(StoreKey.cleanupKeepAlbumIds, null, ""),
cleanupCutoffDaysAgo<int>(StoreKey.cleanupCutoffDaysAgo, null, -1),
cleanupDefaultsInitialized<bool>(StoreKey.cleanupDefaultsInitialized, null, false);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
final StoreKey<T> storeKey;
final String? hiveKey;
final T defaultValue;
}
class AppSettingsService {
const AppSettingsService();
T getSetting<T>(AppSettingsEnum<T> setting) {
return Store.get(setting.storeKey, setting.defaultValue);
}
Future<void> setSetting<T>(AppSettingsEnum<T> setting, T value) {
return Store.put(setting.storeKey, value);
}
}

View file

@ -0,0 +1,465 @@
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;
}
}

View file

@ -0,0 +1,220 @@
import 'dart:async';
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/repositories/auth.repository.dart';
import 'package:immich_mobile/repositories/auth_api.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/network.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final authServiceProvider = Provider(
(ref) => AuthService(
ref.watch(authApiRepositoryProvider),
ref.watch(authRepositoryProvider),
ref.watch(apiServiceProvider),
ref.watch(networkServiceProvider),
ref.watch(backgroundSyncProvider),
ref.watch(appSettingsServiceProvider),
),
);
class AuthService {
final AuthApiRepository _authApiRepository;
final AuthRepository _authRepository;
final ApiService _apiService;
final NetworkService _networkService;
final BackgroundSyncManager _backgroundSyncManager;
final AppSettingsService _appSettingsService;
final _log = Logger("AuthService");
AuthService(
this._authApiRepository,
this._authRepository,
this._apiService,
this._networkService,
this._backgroundSyncManager,
this._appSettingsService,
);
/// Validates the provided server URL by resolving and setting the endpoint.
/// Also sets the device info header and stores the valid URL.
///
/// [url] - The server URL to be validated.
///
/// Returns the validated and resolved server URL as a [String].
///
/// Throws an exception if the URL cannot be resolved or set.
Future<String> validateServerUrl(String url) async {
final validUrl = await _apiService.resolveAndSetEndpoint(url);
await _apiService.setDeviceInfoHeader();
await Store.put(StoreKey.serverUrl, validUrl);
return validUrl;
}
Future<bool> validateAuxilaryServerUrl(String url) async {
final httpclient = HttpClient();
bool isValid = false;
try {
final uri = Uri.parse('$url/users/me');
final request = await httpclient.getUrl(uri);
// add auth token + any configured custom headers
final customHeaders = ApiService.getRequestHeaders();
customHeaders.forEach((key, value) {
request.headers.add(key, value);
});
final response = await request.close();
if (response.statusCode == 200) {
isValid = true;
}
} catch (error) {
_log.severe("Error validating auxiliary endpoint", error);
} finally {
httpclient.close();
}
return isValid;
}
Future<LoginResponse> login(String email, String password) {
return _authApiRepository.login(email, password);
}
/// Performs user logout operation by making a server request and clearing local data.
///
/// This method attempts to log out the user through the authentication API repository.
/// If the server request fails, the error is logged but local data is still cleared.
/// The local data cleanup is guaranteed to execute regardless of the server request outcome.
///
/// Throws any unhandled exceptions from the API request or local data clearing operations.
Future<void> logout() async {
try {
await _authApiRepository.logout();
} catch (error, stackTrace) {
_log.severe("Error logging out", error, stackTrace);
} finally {
await clearLocalData().catchError((error, stackTrace) {
_log.severe("Error clearing local data", error, stackTrace);
});
await _appSettingsService.setSetting(AppSettingsEnum.enableBackup, false);
}
}
/// Clears all local authentication-related data.
///
/// This method performs a concurrent deletion of:
/// - Authentication repository data
/// - Current user information
/// - Access token
/// - Asset ETag
///
/// All deletions are executed in parallel using [Future.wait].
Future<void> clearLocalData() async {
// Cancel any ongoing background sync operations before clearing data
await _backgroundSyncManager.cancel();
await Future.wait([
_authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
Store.delete(StoreKey.assetETag),
Store.delete(StoreKey.autoEndpointSwitching),
Store.delete(StoreKey.preferredWifiName),
Store.delete(StoreKey.localEndpoint),
Store.delete(StoreKey.externalEndpointList),
]);
}
Future<void> changePassword(String newPassword) {
try {
return _authApiRepository.changePassword(newPassword);
} catch (error, stackTrace) {
_log.severe("Error changing password", error, stackTrace);
rethrow;
}
}
Future<String?> setOpenApiServiceEndpoint() async {
final enable = _authRepository.getEndpointSwitchingFeature();
if (!enable) {
return null;
}
final wifiName = await _networkService.getWifiName();
final savedWifiName = _authRepository.getPreferredWifiName();
String? endpoint;
if (wifiName == savedWifiName) {
endpoint = await _setLocalConnection();
}
endpoint ??= await _setRemoteConnection();
return endpoint;
}
Future<String?> _setLocalConnection() async {
try {
final localEndpoint = _authRepository.getLocalEndpoint();
if (localEndpoint != null) {
await _apiService.resolveAndSetEndpoint(localEndpoint);
return localEndpoint;
}
} catch (error, stackTrace) {
_log.severe("Cannot set local endpoint", error, stackTrace);
}
return null;
}
Future<String?> _setRemoteConnection() async {
List<AuxilaryEndpoint> endpointList;
try {
endpointList = _authRepository.getExternalEndpointList();
} catch (error, stackTrace) {
_log.severe("Cannot get external endpoint", error, stackTrace);
return null;
}
for (final endpoint in endpointList) {
try {
return await _apiService.resolveAndSetEndpoint(endpoint.url);
} on ApiException catch (error) {
_log.severe("Cannot resolve endpoint", error);
continue;
} catch (_) {
_log.severe("Auxiliary server is not valid");
continue;
}
}
return null;
}
Future<bool> unlockPinCode(String pinCode) {
return _authApiRepository.unlockPinCode(pinCode);
}
Future<void> lockPinCode() {
return _authApiRepository.lockPinCode();
}
Future<void> setupPinCode(String pinCode) {
return _authApiRepository.setupPinCode(pinCode);
}
}

View file

@ -0,0 +1,597 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'dart:isolate';
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart';
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:immich_mobile/utils/http_ssl_options.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;
CancellationToken? _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?.cancel();
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)),
],
);
HttpSSLOptions.apply();
await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken));
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 = CancellationToken();
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!.isCancelled) {
unawaited(
_showErrorNotification(
title: "backup_background_service_error_title".tr(),
content: "backup_background_service_backup_failed_message".tr(),
),
);
}
return ok;
}
void _onAssetUploaded({bool shouldNotify = false}) async {
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();
}

View file

@ -0,0 +1,436 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
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/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
final backgroundUploadServiceProvider = Provider((ref) {
final service = BackgroundUploadService(
ref.watch(uploadRepositoryProvider),
ref.watch(storageRepositoryProvider),
ref.watch(localAssetRepository),
ref.watch(backupRepositoryProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(assetMediaRepositoryProvider),
);
ref.onDispose(service.dispose);
return service;
});
/// Metadata for upload tasks to track live photo handling
class UploadTaskMetadata {
final String localAssetId;
final bool isLivePhotos;
final String livePhotoVideoId;
const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId});
UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) {
return UploadTaskMetadata(
localAssetId: localAssetId ?? this.localAssetId,
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'localAssetId': localAssetId,
'isLivePhotos': isLivePhotos,
'livePhotoVideoId': livePhotoVideoId,
};
}
factory UploadTaskMetadata.fromMap(Map<String, dynamic> map) {
return UploadTaskMetadata(
localAssetId: map['localAssetId'] as String,
isLivePhotos: map['isLivePhotos'] as bool,
livePhotoVideoId: map['livePhotoVideoId'] as String,
);
}
String toJson() => json.encode(toMap());
factory UploadTaskMetadata.fromJson(String source) =>
UploadTaskMetadata.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() =>
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
@override
bool operator ==(covariant UploadTaskMetadata other) {
if (identical(this, other)) return true;
return other.localAssetId == localAssetId &&
other.isLivePhotos == isLivePhotos &&
other.livePhotoVideoId == livePhotoVideoId;
}
@override
int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
}
/// Service for handling background uploads using iOS URLSession (background_downloader)
///
/// This service handles asynchronous background uploads that can continue
/// even when the app is suspended. Primarily used for iOS background backup.
class BackgroundUploadService {
BackgroundUploadService(
this._uploadRepository,
this._storageRepository,
this._localAssetRepository,
this._backupRepository,
this._appSettingsService,
this._assetMediaRepository,
) {
_uploadRepository.onUploadStatus = _onUploadCallback;
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
}
final UploadRepository _uploadRepository;
final StorageRepository _storageRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftBackupRepository _backupRepository;
final AppSettingsService _appSettingsService;
final AssetMediaRepository _assetMediaRepository;
final Logger _logger = Logger('BackgroundUploadService');
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
final StreamController<TaskProgressUpdate> _taskProgressController = StreamController<TaskProgressUpdate>.broadcast();
Stream<TaskStatusUpdate> get taskStatusStream => _taskStatusController.stream;
Stream<TaskProgressUpdate> get taskProgressStream => _taskProgressController.stream;
bool shouldAbortQueuingTasks = false;
void _onTaskProgressCallback(TaskProgressUpdate update) {
if (!_taskProgressController.isClosed) {
_taskProgressController.add(update);
}
}
void _onUploadCallback(TaskStatusUpdate update) {
if (!_taskStatusController.isClosed) {
_taskStatusController.add(update);
}
_handleTaskStatusUpdate(update);
}
void dispose() {
_taskStatusController.close();
_taskProgressController.close();
}
/// Enqueue tasks to the background upload queue
Future<List<bool>> enqueueTasks(List<UploadTask> tasks) {
return _uploadRepository.enqueueBackgroundAll(tasks);
}
/// Get a list of tasks that are ENQUEUED or RUNNING
Future<List<Task>> getActiveTasks(String group) {
return _uploadRepository.getActiveTasks(group);
}
/// Start background upload using iOS URLSession
///
/// Finds backup candidates, builds upload tasks, and enqueues them
/// for background processing.
Future<void> uploadBackupCandidates(String userId) async {
await _storageRepository.clearCache();
shouldAbortQueuingTasks = false;
final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) {
return;
}
const batchSize = 100;
final batch = candidates.take(batchSize).toList();
List<UploadTask> tasks = [];
for (final asset in batch) {
final task = await getUploadTask(asset);
if (task != null) {
tasks.add(task);
}
}
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
await enqueueTasks(tasks);
}
}
/// Cancel all ongoing background uploads and reset the upload queue
///
/// Returns the number of tasks left in the queue
Future<int> cancel() async {
shouldAbortQueuingTasks = true;
await _storageRepository.clearCache();
await _uploadRepository.reset(kBackupGroup);
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
final activeTasks = await _uploadRepository.getActiveTasks(kBackupGroup);
return activeTasks.length;
}
/// Resume background backup processing
Future<void> resume() {
return _uploadRepository.start();
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
switch (update.status) {
case TaskStatus.complete:
unawaited(_handleLivePhoto(update));
if (CurrentPlatform.isIOS) {
try {
final path = await update.task.filePath();
await File(path).delete();
} catch (e) {
_logger.severe('Error deleting file path for iOS: $e');
}
}
break;
default:
break;
}
}
Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
try {
if (update.task.metaData.isEmpty || update.task.metaData == '') {
return;
}
final metadata = UploadTaskMetadata.fromJson(update.task.metaData);
if (!metadata.isLivePhotos) {
return;
}
if (update.responseBody == null || update.responseBody!.isEmpty) {
return;
}
final response = jsonDecode(update.responseBody!);
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
if (localAsset == null) {
return;
}
final uploadTask = await getLivePhotoUploadTask(localAsset, response['id'] as String);
if (uploadTask == null) {
return;
}
await enqueueTasks([uploadTask]);
} catch (error, stackTrace) {
dPrint(() => "Error handling live photo upload task: $error $stackTrace");
}
}
@visibleForTesting
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
_logger.warning("Asset entity not found for ${asset.id} - ${asset.name}");
return null;
}
File? file;
/// iOS LivePhoto has two files: a photo and a video.
/// They are uploaded separately, with video file being upload first, then returned with the assetId
/// The assetId is then used as a metadata for the photo file upload task.
///
/// We implement two separate upload groups for this, the normal one for the video file
/// and the higher priority group for the photo file because the video file is already uploaded.
///
/// The cancel operation will only cancel the video group (normal group), the photo group will not
/// be touched, as the video file is already uploaded.
if (entity.isLivePhoto) {
file = await _storageRepository.getMotionFileForAsset(asset);
} else {
file = await _storageRepository.getFileForAsset(asset.id);
}
if (file == null) {
_logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}");
return null;
}
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final hasExtension = p.extension(fileName).isNotEmpty;
if (!hasExtension) {
fileName = p.setExtension(fileName, p.extension(asset.name));
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
String metadata = UploadTaskMetadata(
localAssetId: asset.id,
isLivePhotos: entity.isLivePhoto,
livePhotoVideoId: '',
).toJson();
final requiresWiFi = _shouldRequireWiFi(asset);
return buildUploadTask(
file,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: originalFileName,
deviceAssetId: asset.id,
metadata: metadata,
group: group,
priority: priority,
isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi,
cloudId: entity.isLivePhoto ? null : asset.cloudId,
adjustmentTime: entity.isLivePhoto ? null : asset.adjustmentTime?.toIso8601String(),
latitude: entity.isLivePhoto ? null : asset.latitude?.toString(),
longitude: entity.isLivePhoto ? null : asset.longitude?.toString(),
);
}
@visibleForTesting
Future<UploadTask?> getLivePhotoUploadTask(LocalAsset asset, String livePhotoVideoId) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
return null;
}
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
return null;
}
final fields = {'livePhotoVideoId': livePhotoVideoId};
final requiresWiFi = _shouldRequireWiFi(asset);
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
return buildUploadTask(
file,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: originalFileName,
deviceAssetId: asset.id,
fields: fields,
group: kBackupLivePhotoGroup,
priority: 0, // Highest priority to get upload immediately
isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi,
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
bool _shouldRequireWiFi(LocalAsset asset) {
bool requiresWiFi = true;
if (asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)) {
requiresWiFi = false;
} else if (!asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)) {
requiresWiFi = false;
}
return requiresWiFi;
}
Future<UploadTask> buildUploadTask(
File file, {
required String group,
required DateTime createdAt,
required DateTime modifiedAt,
Map<String, String>? fields,
String? originalFileName,
String? deviceAssetId,
String? metadata,
int? priority,
bool? isFavorite,
bool requiresWiFi = true,
String? cloudId,
String? adjustmentTime,
String? latitude,
String? longitude,
}) async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final url = Uri.parse('$serverEndpoint/assets').toString();
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
final (baseDirectory, directory, filename) = await Task.split(filePath: file.path);
final fieldsMap = {
'filename': originalFileName ?? filename,
'deviceAssetId': deviceAssetId ?? '',
'deviceId': deviceId,
'fileCreatedAt': createdAt.toUtc().toIso8601String(),
'fileModifiedAt': modifiedAt.toUtc().toIso8601String(),
'isFavorite': isFavorite?.toString() ?? 'false',
'duration': '0',
if (fields != null) ...fields,
if (CurrentPlatform.isIOS && cloudId != null)
'metadata': jsonEncode([
RemoteAssetMetadataItem(
key: RemoteAssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: cloudId,
createdAt: createdAt.toIso8601String(),
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
),
]),
};
return UploadTask(
taskId: deviceAssetId,
displayName: originalFileName ?? filename,
httpRequestMethod: 'POST',
url: url,
headers: headers,
filename: filename,
fields: fieldsMap,
baseDirectory: baseDirectory,
directory: directory,
fileField: 'assetData',
metaData: metadata ?? '',
group: group,
requiresWiFi: requiresWiFi,
priority: priority ?? 5,
updates: Updates.statusAndProgress,
retries: 3,
);
}
}

View file

@ -0,0 +1,500 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:cancellation_token_http/http.dart' as http;
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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 httpClient = http.Client();
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,
http.CancellationToken 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 = http.MultipartFile(
"assetData",
fileStream,
file.lengthSync(),
filename: originalFileName,
);
final baseRequest = MultipartRequest(
'POST',
Uri.parse('$savedEndpoint/assets'),
onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
);
baseRequest.headers.addAll(ApiService.getRequestHeaders());
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 httpClient.send(baseRequest, cancellationToken: cancelToken);
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 http.CancelledException {
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,
http.CancellationToken cancelToken,
) async {
if (livePhotoVideoFile == null) {
return null;
}
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoVideoFile.path));
final fileStream = livePhotoVideoFile.openRead();
final livePhotoRawUploadData = http.MultipartFile(
"assetData",
fileStream,
livePhotoVideoFile.lengthSync(),
filename: livePhotoTitle,
);
final livePhotoReq = MultipartRequest(baseRequest.method, baseRequest.url, onProgress: baseRequest.onProgress)
..headers.addAll(baseRequest.headers)
..fields.addAll(baseRequest.fields);
livePhotoReq.files.add(livePhotoRawUploadData);
var response = await httpClient.send(livePhotoReq, cancellationToken: cancelToken);
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",
};
}
class MultipartRequest extends http.MultipartRequest {
/// Creates a new [MultipartRequest].
MultipartRequest(super.method, super.url, {required this.onProgress});
final void Function(int bytes, int totalBytes) onProgress;
/// Freezes all mutable fields and returns a
/// single-subscription [http.ByteStream]
/// that will emit the request body.
@override
http.ByteStream finalize() {
final byteStream = super.finalize();
final total = contentLength;
var bytes = 0;
final t = StreamTransformer.fromHandlers(
handleData: (List<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress.call(bytes, total);
sink.add(data);
},
);
final stream = byteStream.transform(t);
return http.ByteStream(stream);
}
}

View file

@ -0,0 +1,33 @@
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);
}
}

View file

@ -0,0 +1,197 @@
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),
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
));
final upper = compute(_computeSaveToDelete, (
deleteCandidates: deleteCandidates.slice(half),
originals: originals.slice(half),
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
));
toDelete = await lower + await upper;
} else {
toDelete = await compute(_computeSaveToDelete, (
deleteCandidates: deleteCandidates,
originals: originals,
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
));
}
return toDelete;
}
static Future<List<Asset>> _computeSaveToDelete(
({
List<Asset> deleteCandidates,
List<Asset> originals,
String auth,
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);
await apiService.setAccessToken(tuple.auth);
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),
),
);

View file

@ -0,0 +1,60 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
final cleanupServiceProvider = Provider<CleanupService>((ref) {
return CleanupService(ref.watch(localAssetRepository), ref.watch(assetMediaRepositoryProvider));
});
class CleanupService {
final DriftLocalAssetRepository _localAssetRepository;
final AssetMediaRepository _assetMediaRepository;
const CleanupService(this._localAssetRepository, this._assetMediaRepository);
Future<RemovalCandidatesResult> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetKeepType keepMediaType = AssetKeepType.none,
bool keepFavorites = true,
Set<String> keepAlbumIds = const {},
}) {
return _localAssetRepository.getRemovalCandidates(
userId,
cutoffDate,
keepMediaType: keepMediaType,
keepFavorites: keepFavorites,
keepAlbumIds: keepAlbumIds,
);
}
Future<int> deleteLocalAssets(List<String> localIds) async {
if (localIds.isEmpty) {
return 0;
}
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isNotEmpty) {
await _localAssetRepository.delete(deletedIds);
return deletedIds.length;
}
return 0;
}
/// Returns album IDs that should be kept by default (e.g., messaging app albums)
Set<String> getDefaultKeepAlbumIds(List<(String id, String name)> albums) {
const messagingApps = ['whatsapp', 'telegram', 'signal', 'messenger', 'viber', 'wechat', 'line'];
final toKeep = <String>{};
for (final (id, name) in albums) {
final albumName = name.toLowerCase();
if (messagingApps.any((app) => albumName.contains(app))) {
toKeep.add(id);
}
}
return toKeep;
}
}

View file

@ -0,0 +1,228 @@
import 'package:auto_route/auto_route.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart' as beta_asset_service;
import 'package:immich_mobile/domain/services/memory.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';
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),
ref.watch(driftMemoryServiceProvider),
ref.watch(currentUserProvider),
),
);
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;
final DriftMemoryService _betaMemoryServiceProvider;
final UserDto? _currentUser;
const DeepLinkService(
this._memoryService,
this._assetService,
this._albumService,
this._currentAsset,
this._currentAlbum,
this._betaTimelineFactory,
this._betaAssetService,
this._betaRemoteAlbumService,
this._betaMemoryServiceProvider,
this._currentUser,
);
DeepLink _handleColdStart(PageRouteInfo<dynamic> route, bool isColdStart) {
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(),
route,
]);
}
Future<DeepLink> handleScheme(PlatformDeepLink link, WidgetRef ref, bool isColdStart) async {
// get everything after the scheme, since Uri cannot parse path
final intent = link.uri.host;
final queryParams = link.uri.queryParameters;
PageRouteInfo<dynamic>? deepLinkRoute = switch (intent) {
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref),
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
"activity" => await _buildActivityDeepLink(queryParams['albumId'] ?? ''),
_ => null,
};
// Deep link resolution failed, safely handle it based on the app state
if (deepLinkRoute == null) {
if (isColdStart) {
return DeepLink.defaultPath;
}
return DeepLink.none;
}
return _handleColdStart(deepLinkRoute, isColdStart);
}
Future<DeepLink> handleMyImmichApp(PlatformDeepLink link, WidgetRef ref, bool isColdStart) async {
final path = link.uri.path;
const uuidRegex = r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
final assetRegex = RegExp('/photos/($uuidRegex)');
final albumRegex = RegExp('/albums/($uuidRegex)');
PageRouteInfo<dynamic>? deepLinkRoute;
if (assetRegex.hasMatch(path)) {
final assetId = assetRegex.firstMatch(path)?.group(1) ?? '';
deepLinkRoute = await _buildAssetDeepLink(assetId, ref);
} else if (albumRegex.hasMatch(path)) {
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
deepLinkRoute = await _buildAlbumDeepLink(albumId);
} else if (path == "/memory") {
deepLinkRoute = await _buildMemoryDeepLink(null);
}
// Deep link resolution failed, safely handle it based on the app state
if (deepLinkRoute == null) {
if (isColdStart) return DeepLink.defaultPath;
return DeepLink.none;
}
return _handleColdStart(deepLinkRoute, isColdStart);
}
Future<PageRouteInfo?> _buildMemoryDeepLink(String? memoryId) async {
if (Store.isBetaTimelineEnabled) {
List<DriftMemory> memories = [];
if (memoryId == null) {
if (_currentUser == null) {
return null;
}
memories = await _betaMemoryServiceProvider.getMemoryLane(_currentUser.id);
} else {
final memory = await _betaMemoryServiceProvider.get(memoryId);
if (memory != null) {
memories = [memory];
}
}
if (memories.isEmpty) {
return null;
}
return DriftMemoryRoute(memories: memories, memoryIndex: 0);
} else {
// TODO: Remove this when beta is default
if (memoryId == null) {
return null;
}
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) {
return null;
}
final album = await _betaRemoteAlbumService.get(albumId);
if (album == null || album.isActivityEnabled == false) {
return null;
}
return DriftActivitiesRoute(album: album);
}
}

View file

@ -0,0 +1,25 @@
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();
}
}

View file

@ -0,0 +1,202 @@
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(
(ref) => DownloadService(ref.watch(fileMediaRepositoryProvider), ref.watch(downloadRepositoryProvider)),
);
class DownloadService {
final DownloadRepository _downloadRepository;
final FileMediaRepository _fileMediaRepository;
final Logger _log = Logger("DownloadService");
void Function(TaskStatusUpdate)? onImageDownloadStatus;
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
DownloadService(this._fileMediaRepository, this._downloadRepository) {
_downloadRepository.onImageDownloadStatus = _onImageDownloadCallback;
_downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback;
_downloadRepository.onLivePhotoDownloadStatus = _onLivePhotoDownloadCallback;
_downloadRepository.onTaskProgress = _onTaskProgressCallback;
}
void _onTaskProgressCallback(TaskProgressUpdate update) {
onTaskProgress?.call(update);
}
void _onImageDownloadCallback(TaskStatusUpdate update) {
onImageDownloadStatus?.call(update);
}
void _onVideoDownloadCallback(TaskStatusUpdate update) {
onVideoDownloadStatus?.call(update);
}
void _onLivePhotoDownloadCallback(TaskStatusUpdate update) {
onLivePhotoDownloadStatus?.call(update);
}
Future<bool> saveImageWithPath(Task task) async {
final filePath = await task.filePath();
final title = task.filename;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
try {
final Asset? resultAsset = await _fileMediaRepository.saveImageWithFile(
filePath,
title: title,
relativePath: relativePath,
);
return resultAsset != null;
} catch (error, stack) {
_log.severe("Error saving image", error, stack);
return false;
} finally {
if (await File(filePath).exists()) {
await File(filePath).delete();
}
}
}
Future<bool> saveVideo(Task task) async {
final filePath = await task.filePath();
final title = task.filename;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
final file = File(filePath);
try {
final Asset? resultAsset = await _fileMediaRepository.saveVideo(file, title: title, relativePath: relativePath);
return resultAsset != null;
} catch (error, stack) {
_log.severe("Error saving video", error, stack);
return false;
} finally {
if (await file.exists()) {
await file.delete();
}
}
}
Future<bool> saveLivePhotos(Task task, String livePhotosId) async {
final records = await _downloadRepository.getLiveVideoTasks();
if (records.length < 2) {
return false;
}
final imageRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.image);
final videoRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.video);
final imageFilePath = await imageRecord.task.filePath();
final videoFilePath = await videoRecord.task.filePath();
try {
final result = await _fileMediaRepository.saveLivePhoto(
image: File(imageFilePath),
video: File(videoFilePath),
title: task.filename,
);
return result != null;
} on PlatformException catch (error, stack) {
// Handle saving MotionPhotos on iOS
if (error.code == 'PHPhotosErrorDomain (-1)') {
final result = await _fileMediaRepository.saveImageWithFile(imageFilePath, title: task.filename);
return result != null;
}
_log.severe("Error saving live photo", error, stack);
return false;
} catch (error, stack) {
_log.severe("Error saving live photo", error, stack);
return false;
} finally {
final imageFile = File(imageFilePath);
if (await imageFile.exists()) {
await imageFile.delete();
}
final videoFile = File(videoFilePath);
if (await videoFile.exists()) {
await videoFile.delete();
}
await _downloadRepository.deleteRecordsWithIds([imageRecord.task.taskId, videoRecord.task.taskId]);
}
}
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) {
return records.firstWhere((record) {
final metadata = LivePhotosMetadata.fromJson(record.task.metaData);
return metadata.id == livePhotosId && metadata.part == part;
});
}

View file

@ -0,0 +1,44 @@
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)),
);

View file

@ -0,0 +1,14 @@
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();
}
}

View file

@ -0,0 +1,15 @@
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();
}
}

View file

@ -0,0 +1,101 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/models/folder/recursive_folder.model.dart';
import 'package:immich_mobile/models/folder/root_folder.model.dart';
import 'package:immich_mobile/repositories/folder_api.repository.dart';
import 'package:logging/logging.dart';
final folderServiceProvider = Provider((ref) => FolderService(ref.watch(folderApiRepositoryProvider)));
class FolderService {
final FolderApiRepository _folderApiRepository;
final Logger _log = Logger("FolderService");
FolderService(this._folderApiRepository);
Future<RootFolder> getFolderStructure(SortOrder order) async {
final paths = await _folderApiRepository.getAllUniquePaths();
// Create folder structure
Map<String, List<RecursiveFolder>> folderMap = {};
for (String fullPath in paths) {
if (fullPath == '/') continue;
// Ensure the path starts with a slash
if (!fullPath.startsWith('/')) {
fullPath = '/$fullPath';
}
List<String> segments = fullPath.split('/')..removeWhere((s) => s.isEmpty);
String currentPath = '';
for (int i = 0; i < segments.length; i++) {
String parentPath = currentPath.isEmpty ? '_root_' : currentPath;
currentPath = i == 0 ? '/${segments[i]}' : '$currentPath/${segments[i]}';
if (!folderMap.containsKey(parentPath)) {
folderMap[parentPath] = [];
}
if (!folderMap[parentPath]!.any((f) => f.name == segments[i])) {
folderMap[parentPath]!.add(
RecursiveFolder(path: parentPath == '_root_' ? '' : parentPath, name: segments[i], subfolders: []),
);
// Sort folders based on order parameter
folderMap[parentPath]!.sort(
(a, b) => order == SortOrder.desc ? b.name.compareTo(a.name) : a.name.compareTo(b.name),
);
}
}
}
void attachSubfolders(RecursiveFolder folder) {
String fullPath = folder.path.isEmpty ? '/${folder.name}' : '${folder.path}/${folder.name}';
if (folderMap.containsKey(fullPath)) {
folder.subfolders.addAll(folderMap[fullPath]!);
// Sort subfolders based on order parameter
folder.subfolders.sort((a, b) => order == SortOrder.desc ? b.name.compareTo(a.name) : a.name.compareTo(b.name));
for (var subfolder in folder.subfolders) {
attachSubfolders(subfolder);
}
}
}
List<RecursiveFolder> rootSubfolders = folderMap['_root_'] ?? [];
// Sort root subfolders based on order parameter
rootSubfolders.sort((a, b) => order == SortOrder.desc ? b.name.compareTo(a.name) : a.name.compareTo(b.name));
for (var folder in rootSubfolders) {
attachSubfolders(folder);
}
return RootFolder(subfolders: rootSubfolders, path: '/');
}
Future<List<Asset>> getFolderAssets(RootFolder folder, SortOrder order) async {
try {
if (folder is RecursiveFolder) {
String fullPath = folder.path.isEmpty ? folder.name : '${folder.path}/${folder.name}';
fullPath = fullPath[0] == '/' ? fullPath.substring(1) : fullPath;
var result = await _folderApiRepository.getAssetsForPath(fullPath);
if (order == SortOrder.desc) {
result.sort((a, b) => b.fileCreatedAt.compareTo(a.fileCreatedAt));
} else {
result.sort((a, b) => a.fileCreatedAt.compareTo(b.fileCreatedAt));
}
return result;
}
final result = await _folderApiRepository.getAssetsForPath('/');
return result;
} catch (e, stack) {
_log.severe("Failed to fetch assets for folder ${folder is RecursiveFolder ? folder.name : "root"}", e, stack);
return [];
}
}
}

View file

@ -0,0 +1,493 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.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:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
/// Callbacks for upload progress and status updates
class UploadCallbacks {
final void Function(String id, String filename, int bytes, int totalBytes)? onProgress;
final void Function(String localId, String remoteId)? onSuccess;
final void Function(String id, String errorMessage)? onError;
final void Function(String id, double progress)? onICloudProgress;
const UploadCallbacks({this.onProgress, this.onSuccess, this.onError, this.onICloudProgress});
}
final foregroundUploadServiceProvider = Provider((ref) {
return ForegroundUploadService(
ref.watch(uploadRepositoryProvider),
ref.watch(storageRepositoryProvider),
ref.watch(backupRepositoryProvider),
ref.watch(connectivityApiProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(assetMediaRepositoryProvider),
);
});
/// Service for handling foreground HTTP uploads
///
/// This service handles synchronous uploads using HTTP client with
/// concurrent worker pools. Used for manual backups, auto backups
/// (foreground mode), and share intent uploads.
class ForegroundUploadService {
ForegroundUploadService(
this._uploadRepository,
this._storageRepository,
this._backupRepository,
this._connectivityApi,
this._appSettingsService,
this._assetMediaRepository,
);
final UploadRepository _uploadRepository;
final StorageRepository _storageRepository;
final DriftBackupRepository _backupRepository;
final ConnectivityApi _connectivityApi;
final AppSettingsService _appSettingsService;
final AssetMediaRepository _assetMediaRepository;
final Logger _logger = Logger('ForegroundUploadService');
bool shouldAbortUpload = false;
Future<({int total, int remainder, int processing})> getBackupCounts(String userId) {
return _backupRepository.getAllCounts(userId);
}
Future<List<LocalAsset>> getBackupCandidates(String userId, {bool onlyHashed = true}) {
return _backupRepository.getCandidates(userId, onlyHashed: onlyHashed);
}
/// Bulk upload of backup candidates from selected albums
Future<void> uploadCandidates(
String userId,
CancellationToken cancelToken, {
UploadCallbacks callbacks = const UploadCallbacks(),
bool useSequentialUpload = false,
}) async {
final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) {
return;
}
final networkCapabilities = await _connectivityApi.getCapabilities();
final hasWifi = networkCapabilities.isUnmetered;
_logger.info('Network capabilities: $networkCapabilities, hasWifi/isUnmetered: $hasWifi');
if (useSequentialUpload) {
await _uploadSequentially(items: candidates, cancelToken: cancelToken, hasWifi: hasWifi, callbacks: callbacks);
} else {
await _executeWithWorkerPool<LocalAsset>(
items: candidates,
cancelToken: cancelToken,
shouldSkip: (asset) {
final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi;
},
processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks),
);
}
}
/// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues
Future<void> _uploadSequentially({
required List<LocalAsset> items,
required CancellationToken cancelToken,
required bool hasWifi,
required UploadCallbacks callbacks,
}) async {
final httpClient = Client();
await _storageRepository.clearCache();
shouldAbortUpload = false;
try {
for (final asset in items) {
if (shouldAbortUpload || cancelToken.isCancelled) {
break;
}
final requireWifi = _shouldRequireWiFi(asset);
if (requireWifi && !hasWifi) {
_logger.warning('Skipping upload for ${asset.id} because it requires WiFi');
continue;
}
await _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks);
}
} finally {
httpClient.close();
}
}
/// Manually upload picked local assets
Future<void> uploadManual(
List<LocalAsset> localAssets,
CancellationToken cancelToken, {
UploadCallbacks callbacks = const UploadCallbacks(),
}) async {
if (localAssets.isEmpty) {
return;
}
await _executeWithWorkerPool<LocalAsset>(
items: localAssets,
cancelToken: cancelToken,
processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks),
);
}
/// Upload files from shared intent
Future<void> uploadShareIntent(
List<File> files, {
CancellationToken? cancelToken,
void Function(String fileId, int bytes, int totalBytes)? onProgress,
void Function(String fileId)? onSuccess,
void Function(String fileId, String errorMessage)? onError,
}) async {
if (files.isEmpty) {
return;
}
final effectiveCancelToken = cancelToken ?? CancellationToken();
await _executeWithWorkerPool<File>(
items: files,
cancelToken: effectiveCancelToken,
processItem: (file, httpClient) async {
final fileId = p.hash(file.path).toString();
final result = await _uploadSingleFile(
file,
deviceAssetId: fileId,
httpClient: httpClient,
cancelToken: effectiveCancelToken,
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
);
if (result.isSuccess) {
onSuccess?.call(fileId);
} else if (!result.isCancelled && result.errorMessage != null) {
onError?.call(fileId, result.errorMessage!);
}
},
);
}
void cancel() {
shouldAbortUpload = true;
}
/// Generic worker pool for concurrent uploads
///
/// [items] - List of items to process
/// [cancelToken] - Token to cancel the operation
/// [processItem] - Function to process each item with an HTTP client
/// [shouldSkip] - Optional function to skip items (e.g., WiFi requirement check)
/// [concurrentWorkers] - Number of concurrent workers (default: 3)
Future<void> _executeWithWorkerPool<T>({
required List<T> items,
required CancellationToken cancelToken,
required Future<void> Function(T item, Client httpClient) processItem,
bool Function(T item)? shouldSkip,
int concurrentWorkers = 3,
}) async {
final httpClients = List.generate(concurrentWorkers, (_) => Client());
await _storageRepository.clearCache();
shouldAbortUpload = false;
try {
int currentIndex = 0;
Future<void> worker(Client httpClient) async {
while (true) {
if (shouldAbortUpload || cancelToken.isCancelled) {
break;
}
final index = currentIndex;
if (index >= items.length) {
break;
}
currentIndex++;
final item = items[index];
if (shouldSkip?.call(item) ?? false) {
continue;
}
await processItem(item, httpClient);
}
}
final workerFutures = <Future<void>>[];
for (int i = 0; i < concurrentWorkers; i++) {
workerFutures.add(worker(httpClients[i]));
}
await Future.wait(workerFutures);
} finally {
for (final client in httpClients) {
client.close();
}
}
}
Future<void> _uploadSingleAsset(
LocalAsset asset,
Client httpClient,
CancellationToken cancelToken, {
required UploadCallbacks callbacks,
}) async {
File? file;
File? livePhotoFile;
try {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
callbacks.onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
return;
}
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
if (!isAvailableLocally && CurrentPlatform.isIOS) {
_logger.info("Loading iCloud asset ${asset.id} - ${asset.name}");
// Create progress handler for iCloud download
PMProgressHandler? progressHandler;
StreamSubscription? progressSubscription;
progressHandler = PMProgressHandler();
progressSubscription = progressHandler.stream.listen((event) {
callbacks.onICloudProgress?.call(asset.localId!, event.progress);
});
try {
file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler);
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.loadMotionFileFromCloud(
asset.id,
progressHandler: progressHandler,
);
}
} finally {
await progressSubscription.cancel();
}
} else {
// Get files locally
file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
callbacks.onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
return;
}
// For live photos, get the motion video file
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
if (livePhotoFile == null) {
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
callbacks.onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
}
}
}
if (file == null) {
_logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}");
callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t());
return;
}
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
/// Handle special file name from DJI or Fusion app
/// If the file name has no extension, likely due to special renaming template by specific apps
/// we append the original extension from the asset name
final hasExtension = p.extension(fileName).isNotEmpty;
if (!hasExtension) {
fileName = p.setExtension(fileName, p.extension(asset.name));
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId);
final headers = ApiService.getRequestHeaders();
final fields = {
'deviceAssetId': asset.localId!,
'deviceId': deviceId,
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
'isFavorite': asset.isFavorite.toString(),
'duration': asset.duration.toString(),
};
// Upload live photo video first if available
String? livePhotoVideoId;
if (entity.isLivePhoto && livePhotoFile != null) {
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile,
originalFileName: livePhotoTitle,
headers: headers,
fields: fields,
httpClient: httpClient,
cancelToken: cancelToken,
onProgress: (bytes, totalBytes) =>
callbacks.onProgress?.call(asset.localId!, livePhotoTitle, bytes, totalBytes),
logContext: 'livePhotoVideo[${asset.localId}]',
);
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) {
livePhotoVideoId = livePhotoResult.remoteAssetId;
}
}
if (livePhotoVideoId != null) {
fields['livePhotoVideoId'] = livePhotoVideoId;
}
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
if (CurrentPlatform.isIOS && asset.cloudId != null) {
fields['metadata'] = jsonEncode([
RemoteAssetMetadataItem(
key: RemoteAssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: asset.cloudId,
createdAt: asset.createdAt.toIso8601String(),
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
),
),
]);
}
final result = await _uploadRepository.uploadFile(
file: file,
originalFileName: originalFileName,
headers: headers,
fields: fields,
httpClient: httpClient,
cancelToken: cancelToken,
onProgress: (bytes, totalBytes) =>
callbacks.onProgress?.call(asset.localId!, originalFileName, bytes, totalBytes),
logContext: 'asset[${asset.localId}]',
);
if (result.isSuccess && result.remoteAssetId != null) {
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
} else if (result.isCancelled) {
_logger.warning(() => "Backup was cancelled by the user");
shouldAbortUpload = true;
} else if (result.errorMessage != null) {
_logger.severe(
() =>
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
);
callbacks.onError?.call(asset.localId!, result.errorMessage!);
if (result.errorMessage == "Quota has been exceeded!") {
shouldAbortUpload = true;
}
}
} catch (error, stackTrace) {
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace);
callbacks.onError?.call(asset.localId!, error.toString());
} finally {
if (Platform.isIOS) {
try {
await file?.delete();
await livePhotoFile?.delete();
} catch (error, stackTrace) {
_logger.severe(() => "ERROR deleting file: ${error.toString()}", stackTrace);
}
}
}
}
Future<UploadResult> _uploadSingleFile(
File file, {
required String deviceAssetId,
required Client httpClient,
required CancellationToken cancelToken,
void Function(int bytes, int totalBytes)? onProgress,
}) async {
try {
final stats = await file.stat();
final fileCreatedAt = stats.changed;
final fileModifiedAt = stats.modified;
final filename = p.basename(file.path);
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
final fields = {
'deviceAssetId': deviceAssetId,
'deviceId': deviceId,
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
'isFavorite': 'false',
'duration': '0',
};
return await _uploadRepository.uploadFile(
file: file,
originalFileName: filename,
headers: headers,
fields: fields,
httpClient: httpClient,
cancelToken: cancelToken,
onProgress: onProgress ?? (_, __) {},
logContext: 'shareIntent[$deviceAssetId]',
);
} catch (e) {
return UploadResult.error(errorMessage: e.toString());
}
}
bool _shouldRequireWiFi(LocalAsset asset) {
bool requiresWiFi = true;
if (asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)) {
requiresWiFi = false;
} else if (!asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)) {
requiresWiFi = false;
}
return requiresWiFi;
}
}

View file

@ -0,0 +1,250 @@
import 'dart:async';
import 'package:cast/session.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/models/sessions/session_create_response.model.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/gcast.repository.dart';
import 'package:immich_mobile/repositories/sessions_api.repository.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
// ignore: import_rule_openapi, we are only using the AssetMediaSize enum
import 'package:openapi/api.dart';
final gCastServiceProvider = Provider(
(ref) => GCastService(
ref.watch(gCastRepositoryProvider),
ref.watch(sessionsAPIRepositoryProvider),
ref.watch(assetApiRepositoryProvider),
),
);
class GCastService {
final GCastRepository _gCastRepository;
final SessionsAPIRepository _sessionsApiService;
final AssetApiRepository _assetApiRepository;
SessionCreateResponse? sessionKey;
String? currentAssetId;
bool isConnected = false;
int? _sessionId;
Timer? _mediaStatusPollingTimer;
void Function(bool)? onConnectionState;
void Function(Duration)? onCurrentTime;
void Function(Duration)? onDuration;
void Function(String)? onReceiverName;
void Function(CastState)? onCastState;
GCastService(this._gCastRepository, this._sessionsApiService, this._assetApiRepository) {
_gCastRepository.onCastStatus = _onCastStatusCallback;
_gCastRepository.onCastMessage = _onCastMessageCallback;
}
void _onCastStatusCallback(CastSessionState state) {
if (state == CastSessionState.connected) {
onConnectionState?.call(true);
isConnected = true;
} else if (state == CastSessionState.closed) {
onConnectionState?.call(false);
isConnected = false;
onReceiverName?.call("");
currentAssetId = null;
}
}
void _onCastMessageCallback(Map<String, dynamic> message) {
switch (message['type']) {
case "MEDIA_STATUS":
_handleMediaStatus(message);
break;
}
}
void _handleMediaStatus(Map<String, dynamic> message) {
final statusList = (message['status'] as List).whereType<Map<String, dynamic>>().toList();
if (statusList.isEmpty) {
return;
}
final status = statusList[0];
switch (status['playerState']) {
case "PLAYING":
onCastState?.call(CastState.playing);
break;
case "PAUSED":
onCastState?.call(CastState.paused);
break;
case "BUFFERING":
onCastState?.call(CastState.buffering);
break;
case "IDLE":
onCastState?.call(CastState.idle);
// stop polling for media status if the video finished playing
if (status["idleReason"] == "FINISHED") {
_mediaStatusPollingTimer?.cancel();
}
break;
}
if (status["media"] != null && status["media"]["duration"] != null) {
final duration = Duration(milliseconds: (status["media"]["duration"] * 1000 ?? 0).toInt());
onDuration?.call(duration);
}
if (status["mediaSessionId"] != null) {
_sessionId = status["mediaSessionId"];
}
if (status["currentTime"] != null) {
final currentTime = Duration(milliseconds: (status["currentTime"] * 1000 ?? 0).toInt());
onCurrentTime?.call(currentTime);
}
}
Future<void> connect(dynamic device) async {
await _gCastRepository.connect(device);
onReceiverName?.call(device.extras["fn"] ?? "Google Cast");
}
CastDestinationType getType() {
return CastDestinationType.googleCast;
}
Future<bool> initialize() async {
// there is nothing blocking us from using Google Cast that we can check for
return true;
}
Future<void> disconnect() async {
onReceiverName?.call("");
currentAssetId = null;
await _gCastRepository.disconnect();
}
bool isSessionValid() {
// check if we already have a session token
// we should always have a expiration date
if (sessionKey == null || sessionKey?.expiresAt == null) {
return false;
}
final tokenExpiration = DateTime.parse(sessionKey!.expiresAt!);
// we want to make sure we have at least 10 seconds remaining in the session
// this is to account for network latency and other delays when sending the request
final bufferedExpiration = tokenExpiration.subtract(const Duration(seconds: 10));
return bufferedExpiration.isAfter(DateTime.now());
}
void loadMedia(RemoteAsset asset, bool reload) async {
if (!isConnected) {
return;
} else if (asset.id == currentAssetId && !reload) {
return;
}
// create a session key
if (!isSessionValid()) {
sessionKey = await _sessionsApiService.createSession(
"Cast",
"Google Cast",
duration: const Duration(minutes: 15).inSeconds,
);
}
final unauthenticatedUrl = asset.isVideo
? getPlaybackUrlForRemoteId(asset.id)
: getThumbnailUrlForRemoteId(asset.id, type: AssetMediaSize.fullsize);
final authenticatedURL = "$unauthenticatedUrl&sessionKey=${sessionKey?.token}";
// get image mime type
final mimeType = await _assetApiRepository.getAssetMIMEType(asset.id);
if (mimeType == null) {
return;
}
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "LOAD",
"media": {
"contentId": authenticatedURL,
"streamType": "BUFFERED",
"contentType": mimeType,
"contentUrl": authenticatedURL,
},
"autoplay": true,
});
currentAssetId = asset.id;
// we need to poll for media status since the cast device does not
// send a message when the media is loaded for whatever reason
// only do this on videos
_mediaStatusPollingTimer?.cancel();
if (asset.isVideo) {
_mediaStatusPollingTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) {
if (isConnected) {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "GET_STATUS",
"mediaSessionId": _sessionId,
});
} else {
timer.cancel();
}
});
}
}
void play() {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "PLAY", "mediaSessionId": _sessionId});
}
void pause() {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "PAUSE", "mediaSessionId": _sessionId});
}
void seekTo(Duration position) {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "SEEK",
"mediaSessionId": _sessionId,
"currentTime": position.inSeconds,
});
}
void stop() {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "STOP", "mediaSessionId": _sessionId});
_mediaStatusPollingTimer?.cancel();
currentAssetId = null;
}
// 0x01 is display capability bitmask
bool isDisplay(int ca) => (ca & 0x01) != 0;
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
final dests = await _gCastRepository.listDestinations();
return dests
.map((device) => (device.extras["fn"] ?? "Google Cast", CastDestinationType.googleCast, device))
.where((device) {
final caString = device.$3.extras["ca"];
final caNumber = int.tryParse(caString ?? "0") ?? 0;
return isDisplay(caNumber);
})
.toList(growable: false);
}
}

View file

@ -0,0 +1,191 @@
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),
),
);

View file

@ -0,0 +1,51 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
/// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
/// The logs are written to the database and onto console, using `debugPrint` method.
///
/// The logs are deleted when exceeding the `maxLogEntries` (default 500) property
/// in the class.
///
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
/// and generate a csv file.
abstract final class ImmichLogger {
const ImmichLogger();
static Future<void> shareLogs(BuildContext context) async {
final tempDir = await getTemporaryDirectory();
final dateTime = DateTime.now().toIso8601String();
final filePath = '${tempDir.path}/Immich_log_$dateTime.log';
final logFile = await File(filePath).create();
final io = logFile.openWrite();
try {
// Write messages
for (final m in await LogService.I.getMessages()) {
final created = m.createdAt;
final level = m.level.name.padRight(8);
final logger = (m.logger ?? "<UNKNOWN_LOGGER>").padRight(20);
final message = m.message;
final error = m.error == null ? "" : " ${m.error} |";
final stack = m.stack == null ? "" : "\n${m.stack!}";
io.write('$created | $level | $logger | $message |$error$stack\n');
}
} finally {
await io.flush();
await io.close();
}
final box = context.findRenderObject() as RenderBox?;
// Share file
await Share.shareXFiles(
[XFile(filePath)],
subject: "Immich logs $dateTime",
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
).then((value) => logFile.delete());
}
}

View file

@ -0,0 +1,19 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/auth/biometric_status.model.dart';
import 'package:immich_mobile/repositories/biometric.repository.dart';
final localAuthServiceProvider = Provider((ref) => LocalAuthService(ref.watch(biometricRepositoryProvider)));
class LocalAuthService {
final BiometricRepository _biometricRepository;
const LocalAuthService(this._biometricRepository);
Future<BiometricStatus> getStatus() {
return _biometricRepository.getStatus();
}
Future<bool> authenticate([String? message]) async {
return _biometricRepository.authenticate(message);
}
}

View file

@ -0,0 +1,66 @@
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
final localFileManagerServiceProvider = Provider<LocalFilesManagerService>((ref) => const LocalFilesManagerService());
class LocalFilesManagerService {
const LocalFilesManagerService();
static final Logger _logger = Logger('LocalFilesManager');
static const MethodChannel _channel = MethodChannel('file_trash');
Future<bool> moveToTrash(List<String> mediaUrls) async {
try {
return await _channel.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
} catch (e, s) {
_logger.warning('Error moving file to trash', e, s);
return false;
}
}
Future<bool> restoreFromTrash(String fileName, int type) async {
try {
return await _channel.invokeMethod('restoreFromTrash', {'fileName': fileName, 'type': type});
} catch (e, s) {
_logger.warning('Error restore file from trash', e, s);
return false;
}
}
Future<bool> restoreFromTrashById(String mediaId, int type) async {
try {
return await _channel.invokeMethod('restoreFromTrash', {'mediaId': mediaId, 'type': type});
} catch (e, s) {
_logger.warning('Error restore file from trash by Id', e, s);
return false;
}
}
Future<bool> requestManageMediaPermission() async {
try {
return await _channel.invokeMethod('requestManageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission', e, s);
return false;
}
}
Future<bool> hasManageMediaPermission() async {
try {
return await _channel.invokeMethod('hasManageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission state', e, s);
return false;
}
}
Future<bool> manageMediaPermission() async {
try {
return await _channel.invokeMethod('manageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission settings', e, s);
return false;
}
}
}

View file

@ -0,0 +1,118 @@
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();
}
}
}
}

View file

@ -0,0 +1,31 @@
// ignore_for_file: implementation_imports
import 'package:easy_localization/src/easy_localization_controller.dart';
import 'package:easy_localization/src/localization.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/utils/debug_print.dart';
/// Workaround to manually load translations in another Isolate
Future<bool> loadTranslations() async {
await EasyLocalizationController.initEasyLocation();
final controller = EasyLocalizationController(
supportedLocales: locales.values.toList(),
useFallbackTranslations: true,
saveLocale: true,
assetLoader: const CodegenLoader(),
path: translationsPath,
useOnlyLangCode: false,
onLoadError: (e) => dPrint(() => e.toString()),
fallbackLocale: locales.values.first,
);
await controller.loadTranslations();
return Localization.load(
controller.locale,
translations: controller.translations,
fallbackTranslations: controller.fallbackTranslations,
);
}

View file

@ -0,0 +1,45 @@
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:logging/logging.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class MapService with ErrorLoggerMixin {
final ApiService _apiService;
@override
final logger = Logger("MapService");
MapService(this._apiService) {
_setMapUserAgentHeader();
}
Future<void> _setMapUserAgentHeader() async {
final userAgent = await getUserAgentString();
await setHttpHeaders({'User-Agent': userAgent});
}
Future<Iterable<MapMarker>> getMapMarkers({
bool? isFavorite,
bool? withArchived,
bool? withPartners,
DateTime? fileCreatedAfter,
DateTime? fileCreatedBefore,
}) async {
return logError(
() async {
final markers = await _apiService.mapApi.getMapMarkers(
isFavorite: isFavorite,
isArchived: withArchived,
withPartners: withPartners,
fileCreatedAfter: fileCreatedAfter,
fileCreatedBefore: fileCreatedBefore,
);
return markers?.map(MapMarker.fromDto) ?? [];
},
defaultValue: [],
errorMessage: "Failed to get map markers",
);
}
}

View file

@ -0,0 +1,71 @@
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;
}
}
}

View file

@ -0,0 +1,43 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/repositories/network.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
final networkServiceProvider = Provider((ref) {
return NetworkService(ref.watch(networkRepositoryProvider), ref.watch(permissionRepositoryProvider));
});
class NetworkService {
final NetworkRepository _repository;
final IPermissionRepository _permissionRepository;
const NetworkService(this._repository, this._permissionRepository);
Future<bool> getLocationWhenInUserPermission() {
return _permissionRepository.hasLocationWhenInUsePermission();
}
Future<bool> requestLocationWhenInUsePermission() {
return _permissionRepository.requestLocationWhenInUsePermission();
}
Future<bool> getLocationAlwaysPermission() {
return _permissionRepository.hasLocationAlwaysPermission();
}
Future<bool> requestLocationAlwaysPermission() {
return _permissionRepository.requestLocationAlwaysPermission();
}
Future<String?> getWifiName() async {
final canRead = await getLocationWhenInUserPermission();
if (!canRead) {
return null;
}
return await _repository.getWifiName();
}
Future<bool> openSettings() {
return _permissionRepository.openSettings();
}
}

View file

@ -0,0 +1,43 @@
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
// Redirect URL = app.immich:///oauth-callback
class OAuthService {
final ApiService _apiService;
final callbackUrlScheme = 'app.immich';
final log = Logger('OAuthService');
OAuthService(this._apiService);
Future<String?> getOAuthServerUrl(String serverUrl, String state, String codeChallenge) async {
// Resolve API server endpoint from user provided serverUrl
await _apiService.resolveAndSetEndpoint(serverUrl);
final redirectUri = '$callbackUrlScheme:///oauth-callback';
log.info("Starting OAuth flow with redirect URI: $redirectUri");
final dto = await _apiService.oAuthApi.startOAuth(
OAuthConfigDto(redirectUri: redirectUri, state: state, codeChallenge: codeChallenge),
);
final authUrl = dto?.url;
log.info('Received Authorization URL: $authUrl');
return authUrl;
}
Future<LoginResponseDto?> oAuthLogin(String oauthUrl, String state, String codeVerifier) async {
String result = await FlutterWebAuth2.authenticate(url: oauthUrl, callbackUrlScheme: callbackUrlScheme);
log.info('Received OAuth callback: $result');
if (result.startsWith('app.immich:/oauth-callback')) {
result = result.replaceAll('app.immich:/oauth-callback', 'app.immich:///oauth-callback');
}
return await _apiService.oAuthApi.finishOAuth(
OAuthCallbackDto(url: result, state: state, codeVerifier: codeVerifier),
);
}
}

View file

@ -0,0 +1,73 @@
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;
}
}

View file

@ -0,0 +1,54 @@
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';
part 'person.service.g.dart';
@riverpod
PersonService personService(Ref ref) => PersonService(
ref.watch(personApiRepositoryProvider),
ref.watch(assetApiRepositoryProvider),
ref.read(assetRepositoryProvider),
);
class PersonService {
final Logger _log = Logger("PersonService");
final PersonApiRepository _personApiRepository;
final AssetApiRepository _assetApiRepository;
final AssetRepository _assetRepository;
PersonService(this._personApiRepository, this._assetApiRepository, this._assetRepository);
Future<List<PersonDto>> getAllPeople() async {
try {
return await _personApiRepository.getAll();
} catch (error, stack) {
_log.severe("Error while fetching curated people", error, stack);
return [];
}
}
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);
} catch (error, stack) {
_log.severe("Error while updating person name", error, stack);
}
return null;
}
}

View file

@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'person.service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$personServiceHash() => r'10883bccc6c402205e6785cf9ee6cd7142cd0983';
/// See also [personService].
@ProviderFor(personService)
final personServiceProvider = AutoDisposeProvider<PersonService>.internal(
personService,
name: r'personServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$personServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef PersonServiceRef = AutoDisposeProviderRef<PersonService>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -0,0 +1,86 @@
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: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),
),
);
class SearchService {
final ApiService _apiService;
final AssetRepository _assetRepository;
final SearchApiRepository _searchApiRepository;
final _log = Logger("SearchService");
SearchService(this._apiService, this._assetRepository, this._searchApiRepository);
Future<List<String>?> getSearchSuggestions(
SearchSuggestionType type, {
String? country,
String? state,
String? make,
String? model,
}) async {
try {
return await _searchApiRepository.getSearchSuggestions(
type,
country: country,
state: state,
make: make,
model: model,
);
} catch (e) {
dPrint(() => "[ERROR] [getSearchSuggestions] ${e.toString()}");
return [];
}
}
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();
} catch (error, stackTrace) {
_log.severe("Failed to getExploreData", error, stackTrace);
}
return null;
}
Future<List<AssetResponseDto>?> getAllPlaces() async {
try {
return await _apiService.searchApi.getAssetsByCity();
} catch (error, stackTrace) {
_log.severe("Failed to getAllPlaces", error, stackTrace);
}
return null;
}
}

View file

@ -0,0 +1,24 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/repositories/secure_storage.repository.dart';
final secureStorageServiceProvider = Provider(
(ref) => SecureStorageService(ref.watch(secureStorageRepositoryProvider)),
);
class SecureStorageService {
final SecureStorageRepository _secureStorageRepository;
const SecureStorageService(this._secureStorageRepository);
Future<void> write(String key, String value) async {
await _secureStorageRepository.write(key, value);
}
Future<void> delete(String key) async {
await _secureStorageRepository.delete(key);
}
Future<String?> read(String key) async {
return _secureStorageRepository.read(key);
}
}

View file

@ -0,0 +1,64 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/server_info/server_config.model.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/models/server_info/server_features.model.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final serverInfoServiceProvider = Provider((ref) => ServerInfoService(ref.watch(apiServiceProvider)));
class ServerInfoService {
final ApiService _apiService;
const ServerInfoService(this._apiService);
Future<ServerDiskInfo?> getDiskInfo() async {
try {
final dto = await _apiService.serverInfoApi.getStorage();
if (dto != null) {
return ServerDiskInfo.fromDto(dto);
}
} catch (e) {
dPrint(() => "Error [getDiskInfo] ${e.toString()}");
}
return null;
}
Future<ServerVersion?> getServerVersion() async {
try {
final dto = await _apiService.serverInfoApi.getServerVersion();
if (dto != null) {
return ServerVersion.fromDto(dto);
}
} catch (e) {
dPrint(() => "Error [getServerVersion] ${e.toString()}");
}
return null;
}
Future<ServerFeatures?> getServerFeatures() async {
try {
final dto = await _apiService.serverInfoApi.getServerFeatures();
if (dto != null) {
return ServerFeatures.fromDto(dto);
}
} catch (e) {
dPrint(() => "Error [getServerFeatures] ${e.toString()}");
}
return null;
}
Future<ServerConfig?> getServerConfig() async {
try {
final dto = await _apiService.serverInfoApi.getServerConfig();
if (dto != null) {
return ServerConfig.fromDto(dto);
}
} catch (e) {
dPrint(() => "Error [getServerConfig] ${e.toString()}");
}
return null;
}
}

View file

@ -0,0 +1,75 @@
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:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'api.service.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;
}
}

View file

@ -0,0 +1,17 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/repositories/share_handler.repository.dart';
final shareIntentServiceProvider = Provider((ref) => ShareIntentService(ref.watch(shareHandlerRepositoryProvider)));
class ShareIntentService {
final ShareHandlerRepository shareHandlerRepository;
void Function(List<ShareIntentAttachment> attachments)? onSharedMedia;
ShareIntentService(this.shareHandlerRepository);
void init() {
shareHandlerRepository.onSharedMedia = onSharedMedia;
shareHandlerRepository.init();
}
}

View file

@ -0,0 +1,114 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final sharedLinkServiceProvider = Provider((ref) => SharedLinkService(ref.watch(apiServiceProvider)));
class SharedLinkService {
final ApiService _apiService;
final Logger _log = Logger("SharedLinkService");
SharedLinkService(this._apiService);
Future<AsyncValue<List<SharedLink>>> getAllSharedLinks() async {
try {
final list = await _apiService.sharedLinksApi.getAllSharedLinks();
return list != null ? AsyncData(list.map(SharedLink.fromDto).toList()) : const AsyncData([]);
} catch (e, stack) {
_log.severe("Failed to fetch shared links", e, stack);
return AsyncError(e, stack);
}
}
Future<void> deleteSharedLink(String id) async {
try {
return await _apiService.sharedLinksApi.removeSharedLink(id);
} catch (e) {
_log.severe("Failed to delete shared link id - $id", e);
}
}
Future<SharedLink?> createSharedLink({
required bool showMeta,
required bool allowDownload,
required bool allowUpload,
String? description,
String? password,
String? albumId,
List<String>? assetIds,
DateTime? expiresAt,
}) async {
try {
final type = albumId != null ? SharedLinkType.ALBUM : SharedLinkType.INDIVIDUAL;
SharedLinkCreateDto? dto;
if (type == SharedLinkType.ALBUM) {
dto = SharedLinkCreateDto(
type: type,
albumId: albumId,
showMetadata: showMeta,
allowDownload: allowDownload,
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
password: password,
);
} else if (assetIds != null) {
dto = SharedLinkCreateDto(
type: type,
showMetadata: showMeta,
allowDownload: allowDownload,
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
password: password,
assetIds: assetIds,
);
}
if (dto != null) {
final responseDto = await _apiService.sharedLinksApi.createSharedLink(dto);
if (responseDto != null) {
return SharedLink.fromDto(responseDto);
}
}
} catch (e) {
_log.severe("Failed to create shared link", e);
}
return null;
}
Future<SharedLink?> updateSharedLink(
String id, {
required bool? showMeta,
required bool? allowDownload,
required bool? allowUpload,
bool? changeExpiry = false,
String? description,
String? password,
DateTime? expiresAt,
}) async {
try {
final responseDto = await _apiService.sharedLinksApi.updateSharedLink(
id,
SharedLinkEditDto(
showMetadata: showMeta,
allowDownload: allowDownload,
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
password: password,
changeExpiryTime: changeExpiry,
),
);
if (responseDto != null) {
return SharedLink.fromDto(responseDto);
}
} catch (e) {
_log.severe("Failed to update shared link id - $id", e);
}
return null;
}
}

View file

@ -0,0 +1,64 @@
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)),
);

View file

@ -0,0 +1,945 @@
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);
}

View file

@ -0,0 +1,98 @@
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());
}
}

View file

@ -0,0 +1,75 @@
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);
}
}

View file

@ -0,0 +1,42 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/repositories/widget.repository.dart';
final widgetServiceProvider = Provider((ref) {
return WidgetService(ref.watch(widgetRepositoryProvider));
});
class WidgetService {
final WidgetRepository _repository;
const WidgetService(this._repository);
Future<void> writeCredentials(String serverURL, String sessionKey, String? customHeaders) async {
await _repository.setAppGroupId(appShareGroupId);
await _repository.saveData(kWidgetServerEndpoint, serverURL);
await _repository.saveData(kWidgetAuthToken, sessionKey);
if (customHeaders != null && customHeaders.isNotEmpty) {
await _repository.saveData(kWidgetCustomHeaders, customHeaders);
}
// wait 3 seconds to ensure the widget is updated, dont block
Future.delayed(const Duration(seconds: 3), refreshWidgets);
}
Future<void> clearCredentials() async {
await _repository.setAppGroupId(appShareGroupId);
await _repository.saveData(kWidgetServerEndpoint, "");
await _repository.saveData(kWidgetAuthToken, "");
await _repository.saveData(kWidgetCustomHeaders, "");
// wait 3 seconds to ensure the widget is updated, dont block
Future.delayed(const Duration(seconds: 3), refreshWidgets);
}
Future<void> refreshWidgets() async {
for (final (iOSName, androidName) in kWidgetNames) {
await _repository.refresh(iOSName, androidName);
}
}
}