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,119 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.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_asset.repository.dart';
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
class AssetService {
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
const AssetService({
required RemoteAssetRepository remoteAssetRepository,
required DriftLocalAssetRepository localAssetRepository,
}) : _remoteAssetRepository = remoteAssetRepository,
_localAssetRepository = localAssetRepository;
Future<BaseAsset?> getAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
return asset is LocalAsset ? _localAssetRepository.get(id) : _remoteAssetRepository.get(id);
}
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id);
}
Future<List<LocalAsset?>> getLocalAssetsByChecksum(String checksum) {
return _localAssetRepository.getByChecksum(checksum);
}
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
return _remoteAssetRepository.getByChecksum(checksum);
}
Future<RemoteAsset?> getRemoteAsset(String id) {
return _remoteAssetRepository.get(id);
}
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
if (asset.stackId == null) {
return const [];
}
final stack = await _remoteAssetRepository.getStackChildren(asset);
// Include the primary asset in the stack as the first item
return [asset, ...stack];
}
Future<ExifInfo?> getExif(BaseAsset asset) async {
if (!asset.hasRemote) {
return null;
}
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
return _remoteAssetRepository.getExif(id);
}
Future<double> getAspectRatio(BaseAsset asset) async {
final dimension = asset is LocalAsset
? await _getLocalAssetDimensions(asset)
: await _getRemoteAssetDimensions(asset as RemoteAsset);
if (dimension.width == null || dimension.height == null || dimension.height == 0) {
return 1.0;
}
return dimension.isFlipped ? dimension.height! / dimension.width! : dimension.width! / dimension.height!;
}
Future<_AssetVideoDimension> _getLocalAssetDimensions(LocalAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
int orientation = asset.orientation;
if (width == null || height == null) {
final fetched = await _localAssetRepository.get(asset.id);
width = fetched?.width?.toDouble();
height = fetched?.height?.toDouble();
orientation = fetched?.orientation ?? 0;
}
// On Android, local assets need orientation correction for 90°/270° rotations
// On iOS, the Photos framework pre-corrects dimensions
final isFlipped = CurrentPlatform.isAndroid && (orientation == 90 || orientation == 270);
return (width: width, height: height, isFlipped: isFlipped);
}
Future<_AssetVideoDimension> _getRemoteAssetDimensions(RemoteAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
if (width == null || height == null) {
final fetched = await _remoteAssetRepository.get(asset.id);
width = fetched?.width?.toDouble();
height = fetched?.height?.toDouble();
}
return (width: width, height: height, isFlipped: false);
}
Future<List<(String, String)>> getPlaces(String userId) {
return _remoteAssetRepository.getPlaces(userId);
}
Future<(int local, int remote)> getAssetCounts() async {
return (await _localAssetRepository.getCount(), await _remoteAssetRepository.getCount());
}
Future<int> getLocalHashedCount() {
return _localAssetRepository.getHashedCount();
}
Future<List<LocalAlbum>> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) {
return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
}
}

View file

@ -0,0 +1,311 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
import 'package:immich_mobile/providers/user.provider.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/localization.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
class BackgroundWorkerFgService {
final BackgroundWorkerFgHostApi _foregroundHostApi;
const BackgroundWorkerFgService(this._foregroundHostApi);
// TODO: Move this call to native side once old timeline is removed
Future<void> enable() => _foregroundHostApi.enable();
Future<void> saveNotificationMessage(String title, String body) =>
_foregroundHostApi.saveNotificationMessage(title, body);
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
BackgroundWorkerSettings(
minimumDelaySeconds:
minimumDelaySeconds ??
Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
requiresCharging:
requireCharging ??
Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
),
);
Future<void> disable() => _foregroundHostApi.disable();
}
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
ProviderContainer? _ref;
final Isar _isar;
final Drift _drift;
final DriftLogger _driftLogger;
final BackgroundWorkerBgHostApi _backgroundHostApi;
final CancellationToken _cancellationToken = CancellationToken();
final Logger _logger = Logger('BackgroundWorkerBgService');
bool _isCleanedUp = false;
BackgroundWorkerBgService({required Isar isar, required Drift drift, required DriftLogger driftLogger})
: _isar = isar,
_drift = drift,
_driftLogger = driftLogger,
_backgroundHostApi = BackgroundWorkerBgHostApi() {
_ref = ProviderContainer(
overrides: [
dbProvider.overrideWithValue(isar),
isarProvider.overrideWithValue(isar),
driftProvider.overrideWith(driftOverride(drift)),
],
);
BackgroundWorkerFlutterApi.setUp(this);
}
bool get _isBackupEnabled => _ref?.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup) ?? false;
Future<void> init() async {
try {
HttpSSLOptions.apply(applyNative: false);
await Future.wait(
[
loadTranslations(),
workerManagerPatch.init(dynamicSpawning: true),
_ref?.read(authServiceProvider).setOpenApiServiceEndpoint(),
// Initialize the file downloader
FileDownloader().configure(
globalConfig: [
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
(Config.holdingQueue, (6, 6, 3)),
// On Android, if files are larger than 256MB, run in foreground service
(Config.runInForegroundIfFileLargerThan, 256),
],
),
FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false),
FileDownloader().trackTasks(),
_ref?.read(fileMediaRepositoryProvider).enableBackgroundAccess(),
].nonNulls,
);
configureFileDownloaderNotifications();
// Notify the host that the background worker service has been initialized and is ready to use
unawaited(_backgroundHostApi.onInitialized());
} catch (error, stack) {
_logger.severe("Failed to initialize background worker", error, stack);
unawaited(_backgroundHostApi.close());
}
}
@override
Future<void> onAndroidUpload() async {
_logger.info('Android background processing started');
final sw = Stopwatch()..start();
try {
if (!await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6))) {
_logger.warning("Remote sync did not complete successfully, skipping backup");
return;
}
await _handleBackup();
} catch (error, stack) {
_logger.severe("Failed to complete Android background processing", error, stack);
} finally {
sw.stop();
_logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s");
await _cleanup();
}
}
@override
Future<void> onIosUpload(bool isRefresh, int? maxSeconds) async {
_logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s');
final sw = Stopwatch()..start();
try {
final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6);
if (!await _syncAssets(hashTimeout: timeout)) {
_logger.warning("Remote sync did not complete successfully, skipping backup");
return;
}
final backupFuture = _handleBackup();
if (maxSeconds != null) {
await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {});
} else {
await backupFuture;
}
} catch (error, stack) {
_logger.severe("Failed to complete iOS background upload", error, stack);
} finally {
sw.stop();
_logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s");
await _cleanup();
}
}
@override
Future<void> cancel() async {
_logger.warning("Background worker cancelled");
try {
await _cleanup();
} catch (error, stack) {
dPrint(() => 'Failed to cleanup background worker: $error with stack: $stack');
}
}
Future<void> _cleanup() async {
await runZonedGuarded(_handleCleanup, (error, stack) {
dPrint(() => "Error during background worker cleanup: $error, $stack");
});
}
Future<void> _handleCleanup() async {
// If ref is null, it means the service was never initialized properly
if (_isCleanedUp || _ref == null) {
return;
}
try {
_isCleanedUp = true;
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
await _drift.close();
await _driftLogger.close();
_ref?.dispose();
_ref = null;
_cancellationToken.cancel();
_logger.info("Cleaning up background worker");
final cleanupFutures = [
nativeSyncApi?.cancelHashing(),
workerManagerPatch.dispose().catchError((_) async {
// Discard any errors on the dispose call
return;
}),
LogService.I.dispose(),
Store.dispose(),
backgroundSyncManager?.cancel(),
];
if (_isar.isOpen) {
cleanupFutures.add(_isar.close());
}
await Future.wait(cleanupFutures.nonNulls);
_logger.info("Background worker resources cleaned up");
} catch (error, stack) {
dPrint(() => 'Failed to cleanup background worker: $error with stack: $stack');
}
}
Future<void> _handleBackup() async {
await runZonedGuarded(
() async {
if (_isCleanedUp) {
return;
}
if (!_isBackupEnabled) {
_logger.info("Backup is disabled. Skipping backup routine");
return;
}
final currentUser = _ref?.read(currentUserProvider);
if (currentUser == null) {
_logger.warning("No current user found. Skipping backup from background");
return;
}
if (Platform.isIOS) {
return _ref?.read(driftBackupProvider.notifier).startBackupWithURLSession(currentUser.id);
}
return _ref
?.read(foregroundUploadServiceProvider)
.uploadCandidates(currentUser.id, _cancellationToken, useSequentialUpload: true);
},
(error, stack) {
dPrint(() => "Error in backup zone $error, $stack");
},
);
}
Future<bool> _syncAssets({Duration? hashTimeout}) async {
await _ref?.read(backgroundSyncProvider).syncLocal();
if (_isCleanedUp) {
return false;
}
final isSuccess = await _ref?.read(backgroundSyncProvider).syncRemote() ?? false;
if (_isCleanedUp) {
return isSuccess;
}
var hashFuture = _ref?.read(backgroundSyncProvider).hashAssets();
if (hashTimeout != null && hashFuture != null) {
hashFuture = hashFuture.timeout(
hashTimeout,
onTimeout: () {
// Consume cancellation errors as we want to continue processing
},
);
}
await hashFuture;
return isSuccess;
}
}
class BackgroundWorkerLockService {
final BackgroundWorkerLockApi _hostApi;
const BackgroundWorkerLockService(this._hostApi);
Future<void> lock() async {
if (CurrentPlatform.isAndroid) {
return _hostApi.lock();
}
}
Future<void> unlock() async {
if (CurrentPlatform.isAndroid) {
return _hostApi.unlock();
}
}
}
/// Native entry invoked from the background worker. If renaming or moving this to a different
/// library, make sure to update the entry points and URI in native workers as well
@pragma('vm:entry-point')
Future<void> backgroundSyncNativeEntrypoint() async {
WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();
final (isar, drift, logDB) = await Bootstrap.initDB();
await Bootstrap.initDomain(isar, drift, logDB, shouldBufferLogs: false, listenStoreUpdates: false);
await BackgroundWorkerBgService(isar: isar, drift: drift, driftLogger: logDB).init();
}

View file

@ -0,0 +1,145 @@
import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:logging/logging.dart';
const String _kHashCancelledCode = "HASH_CANCELLED";
class HashService {
final int _batchSize;
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
final bool Function()? _cancelChecker;
final _log = Logger('HashService');
HashService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required NativeSyncApi nativeSyncApi,
bool Function()? cancelChecker,
int? batchSize,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_cancelChecker = cancelChecker,
_nativeSyncApi = nativeSyncApi,
_batchSize = batchSize ?? kBatchHashFileLimit;
bool get isCancelled => _cancelChecker?.call() ?? false;
Future<void> hashAssets() async {
_log.info("Starting hashing of assets");
final Stopwatch stopwatch = Stopwatch()..start();
try {
// Migrate hashes from cloud ID to local ID so we don't have to re-hash them
await _localAssetRepository.reconcileHashesFromCloudId();
// Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getBackupAlbums();
for (final album in localAlbums) {
if (isCancelled) {
_log.warning("Hashing cancelled. Stopped processing albums.");
break;
}
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
if (assetsToHash.isNotEmpty) {
await _hashAssets(album, assetsToHash);
}
}
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
final backupAlbumIds = localAlbums.map((e) => e.id);
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
if (trashedToHash.isNotEmpty) {
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
}
}
} on PlatformException catch (e) {
if (e.code == _kHashCancelledCode) {
_log.warning("Hashing cancelled by platform");
return;
}
} catch (e, s) {
_log.severe("Error during hashing", e, s);
}
stopwatch.stop();
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
}
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
/// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
final toHash = <String, LocalAsset>{};
for (final asset in assetsToHash) {
if (isCancelled) {
_log.warning("Hashing cancelled. Stopped processing assets.");
return;
}
toHash[asset.id] = asset;
if (toHash.length == _batchSize) {
await _processBatch(album, toHash, isTrashed);
toHash.clear();
}
}
await _processBatch(album, toHash, isTrashed);
}
/// Processes a batch of assets.
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
if (toHash.isEmpty) {
return;
}
_log.fine("Hashing ${toHash.length} files");
final hashed = <String, String>{};
final hashResults = await _nativeSyncApi.hashAssets(
toHash.keys.toList(),
allowNetworkAccess: album.backupSelection == BackupSelection.selected,
);
assert(
hashResults.length == toHash.length,
"Hashes length does not match toHash length: ${hashResults.length} != ${toHash.length}",
);
for (int i = 0; i < hashResults.length; i++) {
if (isCancelled) {
_log.warning("Hashing cancelled. Stopped processing batch.");
return;
}
final hashResult = hashResults[i];
if (hashResult.hash != null) {
hashed[hashResult.assetId] = hashResult.hash!;
} else {
final asset = toHash[hashResult.assetId];
_log.warning(
"Failed to hash asset with id: ${hashResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}, from album: ${album.name}. Error: ${hashResult.error ?? "unknown"}",
);
}
}
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
if (isTrashed) {
await _trashedLocalAssetRepository.updateHashes(hashed);
} else {
await _localAssetRepository.updateHashes(hashed);
}
}
}

View file

@ -0,0 +1,37 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
class LocalAlbumService {
final DriftLocalAlbumRepository _repository;
const LocalAlbumService(this._repository);
Future<List<LocalAlbum>> getAll({Set<SortLocalAlbumsBy> sortBy = const {}}) {
return _repository.getAll(sortBy: sortBy);
}
Future<LocalAsset?> getThumbnail(String albumId) {
return _repository.getThumbnail(albumId);
}
Future<void> update(LocalAlbum album) {
return _repository.upsert(album);
}
Future<int> getCount() {
return _repository.getCount();
}
Future<void> unlinkRemoteAlbum(String id) async {
return _repository.unlinkRemoteAlbum(id);
}
Future<void> linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async {
return _repository.linkRemoteAlbum(localAlbumId, remoteAlbumId);
}
Future<List<LocalAlbum>> getBackupAlbums() {
return _repository.getBackupAlbums();
}
}

View file

@ -0,0 +1,441 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/album/local_album.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/local_album.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/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_nativeSyncApi = nativeSyncApi;
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _syncTrashedAssets();
} else {
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
}
}
if (CurrentPlatform.isIOS) {
final assets = await _localAssetRepository.getEmptyCloudIdAssets();
await _mapIosCloudIds(assets);
}
if (full || await _nativeSyncApi.shouldFullSync()) {
_log.fine("Full sync request from ${full ? "user" : "native"}");
return await fullSync();
}
final delta = await _nativeSyncApi.getMediaChanges();
if (!delta.hasChanges) {
_log.fine("No media changes detected. Skipping sync");
return;
}
_log.fine("Delta updated: ${delta.updates.length}");
_log.fine("Delta deleted: ${delta.deletes.length}");
final deviceAlbums = await _nativeSyncApi.getAlbums();
await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums());
final newAssets = delta.updates.toLocalAssets();
await _localAlbumRepository.processDelta(
updates: newAssets,
deletes: delta.deletes,
assetAlbums: delta.assetAlbums,
);
final dbAlbums = await _localAlbumRepository.getAll();
// On Android, we need to sync all albums since it is not possible to
// detect album deletions from the native side
if (CurrentPlatform.isAndroid) {
for (final album in dbAlbums) {
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
await _localAlbumRepository.syncDeletes(album.id, deviceIds);
}
}
if (CurrentPlatform.isIOS) {
// On iOS, we need to full sync albums that are marked as cloud as the delta sync
// does not include changes for cloud albums. If ignoreIcloudAssets is enabled,
// remove the albums from the local database from the previous sync
final cloudAlbums = deviceAlbums.where((a) => a.isCloud).toLocalAlbums();
for (final album in cloudAlbums) {
final dbAlbum = dbAlbums.firstWhereOrNull((a) => a.id == album.id);
if (dbAlbum == null) {
_log.warning("Cloud album ${album.name} not found in local database. Skipping sync.");
continue;
}
await updateAlbum(dbAlbum, album);
}
await _mapIosCloudIds(newAssets);
}
await _nativeSyncApi.checkpointSync();
} catch (e, s) {
_log.severe("Error performing device sync", e, s);
} finally {
stopwatch.stop();
_log.info("Device sync took - ${stopwatch.elapsedMilliseconds}ms");
}
}
Future<void> fullSync() async {
try {
final Stopwatch stopwatch = Stopwatch()..start();
final deviceAlbums = await _nativeSyncApi.getAlbums();
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
await diffSortedLists(
dbAlbums,
deviceAlbums.toLocalAlbums(),
compare: (a, b) => a.id.compareTo(b.id),
both: updateAlbum,
onlyFirst: removeAlbum,
onlySecond: addAlbum,
);
await _nativeSyncApi.checkpointSync();
stopwatch.stop();
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
} catch (e, s) {
_log.severe("Error performing full device sync", e, s);
}
}
Future<void> addAlbum(LocalAlbum album) async {
try {
_log.fine("Adding device album ${album.name}");
final assets = album.assetCount > 0
? await _nativeSyncApi.getAssetsForAlbum(album.id).then((a) => a.toLocalAssets())
: <LocalAsset>[];
await _localAlbumRepository.upsert(album, toUpsert: assets);
await _mapIosCloudIds(assets);
_log.fine("Successfully added device album ${album.name}");
} catch (e, s) {
_log.warning("Error while adding device album", e, s);
}
}
Future<void> removeAlbum(LocalAlbum a) async {
_log.fine("Removing device album ${a.name}");
try {
// Asset deletion is handled in the repository
await _localAlbumRepository.delete(a.id);
} catch (e, s) {
_log.warning("Error while removing device album", e, s);
}
}
// The deviceAlbum is ignored since we are going to refresh it anyways
FutureOr<bool> updateAlbum(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
try {
_log.fine("Syncing device album ${dbAlbum.name}");
if (_albumsEqual(deviceAlbum, dbAlbum)) {
_log.fine("Device album ${dbAlbum.name} has not changed. Skipping sync.");
return false;
}
_log.fine("Device album ${dbAlbum.name} has changed. Syncing...");
// Faster path - only new assets added
if (await checkAddition(dbAlbum, deviceAlbum)) {
_log.fine("Fast synced device album ${dbAlbum.name}");
return true;
}
// Slower path - full sync
return await fullDiff(dbAlbum, deviceAlbum);
} catch (e, s) {
_log.warning("Error while diff device album", e, s);
}
return true;
}
@visibleForTesting
// The [deviceAlbum] is expected to be refreshed before calling this method
// with modified time and asset count
Future<bool> checkAddition(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
try {
_log.fine("Fast syncing device album ${dbAlbum.name}");
// Assets has been modified
if (deviceAlbum.assetCount <= dbAlbum.assetCount) {
_log.fine("Local album has modifications. Proceeding to full sync");
return false;
}
final updatedTime = (dbAlbum.updatedAt.millisecondsSinceEpoch ~/ 1000) + 1;
final newAssetsCount = await _nativeSyncApi.getAssetsCountSince(deviceAlbum.id, updatedTime);
// Early return if no new assets were found
if (newAssetsCount == 0) {
_log.fine("No new assets found despite album having changes. Proceeding to full sync for ${dbAlbum.name}");
return false;
}
// Check whether there is only addition or if there has been deletions
if (deviceAlbum.assetCount != dbAlbum.assetCount + newAssetsCount) {
_log.fine("Local album has modifications. Proceeding to full sync");
return false;
}
final newAssets = await _nativeSyncApi
.getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime)
.then((a) => a.toLocalAssets());
await _localAlbumRepository.upsert(
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
toUpsert: newAssets,
);
await _mapIosCloudIds(newAssets);
return true;
} catch (e, s) {
_log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s);
}
return false;
}
@visibleForTesting
// The [deviceAlbum] is expected to be refreshed before calling this method
// with modified time and asset count
Future<bool> fullDiff(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
try {
final assetsInDevice = deviceAlbum.assetCount > 0
? await _nativeSyncApi.getAssetsForAlbum(deviceAlbum.id).then((a) => a.toLocalAssets())
: <LocalAsset>[];
final assetsInDb = dbAlbum.assetCount > 0 ? await _localAlbumRepository.getAssets(dbAlbum.id) : <LocalAsset>[];
if (deviceAlbum.assetCount == 0) {
_log.fine("Device album ${deviceAlbum.name} is empty. Removing assets from DB.");
await _localAlbumRepository.upsert(
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
toDelete: assetsInDb.map((a) => a.id),
);
return true;
}
final updatedDeviceAlbum = deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection);
if (dbAlbum.assetCount == 0) {
_log.fine("Device album ${deviceAlbum.name} is empty. Adding assets to DB.");
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsInDevice);
await _mapIosCloudIds(assetsInDevice);
return true;
}
assert(assetsInDb.isSortedBy((a) => a.id));
assetsInDevice.sort((a, b) => a.id.compareTo(b.id));
final assetsToUpsert = <LocalAsset>[];
final assetsToDelete = <String>[];
diffSortedListsSync(
assetsInDb,
assetsInDevice,
compare: (a, b) => a.id.compareTo(b.id),
both: (dbAsset, deviceAsset) {
// Custom comparison to check if the asset has been modified without
// comparing the checksum
if (!_assetsEqual(dbAsset, deviceAsset)) {
assetsToUpsert.add(deviceAsset);
return true;
}
return false;
},
onlyFirst: (dbAsset) => assetsToDelete.add(dbAsset.id),
onlySecond: (deviceAsset) => assetsToUpsert.add(deviceAsset),
);
_log.fine(
"Syncing ${deviceAlbum.name}. ${assetsToUpsert.length} assets to add/update and ${assetsToDelete.length} assets to delete",
);
if (assetsToUpsert.isEmpty && assetsToDelete.isEmpty) {
_log.fine("No asset changes detected in album ${deviceAlbum.name}. Updating metadata.");
await _localAlbumRepository.upsert(updatedDeviceAlbum);
return true;
}
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsToUpsert, toDelete: assetsToDelete);
await _mapIosCloudIds(assetsToUpsert);
return true;
} catch (e, s) {
_log.warning("Error on full syncing local album: ${dbAlbum.name}", e, s);
}
return true;
}
Future<void> _mapIosCloudIds(List<LocalAsset> assets) async {
if (!CurrentPlatform.isIOS || assets.isEmpty) {
return;
}
final assetIds = assets.map((a) => a.id).toList();
final cloudMapping = <String, String>{};
final cloudIds = await _nativeSyncApi.getCloudIdForAssetIds(assetIds);
for (int i = 0; i < cloudIds.length; i++) {
final cloudIdResult = cloudIds[i];
if (cloudIdResult.cloudId != null) {
cloudMapping[cloudIdResult.assetId] = cloudIdResult.cloudId!;
} else {
final asset = assets.firstWhereOrNull((a) => a.id == cloudIdResult.assetId);
_log.fine(
"Cannot fetch cloudId for asset with id: ${cloudIdResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}. Error: ${cloudIdResult.error ?? "unknown"}",
);
}
}
await _localAlbumRepository.updateCloudMapping(cloudMapping);
}
bool _assetsEqual(LocalAsset a, LocalAsset b) {
if (CurrentPlatform.isAndroid) {
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
a.createdAt.isAtSameMomentAs(b.createdAt) &&
a.width == b.width &&
a.height == b.height &&
a.durationInSeconds == b.durationInSeconds;
}
final firstAdjustment = a.adjustmentTime?.millisecondsSinceEpoch ?? 0;
final secondAdjustment = b.adjustmentTime?.millisecondsSinceEpoch ?? 0;
return firstAdjustment == secondAdjustment &&
a.createdAt.isAtSameMomentAs(b.createdAt) &&
a.width == b.width &&
a.height == b.height &&
a.durationInSeconds == b.durationInSeconds &&
a.latitude == b.latitude &&
a.longitude == b.longitude;
}
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt);
}
Future<void> _syncTrashedAssets() async {
final trashedAssetMap = await _nativeSyncApi.getTrashedAssets();
await processTrashedAssets(trashedAssetMap);
}
@visibleForTesting
Future<void> processTrashedAssets(Map<String, List<PlatformAsset>> trashedAssetMap) async {
if (trashedAssetMap.isEmpty) {
_log.info("syncTrashedAssets, No trashed assets found");
}
final trashedAssets = trashedAssetMap.cast<String, List<Object?>>().entries.expand(
(entry) => entry.value.cast<PlatformAsset>().toTrashedAssets(entry.key),
);
_log.fine("syncTrashedAssets, trashedAssets: ${trashedAssets.map((e) => e.asset.id)}");
await _trashedLocalAssetRepository.processTrashSnapshot(trashedAssets);
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_log.info("syncTrashedAssets, No remote assets found for restoration");
}
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
if (localAssetsToTrash.isNotEmpty) {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
} else {
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
}
}
}
extension on Iterable<PlatformAlbum> {
List<LocalAlbum> toLocalAlbums() {
return map(
(e) => LocalAlbum(
id: e.id,
name: e.name,
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(),
assetCount: e.assetCount,
isIosSharedAlbum: e.isCloud,
),
).toList();
}
}
extension on Iterable<PlatformAsset> {
List<LocalAsset> toLocalAssets() {
return map((e) => e.toLocalAsset()).toList();
}
Iterable<TrashedAsset> toTrashedAssets(String albumId) {
return map((e) => (albumId: albumId, asset: e.toLocalAsset()));
}
}
extension PlatformToLocalAsset on PlatformAsset {
LocalAsset toLocalAsset() => LocalAsset(
id: id,
name: name,
checksum: null,
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
updatedAt: tryFromSecondsSinceEpoch(updatedAt, isUtc: true) ?? DateTime.timestamp(),
width: width,
height: height,
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
orientation: orientation,
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
latitude: latitude,
longitude: longitude,
isEdited: false,
);
}

View file

@ -0,0 +1,148 @@
import 'dart:async';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
/// Service responsible for handling application logging.
///
/// It listens to Dart's [Logger.root], buffers logs in memory (optionally),
/// writes them to a persistent [ILogRepository], and manages log levels
/// via [IStoreRepository]
class LogService {
final LogRepository _logRepository;
final IStoreRepository _storeRepository;
final List<LogMessage> _msgBuffer = [];
/// Whether to buffer logs in memory before writing to the database.
/// This is useful when logging in quick succession, as it increases performance
/// and reduces NAND wear. However, it may cause the logs to be lost in case of a crash / in isolates.
final bool _shouldBuffer;
Timer? _flushTimer;
late final StreamSubscription<LogRecord> _logSubscription;
static LogService? _instance;
static LogService get I {
if (_instance == null) {
throw const LoggerUnInitializedException();
}
return _instance!;
}
static Future<LogService> init({
required LogRepository logRepository,
required IStoreRepository storeRepository,
bool shouldBuffer = true,
}) async {
_instance ??= await create(
logRepository: logRepository,
storeRepository: storeRepository,
shouldBuffer: shouldBuffer,
);
return _instance!;
}
static Future<LogService> create({
required LogRepository logRepository,
required IStoreRepository storeRepository,
bool shouldBuffer = true,
}) async {
final instance = LogService._(logRepository, storeRepository, shouldBuffer);
await logRepository.truncate(limit: kLogTruncateLimit);
final level = await instance._storeRepository.tryGet(StoreKey.logLevel) ?? LogLevel.info.index;
Logger.root.level = Level.LEVELS.elementAtOrNull(level) ?? Level.INFO;
return instance;
}
LogService._(this._logRepository, this._storeRepository, this._shouldBuffer) {
_logSubscription = Logger.root.onRecord.listen(_handleLogRecord);
}
void _handleLogRecord(LogRecord r) {
dPrint(
() =>
'[${r.level.name}] [${r.time}] [${r.loggerName}] ${r.message}'
'${r.error == null ? '' : '\nError: ${r.error}'}'
'${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}',
);
final record = LogMessage(
message: r.message,
level: r.level.toLogLevel(),
createdAt: r.time,
logger: r.loggerName,
error: r.error?.toString(),
stack: r.stackTrace?.toString(),
);
if (_shouldBuffer) {
_msgBuffer.add(record);
_flushTimer ??= Timer(const Duration(seconds: 5), () => unawaited(_flushBuffer()));
} else {
unawaited(_logRepository.insert(record));
}
}
Future<void> setLogLevel(LogLevel level) async {
await _storeRepository.upsert(StoreKey.logLevel, level.index);
Logger.root.level = level.toLevel();
}
Future<List<LogMessage>> getMessages() async {
final logsFromDb = await _logRepository.getAll();
return [..._msgBuffer.reversed, ...logsFromDb];
}
Future<void> clearLogs() async {
_flushTimer?.cancel();
_flushTimer = null;
_msgBuffer.clear();
await _logRepository.deleteAll();
}
Future<void> flush() {
_flushTimer?.cancel();
return _flushBuffer();
}
Future<void> dispose() {
_flushTimer?.cancel();
_logSubscription.cancel();
return _flushBuffer();
}
Future<void> _flushBuffer() async {
_flushTimer = null;
final buffer = [..._msgBuffer];
_msgBuffer.clear();
if (buffer.isEmpty) {
return;
}
await _logRepository.insertAll(buffer);
}
}
class LoggerUnInitializedException implements Exception {
const LoggerUnInitializedException();
@override
String toString() => 'Logger is not initialized. Call init()';
}
/// Log levels according to dart logging [Level]
extension LevelDomainToInfraExtension on Level {
LogLevel toLogLevel() => LogLevel.values.elementAtOrNull(Level.LEVELS.indexOf(this)) ?? LogLevel.info;
}
extension on LogLevel {
Level toLevel() => Level.LEVELS.elementAtOrNull(index) ?? Level.INFO;
}

View file

@ -0,0 +1,25 @@
import 'package:immich_mobile/domain/models/map.model.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds);
typedef MapQuery = ({MapMarkerSource markerSource});
class MapFactory {
final DriftMapRepository _mapRepository;
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
MapService remote(List<String> ownerIds, TimelineMapOptions options) =>
MapService(_mapRepository.remote(ownerIds, options));
}
class MapService {
final MapMarkerSource _markerSource;
MapService(MapQuery query) : _markerSource = query.markerSource;
Future<List<Marker>> Function(LatLngBounds? bounds) get getMarkers => _markerSource;
}

View file

@ -0,0 +1,23 @@
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart';
import 'package:logging/logging.dart';
class DriftMemoryService {
final log = Logger("DriftMemoryService");
final DriftMemoryRepository _repository;
DriftMemoryService(this._repository);
Future<List<DriftMemory>> getMemoryLane(String ownerId) {
return _repository.getAll(ownerId);
}
Future<DriftMemory?> get(String memoryId) {
return _repository.get(memoryId);
}
Future<int> getCount() {
return _repository.getCount();
}
}

View file

@ -0,0 +1,51 @@
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class DriftPartnerService {
final DriftPartnerRepository _driftPartnerRepository;
final PartnerApiRepository _partnerApiRepository;
const DriftPartnerService(this._driftPartnerRepository, this._partnerApiRepository);
Future<List<PartnerUserDto>> getSharedWith(String userId) {
return _driftPartnerRepository.getSharedWith(userId);
}
Future<List<PartnerUserDto>> getSharedBy(String userId) {
return _driftPartnerRepository.getSharedBy(userId);
}
Future<List<PartnerUserDto>> getAvailablePartners(String currentUserId) async {
final otherUsers = await _driftPartnerRepository.getAvailablePartners(currentUserId);
final currentPartners = await _driftPartnerRepository.getSharedBy(currentUserId);
final available = otherUsers.where((user) {
return !currentPartners.any((partner) => partner.id == user.id);
}).toList();
return available;
}
Future<void> toggleShowInTimeline(String partnerId, String userId) async {
final partner = await _driftPartnerRepository.getPartner(partnerId, userId);
if (partner == null) {
dPrint(() => "Partner not found: $partnerId for user: $userId");
return;
}
await _partnerApiRepository.update(partnerId, inTimeline: !partner.inTimeline);
await _driftPartnerRepository.toggleShowInTimeline(partner, userId);
}
Future<void> addPartner(String partnerId, String userId) async {
await _partnerApiRepository.create(partnerId);
await _driftPartnerRepository.create(partnerId, userId);
}
Future<void> removePartner(String partnerId, String userId) async {
await _partnerApiRepository.delete(partnerId);
await _driftPartnerRepository.delete(partnerId, userId);
}
}

View file

@ -0,0 +1,30 @@
import 'dart:async';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/infrastructure/repositories/people.repository.dart';
import 'package:immich_mobile/repositories/person_api.repository.dart';
class DriftPeopleService {
final DriftPeopleRepository _repository;
final PersonApiRepository _personApiRepository;
const DriftPeopleService(this._repository, this._personApiRepository);
Future<List<DriftPerson>> getAssetPeople(String assetId) {
return _repository.getAssetPeople(assetId);
}
Future<List<DriftPerson>> getAllPeople() {
return _repository.getAllPeople();
}
Future<int> updateName(String personId, String name) async {
await _personApiRepository.update(personId, name: name);
return _repository.updateName(personId, name);
}
Future<int> updateBrithday(String personId, DateTime birthday) async {
await _personApiRepository.update(personId, birthday: birthday);
return _repository.updateBirthday(personId, birthday);
}
}

View file

@ -0,0 +1,214 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
class RemoteAlbumService {
final DriftRemoteAlbumRepository _repository;
final DriftAlbumApiRepository _albumApiRepository;
const RemoteAlbumService(this._repository, this._albumApiRepository);
Stream<RemoteAlbum?> watchAlbum(String albumId) {
return _repository.watchAlbum(albumId);
}
Future<List<RemoteAlbum>> getAll() {
return _repository.getAll();
}
Future<RemoteAlbum?> get(String albumId) {
return _repository.get(albumId);
}
Future<RemoteAlbum?> getByName(String albumName, String ownerId) {
return _repository.getByName(albumName, ownerId);
}
Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums,
AlbumSortMode sortMode, {
bool isReverse = false,
}) async {
final List<RemoteAlbum> sorted = switch (sortMode) {
AlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
AlbumSortMode.title => albums.sortedBy((album) => album.name),
AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
};
return (isReverse ? sorted.reversed : sorted).toList();
}
List<RemoteAlbum> searchAlbums(
List<RemoteAlbum> albums,
String query,
String? userId, [
QuickFilterMode filterMode = QuickFilterMode.all,
]) {
final lowerQuery = query.toLowerCase();
List<RemoteAlbum> filtered = albums;
// Apply text search filter
if (query.isNotEmpty) {
filtered = filtered
.where(
(album) =>
album.name.toLowerCase().contains(lowerQuery) || album.description.toLowerCase().contains(lowerQuery),
)
.toList();
}
if (userId != null) {
switch (filterMode) {
case QuickFilterMode.myAlbums:
filtered = filtered.where((album) => album.ownerId == userId).toList();
break;
case QuickFilterMode.sharedWithMe:
filtered = filtered.where((album) => album.ownerId != userId).toList();
break;
case QuickFilterMode.all:
break;
}
}
return filtered;
}
Future<RemoteAlbum> createAlbum({required String title, required List<String> assetIds, String? description}) async {
final album = await _albumApiRepository.createDriftAlbum(title, description: description, assetIds: assetIds);
await _repository.create(album, assetIds);
return album;
}
Future<RemoteAlbum> updateAlbum(
String albumId, {
String? name,
String? description,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
}) async {
final updatedAlbum = await _albumApiRepository.updateAlbum(
albumId,
name: name,
description: description,
thumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order,
);
// Update the local database
await _repository.update(updatedAlbum);
return updatedAlbum;
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
return _repository.getDateRange(albumId);
}
Future<List<UserDto>> getSharedUsers(String albumId) {
return _repository.getSharedUsers(albumId);
}
Future<AlbumUserRole?> getUserRole(String albumId, String userId) {
return _repository.getUserRole(albumId, userId);
}
Future<List<RemoteAsset>> getAssets(String albumId) {
return _repository.getAssets(albumId);
}
Future<int> addAssets({required String albumId, required List<String> assetIds}) async {
final album = await _albumApiRepository.addAssets(albumId, assetIds);
await _repository.addAssets(albumId, album.added);
return album.added.length;
}
Future<void> deleteAlbum(String albumId) async {
await _albumApiRepository.deleteAlbum(albumId);
await _repository.deleteAlbum(albumId);
}
Future<void> addUsers({required String albumId, required List<String> userIds}) async {
await _albumApiRepository.addUsers(albumId, userIds);
return _repository.addUsers(albumId, userIds);
}
Future<void> removeUser(String albumId, {required String userId}) async {
await _albumApiRepository.removeUser(albumId, userId: userId);
return _repository.removeUser(albumId, userId: userId);
}
Future<void> setActivityStatus(String albumId, bool enabled) async {
await _albumApiRepository.setActivityStatus(albumId, enabled);
return _repository.setActivityStatus(albumId, enabled);
}
Future<int> getCount() {
return _repository.getCount();
}
Future<List<RemoteAlbum>> getAlbumsContainingAsset(String assetId) {
return _repository.getAlbumsContainingAsset(assetId);
}
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their newest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
for (final album in albums) {
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
}
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted;
}
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their oldest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
};
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted.reversed.toList();
}
}

View file

@ -0,0 +1,93 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/search_result.model.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:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
class SearchService {
final _log = Logger("SearchService");
final SearchApiRepository _searchApiRepository;
SearchService(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) {
_log.warning("Failed to get search suggestions", e);
}
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: response.assets.items.map((e) => e.toDto()).toList(),
nextPage: response.assets.nextPage?.toInt(),
);
} catch (error, stackTrace) {
_log.severe("Failed to search for assets", error, stackTrace);
}
return null;
}
}
extension on AssetResponseDto {
RemoteAsset toDto() {
return RemoteAsset(
id: id,
name: originalFileName,
checksum: checksum,
createdAt: fileCreatedAt,
updatedAt: updatedAt,
ownerId: ownerId,
visibility: switch (visibility) {
api.AssetVisibility.timeline => AssetVisibility.timeline,
api.AssetVisibility.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked,
_ => AssetVisibility.timeline,
},
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
height: exifInfo?.exifImageHeight?.toInt(),
width: exifInfo?.exifImageWidth?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
isEdited: isEdited,
);
}
}
extension on AssetTypeEnum {
AssetType toAssetType() => switch (this) {
AssetTypeEnum.IMAGE => AssetType.image,
AssetTypeEnum.VIDEO => AssetType.video,
AssetTypeEnum.AUDIO => AssetType.audio,
AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
};
}

View file

@ -0,0 +1,19 @@
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
// Singleton instance of SettingsService, to use in places
// where reactivity is not required
// ignore: non_constant_identifier_names
final AppSetting = SettingsService(storeService: StoreService.I);
class SettingsService {
final StoreService _storeService;
const SettingsService({required StoreService storeService}) : _storeService = storeService;
T get<T>(Setting<T> setting) => _storeService.get(setting.storeKey, setting.defaultValue);
Future<void> set<T>(Setting<T> setting, T value) => _storeService.put(setting.storeKey, value);
Stream<T> watch<T>(Setting<T> setting) => _storeService.watch(setting.storeKey).map((v) => v ?? setting.defaultValue);
}

View file

@ -0,0 +1,104 @@
import 'dart:async';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
/// Provides access to a persistent key-value store with an in-memory cache.
/// Listens for repository changes to keep the cache updated.
class StoreService {
final IStoreRepository _storeRepository;
/// In-memory cache. Keys are [StoreKey.id]
final Map<int, Object?> _cache = {};
StreamSubscription<List<StoreDto>>? _storeUpdateSubscription;
StoreService._({required IStoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository;
// TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider
static StoreService? _instance;
static StoreService get I {
if (_instance == null) {
throw UnsupportedError("StoreService not initialized. Call init() first");
}
return _instance!;
}
// TODO: Replace the implementation with the one from create after removing the typedef
static Future<StoreService> init({required IStoreRepository storeRepository, bool listenUpdates = true}) async {
_instance ??= await create(storeRepository: storeRepository, listenUpdates: listenUpdates);
return _instance!;
}
static Future<StoreService> create({required IStoreRepository storeRepository, bool listenUpdates = true}) async {
final instance = StoreService._(isarStoreRepository: storeRepository);
await instance.populateCache();
if (listenUpdates) {
instance._storeUpdateSubscription = instance._listenForChange();
}
return instance;
}
Future<void> populateCache() async {
final storeValues = await _storeRepository.getAll();
for (StoreDto storeValue in storeValues) {
_cache[storeValue.key.id] = storeValue.value;
}
}
StreamSubscription<List<StoreDto>> _listenForChange() => _storeRepository.watchAll().listen((events) {
for (final event in events) {
_cache[event.key.id] = event.value;
}
});
/// Disposes the store and cancels the subscription. To reuse the store call init() again
Future<void> dispose() async {
await _storeUpdateSubscription?.cancel();
_cache.clear();
}
/// Returns the cached value for [key], or `null`
T? tryGet<T>(StoreKey<T> key) => _cache[key.id] as T?;
/// Returns the stored value for [key] or [defaultValue].
/// Throws [StoreKeyNotFoundException] if value and [defaultValue] are null.
T get<T>(StoreKey<T> key, [T? defaultValue]) {
final value = tryGet(key) ?? defaultValue;
if (value == null) {
throw StoreKeyNotFoundException(key);
}
return value;
}
/// Stores the [value] for the [key]. Skips write if value hasn't changed.
Future<void> put<U extends StoreKey<T>, T>(U key, T value) async {
if (_cache[key.id] == value) return;
await _storeRepository.upsert(key, value);
_cache[key.id] = value;
}
/// Returns a stream that emits the value for [key] on change.
Stream<T?> watch<T>(StoreKey<T> key) => _storeRepository.watch(key);
/// Removes the value for [key]
Future<void> delete<T>(StoreKey<T> key) async {
await _storeRepository.delete(key);
_cache.remove(key.id);
}
/// Clears all values from the store (cache and DB)
Future<void> clear() async {
await _storeRepository.deleteAll();
_cache.clear();
}
bool get isBetaTimelineEnabled => tryGet(StoreKey.betaTimeline) ?? true;
}
class StoreKeyNotFoundException implements Exception {
final StoreKey key;
const StoreKeyNotFoundException(this.key);
@override
String toString() => "Key - <${key.name}> not available in Store";
}

View file

@ -0,0 +1,110 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
final syncLinkedAlbumServiceProvider = Provider(
(ref) => SyncLinkedAlbumService(
ref.watch(localAlbumRepository),
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
),
);
class SyncLinkedAlbumService {
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
SyncLinkedAlbumService(this._localAlbumRepository, this._remoteAlbumRepository, this._albumApiRepository);
final _log = Logger("SyncLinkedAlbumService");
Future<void> syncLinkedAlbums(String userId) async {
final selectedAlbums = await _localAlbumRepository.getBackupAlbums();
await Future.wait(
selectedAlbums.map((localAlbum) async {
final linkedRemoteAlbumId = localAlbum.linkedRemoteAlbumId;
if (linkedRemoteAlbumId == null) {
_log.warning("No linked remote album ID found for local album: ${localAlbum.name}");
return;
}
final remoteAlbum = await _remoteAlbumRepository.get(linkedRemoteAlbumId);
if (remoteAlbum == null) {
_log.warning("Linked remote album not found for ID: $linkedRemoteAlbumId");
return;
}
// get assets that are uploaded but not in the remote album
final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId);
_log.fine("Syncing ${assetIds.length} assets to remote album: ${remoteAlbum.name}");
if (assetIds.isNotEmpty) {
final album = await _albumApiRepository.addAssets(remoteAlbum.id, assetIds);
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
}
}),
);
}
Future<void> manageLinkedAlbums(List<LocalAlbum> localAlbums, String ownerId) async {
try {
for (final album in localAlbums) {
await _processLocalAlbum(album, ownerId);
}
} catch (error, stackTrace) {
_log.severe("Error managing linked albums", error, stackTrace);
}
}
/// Processes a single local album to ensure proper linking with remote albums
Future<void> _processLocalAlbum(LocalAlbum localAlbum, String ownerId) {
final hasLinkedRemoteAlbum = localAlbum.linkedRemoteAlbumId != null;
if (hasLinkedRemoteAlbum) {
return _handleLinkedAlbum(localAlbum);
} else {
return _handleUnlinkedAlbum(localAlbum, ownerId);
}
}
/// Handles albums that are already linked to a remote album
Future<void> _handleLinkedAlbum(LocalAlbum localAlbum) async {
final remoteAlbumId = localAlbum.linkedRemoteAlbumId!;
final remoteAlbum = await _remoteAlbumRepository.get(remoteAlbumId);
final remoteAlbumExists = remoteAlbum != null;
if (!remoteAlbumExists) {
return _localAlbumRepository.unlinkRemoteAlbum(localAlbum.id);
}
}
/// Handles albums that are not linked to any remote album
Future<void> _handleUnlinkedAlbum(LocalAlbum localAlbum, String ownerId) async {
final existingRemoteAlbum = await _remoteAlbumRepository.getByName(localAlbum.name, ownerId);
if (existingRemoteAlbum != null) {
return _linkToExistingRemoteAlbum(localAlbum, existingRemoteAlbum);
} else {
return _createAndLinkNewRemoteAlbum(localAlbum);
}
}
/// Links a local album to an existing remote album
Future<void> _linkToExistingRemoteAlbum(LocalAlbum localAlbum, dynamic existingRemoteAlbum) {
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, existingRemoteAlbum.id);
}
/// Creates a new remote album and links it to the local album
Future<void> _createAndLinkNewRemoteAlbum(LocalAlbum localAlbum) async {
dPrint(() => "Creating new remote album for local album: ${localAlbum.name}");
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(localAlbum.name, assetIds: []);
await _remoteAlbumRepository.create(newRemoteAlbum, []);
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, newRemoteAlbum.id);
}
}

View file

@ -0,0 +1,400 @@
// ignore_for_file: constant_identifier_names
import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.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/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
enum SyncMigrationTask {
v20260128_ResetExifV1, // EXIF table has incorrect width and height information.
v20260128_CopyExifWidthHeightToAsset, // Asset table has incorrect width and height for video ratio calculations.
v20260128_ResetAssetV1, // Asset v2.5.0 has width and height information that were edited assets.
}
class SyncStreamService {
final Logger _logger = Logger('SyncStreamService');
final SyncApiRepository _syncApiRepository;
final SyncStreamRepository _syncStreamRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final bool Function()? _cancelChecker;
SyncStreamService({
required SyncApiRepository syncApiRepository,
required SyncStreamRepository syncStreamRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required SyncMigrationRepository syncMigrationRepository,
required ApiService api,
bool Function()? cancelChecker,
}) : _syncApiRepository = syncApiRepository,
_syncStreamRepository = syncStreamRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_syncMigrationRepository = syncMigrationRepository,
_api = api,
_cancelChecker = cancelChecker;
bool get isCancelled => _cancelChecker?.call() ?? false;
Future<bool> sync() async {
_logger.info("Remote sync request for user");
final serverVersion = await _api.serverInfoApi.getServerVersion();
if (serverVersion == null) {
_logger.severe("Cannot perform sync: unable to determine server version");
return false;
}
final semVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
final value = Store.get(StoreKey.syncMigrationStatus, "[]");
final migrations = (jsonDecode(value) as List).cast<String>();
int previousLength = migrations.length;
await _runPreSyncTasks(migrations, semVer);
if (migrations.length != previousLength) {
_logger.info("Updated pre-sync migration status: $migrations");
await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations));
}
// Start the sync stream and handle events
bool shouldReset = false;
await _syncApiRepository.streamChanges(_handleEvents, onReset: () => shouldReset = true);
if (shouldReset) {
_logger.info("Resetting sync state as requested by server");
await _syncApiRepository.streamChanges(_handleEvents);
}
previousLength = migrations.length;
await _runPostSyncTasks(migrations);
if (migrations.length != previousLength) {
_logger.info("Updated pre-sync migration status: $migrations");
await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations));
}
return true;
}
Future<void> _runPreSyncTasks(List<String> migrations, SemVer semVer) async {
if (!migrations.contains(SyncMigrationTask.v20260128_ResetExifV1.name)) {
_logger.info("Running pre-sync task: v20260128_ResetExifV1");
await _syncApiRepository.deleteSyncAck([
SyncEntityType.assetExifV1,
SyncEntityType.partnerAssetExifV1,
SyncEntityType.albumAssetExifCreateV1,
SyncEntityType.albumAssetExifUpdateV1,
]);
migrations.add(SyncMigrationTask.v20260128_ResetExifV1.name);
}
if (!migrations.contains(SyncMigrationTask.v20260128_ResetAssetV1.name) &&
semVer >= const SemVer(major: 2, minor: 5, patch: 0)) {
_logger.info("Running pre-sync task: v20260128_ResetAssetV1");
await _syncApiRepository.deleteSyncAck([
SyncEntityType.assetV1,
SyncEntityType.partnerAssetV1,
SyncEntityType.albumAssetCreateV1,
SyncEntityType.albumAssetUpdateV1,
]);
migrations.add(SyncMigrationTask.v20260128_ResetAssetV1.name);
if (!migrations.contains(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name)) {
migrations.add(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name);
}
}
}
Future<void> _runPostSyncTasks(List<String> migrations) async {
if (!migrations.contains(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name)) {
_logger.info("Running post-sync task: v20260128_CopyExifWidthHeightToAsset");
await _syncMigrationRepository.v20260128CopyExifWidthHeightToAsset();
migrations.add(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name);
}
}
Future<void> _handleEvents(List<SyncEvent> events, Function() abort, Function() reset) async {
List<SyncEvent> items = [];
for (final event in events) {
if (isCancelled) {
_logger.warning("Sync stream cancelled");
abort();
return;
}
if (event.type != items.firstOrNull?.type) {
await _processBatch(items);
}
if (event.type == SyncEntityType.syncResetV1) {
reset();
}
items.add(event);
}
await _processBatch(items);
}
Future<void> _processBatch(List<SyncEvent> batch) async {
if (batch.isEmpty) {
return;
}
final type = batch.first.type;
await _handleSyncData(type, batch.map((e) => e.data));
await _syncApiRepository.ack([batch.last.ack]);
batch.clear();
}
Future<void> _handleSyncData(SyncEntityType type, Iterable<Object> data) async {
_logger.fine("Processing sync data for $type of length ${data.length}");
switch (type) {
case SyncEntityType.authUserV1:
return _syncStreamRepository.updateAuthUsersV1(data.cast());
case SyncEntityType.userV1:
return _syncStreamRepository.updateUsersV1(data.cast());
case SyncEntityType.userDeleteV1:
return _syncStreamRepository.deleteUsersV1(data.cast());
case SyncEntityType.partnerV1:
return _syncStreamRepository.updatePartnerV1(data.cast());
case SyncEntityType.partnerDeleteV1:
return _syncStreamRepository.deletePartnerV1(data.cast());
case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
await _applyRemoteRestoreToLocal();
} else {
_logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
}
}
return;
case SyncEntityType.assetDeleteV1:
return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
case SyncEntityType.assetMetadataV1:
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
case SyncEntityType.assetMetadataDeleteV1:
return _syncStreamRepository.deleteAssetsMetadataV1(data.cast());
case SyncEntityType.partnerAssetV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner');
case SyncEntityType.partnerAssetBackfillV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner backfill');
case SyncEntityType.partnerAssetDeleteV1:
return _syncStreamRepository.deleteAssetsV1(data.cast(), debugLabel: "partner");
case SyncEntityType.partnerAssetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'partner');
case SyncEntityType.partnerAssetExifBackfillV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'partner backfill');
case SyncEntityType.albumV1:
return _syncStreamRepository.updateAlbumsV1(data.cast());
case SyncEntityType.albumDeleteV1:
return _syncStreamRepository.deleteAlbumsV1(data.cast());
case SyncEntityType.albumUserV1:
return _syncStreamRepository.updateAlbumUsersV1(data.cast());
case SyncEntityType.albumUserBackfillV1:
return _syncStreamRepository.updateAlbumUsersV1(data.cast(), debugLabel: 'backfill');
case SyncEntityType.albumUserDeleteV1:
return _syncStreamRepository.deleteAlbumUsersV1(data.cast());
case SyncEntityType.albumAssetCreateV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset create');
case SyncEntityType.albumAssetUpdateV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset update');
case SyncEntityType.albumAssetBackfillV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset backfill');
case SyncEntityType.albumAssetExifCreateV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album asset exif create');
case SyncEntityType.albumAssetExifUpdateV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album asset exif update');
case SyncEntityType.albumAssetExifBackfillV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album asset exif backfill');
case SyncEntityType.albumToAssetV1:
return _syncStreamRepository.updateAlbumToAssetsV1(data.cast());
case SyncEntityType.albumToAssetBackfillV1:
return _syncStreamRepository.updateAlbumToAssetsV1(data.cast(), debugLabel: 'backfill');
case SyncEntityType.albumToAssetDeleteV1:
return _syncStreamRepository.deleteAlbumToAssetsV1(data.cast());
// No-op. SyncAckV1 entities are checkpoints in the sync stream
// to acknowledge that the client has processed all the backfill events
case SyncEntityType.syncAckV1:
return;
// SyncCompleteV1 is used to signal the completion of the sync process. Cleanup stale assets and signal completion
case SyncEntityType.syncCompleteV1:
return;
// return _syncStreamRepository.pruneAssets();
// Request to reset the client state. Clear everything related to remote entities
case SyncEntityType.syncResetV1:
return _syncStreamRepository.reset();
case SyncEntityType.memoryV1:
return _syncStreamRepository.updateMemoriesV1(data.cast());
case SyncEntityType.memoryDeleteV1:
return _syncStreamRepository.deleteMemoriesV1(data.cast());
case SyncEntityType.memoryToAssetV1:
return _syncStreamRepository.updateMemoryAssetsV1(data.cast());
case SyncEntityType.memoryToAssetDeleteV1:
return _syncStreamRepository.deleteMemoryAssetsV1(data.cast());
case SyncEntityType.stackV1:
return _syncStreamRepository.updateStacksV1(data.cast());
case SyncEntityType.stackDeleteV1:
return _syncStreamRepository.deleteStacksV1(data.cast());
case SyncEntityType.partnerStackV1:
return _syncStreamRepository.updateStacksV1(data.cast(), debugLabel: 'partner');
case SyncEntityType.partnerStackBackfillV1:
return _syncStreamRepository.updateStacksV1(data.cast(), debugLabel: 'partner backfill');
case SyncEntityType.partnerStackDeleteV1:
return _syncStreamRepository.deleteStacksV1(data.cast(), debugLabel: 'partner');
case SyncEntityType.userMetadataV1:
return _syncStreamRepository.updateUserMetadatasV1(data.cast());
case SyncEntityType.userMetadataDeleteV1:
return _syncStreamRepository.deleteUserMetadatasV1(data.cast());
case SyncEntityType.personV1:
return _syncStreamRepository.updatePeopleV1(data.cast());
case SyncEntityType.personDeleteV1:
return _syncStreamRepository.deletePeopleV1(data.cast());
case SyncEntityType.assetFaceV1:
return _syncStreamRepository.updateAssetFacesV1(data.cast());
case SyncEntityType.assetFaceDeleteV1:
return _syncStreamRepository.deleteAssetFacesV1(data.cast());
default:
_logger.warning("Unknown sync data type: $type");
}
}
Future<void> handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) async {
if (batchData.isEmpty) return;
_logger.info('Processing batch of ${batchData.length} AssetUploadReadyV1 events');
final List<SyncAssetV1> assets = [];
final List<SyncAssetExifV1> exifs = [];
try {
for (final data in batchData) {
if (data is! Map<String, dynamic>) {
continue;
}
final payload = data;
final assetData = payload['asset'];
final exifData = payload['exif'];
if (assetData == null || exifData == null) {
continue;
}
final asset = SyncAssetV1.fromJson(assetData);
final exif = SyncAssetExifV1.fromJson(exifData);
if (asset != null && exif != null) {
assets.add(asset);
exifs.add(exif);
}
}
if (assets.isNotEmpty && exifs.isNotEmpty) {
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-batch');
await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
_logger.info('Successfully processed ${assets.length} assets in batch');
}
} catch (error, stackTrace) {
_logger.severe("Error processing AssetUploadReadyV1 websocket batch events", error, stackTrace);
}
}
Future<void> handleWsAssetEditReadyV1Batch(List<dynamic> batchData) async {
if (batchData.isEmpty) return;
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
final List<SyncAssetV1> assets = [];
try {
for (final data in batchData) {
if (data is! Map<String, dynamic>) {
continue;
}
final payload = data;
final assetData = payload['asset'];
if (assetData == null) {
continue;
}
final asset = SyncAssetV1.fromJson(assetData);
if (asset != null) {
assets.add(asset);
}
}
if (assets.isNotEmpty) {
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
_logger.info('Successfully processed ${assets.length} edited assets');
}
} catch (error, stackTrace) {
_logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace);
}
}
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
if (checksums.isEmpty) {
return Future.value();
} else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
if (localAssetsToTrash.isNotEmpty) {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
} else {
_logger.info("No assets found in backup-enabled albums for assets: $checksums");
}
}
}
Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_logger.info("No remote assets found for restoration");
}
}
}

View file

@ -0,0 +1,236 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
typedef TimelineBucketSource = Stream<List<Bucket>> Function();
typedef TimelineQuery = ({TimelineAssetSource assetSource, TimelineBucketSource bucketSource, TimelineOrigin origin});
enum TimelineOrigin {
main,
localAlbum,
remoteAlbum,
remoteAssets,
favorite,
trash,
archive,
lockedFolder,
video,
place,
person,
map,
search,
deepLink,
albumActivities,
}
class TimelineFactory {
final DriftTimelineRepository _timelineRepository;
final SettingsService _settingsService;
const TimelineFactory({required DriftTimelineRepository timelineRepository, required SettingsService settingsService})
: _timelineRepository = timelineRepository,
_settingsService = settingsService;
GroupAssetsBy get groupBy {
final group = GroupAssetsBy.values[_settingsService.get(Setting.groupAssetsBy)];
// We do not support auto grouping in the new timeline yet, fallback to day grouping
return group == GroupAssetsBy.auto ? GroupAssetsBy.day : group;
}
TimelineService main(List<String> timelineUsers) => TimelineService(_timelineRepository.main(timelineUsers, groupBy));
TimelineService localAlbum({required String albumId}) =>
TimelineService(_timelineRepository.localAlbum(albumId, groupBy));
TimelineService remoteAlbum({required String albumId}) =>
TimelineService(_timelineRepository.remoteAlbum(albumId, groupBy));
TimelineService remoteAssets(String userId) => TimelineService(_timelineRepository.remote(userId, groupBy));
TimelineService favorite(String userId) => TimelineService(_timelineRepository.favorite(userId, groupBy));
TimelineService trash(String userId) => TimelineService(_timelineRepository.trash(userId, groupBy));
TimelineService archive(String userId) => TimelineService(_timelineRepository.archived(userId, groupBy));
TimelineService lockedFolder(String userId) => TimelineService(_timelineRepository.locked(userId, groupBy));
TimelineService video(String userId) => TimelineService(_timelineRepository.video(userId, groupBy));
TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy));
TimelineService person(String userId, String personId) =>
TimelineService(_timelineRepository.person(userId, personId, groupBy));
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssets(assets, type));
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
TimelineService map(List<String> userIds, TimelineMapOptions options) =>
TimelineService(_timelineRepository.map(userIds, options, groupBy));
}
class TimelineService {
final TimelineAssetSource _assetSource;
final TimelineBucketSource _bucketSource;
final TimelineOrigin origin;
final AsyncMutex _mutex = AsyncMutex();
int _bufferOffset = 0;
List<BaseAsset> _buffer = [];
StreamSubscription? _bucketSubscription;
int _totalAssets = 0;
int get totalAssets => _totalAssets;
TimelineService(TimelineQuery query)
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
TimelineService._({
required TimelineAssetSource assetSource,
required TimelineBucketSource bucketSource,
required this.origin,
}) : _assetSource = assetSource,
_bucketSource = bucketSource {
_bucketSubscription = _bucketSource().listen((buckets) {
_mutex.run(() async {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
if (totalAssets == 0) {
_bufferOffset = 0;
_buffer.clear();
} else {
final int offset;
final int count;
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
// we need to reset the buffer and load the first batch of assets.
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
offset = 0;
count = kTimelineAssetLoadBatchSize;
} else {
offset = _bufferOffset;
count = math.min(_buffer.length, totalAssets - _bufferOffset);
}
_buffer = await _assetSource(offset, count);
_bufferOffset = offset;
}
// change the state's total assets count only after the buffer is reloaded
_totalAssets = totalAssets;
EventStream.shared.emit(const TimelineReloadEvent());
});
});
}
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
Future<List<BaseAsset>> loadAssets(int index, int count) => _mutex.run(() => _loadAssets(index, count));
Future<List<BaseAsset>> _loadAssets(int index, int count) async {
if (hasRange(index, count)) {
return getAssets(index, count);
}
// if the requested offset is greater than the cached offset, the user scrolls forward "down"
final bool forward = _bufferOffset < index;
// make sure to load a meaningful amount of data (and not only the requested slice)
// otherwise, each call to [loadAssets] would result in DB call trashing performance
// fills small requests to [kTimelineAssetLoadBatchSize], adds some legroom into the opposite scroll direction for large requests
final len = math.max(kTimelineAssetLoadBatchSize, count + kTimelineAssetLoadOppositeSize);
// when scrolling forward, start shortly before the requested offset
// when scrolling backward, end shortly after the requested offset to guard against the user scrolling
// in the other direction a tiny bit resulting in another required load from the DB
final start = math.max(
0,
forward
? index - kTimelineAssetLoadOppositeSize
: (len > kTimelineAssetLoadBatchSize ? index : index + count - len),
);
_buffer = await _assetSource(start, len);
_bufferOffset = start;
return getAssets(index, count);
}
bool hasRange(int index, int count) =>
index >= 0 &&
index < _totalAssets &&
index >= _bufferOffset &&
index + count <= _bufferOffset + _buffer.length &&
index + count <= _totalAssets;
List<BaseAsset> getAssets(int index, int count) {
if (!hasRange(index, count)) {
throw RangeError('TimelineService::getAssets Index out of range');
}
int start = index - _bufferOffset;
return _buffer.slice(start, start + count);
}
// Pre-cache assets around the given index for asset viewer
Future<void> preCacheAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length));
BaseAsset getAsset(int index) {
if (!hasRange(index, 1)) {
throw RangeError(
'TimelineService::getAsset Index $index not in buffer range [$_bufferOffset, ${_bufferOffset + _buffer.length})',
);
}
return _buffer.elementAt(index - _bufferOffset);
}
/// Gets an asset at the given index, automatically loading the buffer if needed.
/// This is an async version that can handle out-of-range indices by loading the appropriate buffer.
Future<BaseAsset?> getAssetAsync(int index) async {
if (index < 0 || index >= _totalAssets) {
return null;
}
if (hasRange(index, 1)) {
return _buffer.elementAt(index - _bufferOffset);
}
// Load the buffer containing the requested index
try {
final assets = await loadAssets(index, 1);
return assets.isNotEmpty ? assets.first : null;
} catch (e) {
return null;
}
}
/// Safely gets an asset at the given index without throwing a RangeError.
/// Returns null if the index is out of bounds or not currently in the buffer.
/// For automatic buffer loading, use getAssetAsync instead.
BaseAsset? getAssetSafe(int index) {
if (index < 0 || index >= _totalAssets || !hasRange(index, 1)) {
return null;
}
return _buffer.elementAt(index - _bufferOffset);
}
Future<void> dispose() async {
await _bucketSubscription?.cancel();
_bucketSubscription = null;
_buffer = [];
_bufferOffset = 0;
}
}

View file

@ -0,0 +1,65 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:logging/logging.dart';
class UserService {
final Logger _log = Logger("UserService");
final IsarUserRepository _isarUserRepository;
final UserApiRepository _userApiRepository;
final StoreService _storeService;
UserService({
required IsarUserRepository isarUserRepository,
required UserApiRepository userApiRepository,
required StoreService storeService,
}) : _isarUserRepository = isarUserRepository,
_userApiRepository = userApiRepository,
_storeService = storeService;
UserDto getMyUser() {
return _storeService.get(StoreKey.currentUser);
}
UserDto? tryGetMyUser() {
return _storeService.tryGet(StoreKey.currentUser);
}
Stream<UserDto?> watchMyUser() {
return _storeService.watch(StoreKey.currentUser);
}
Future<UserDto?> refreshMyUser() async {
final user = await _userApiRepository.getMyUser();
if (user == null) return null;
await _storeService.put(StoreKey.currentUser, user);
await _isarUserRepository.update(user);
return user;
}
Future<String?> createProfileImage(String name, Uint8List image) async {
try {
final path = await _userApiRepository.createProfileImage(name: name, data: image);
final updatedUser = getMyUser();
await _storeService.put(StoreKey.currentUser, updatedUser);
await _isarUserRepository.update(updatedUser);
return path;
} catch (e) {
_log.warning("Failed to upload profile image", e);
return null;
}
}
Future<List<UserDto>> getAll() async {
return await _isarUserRepository.getAll();
}
Future<void> deleteAll() {
return _isarUserRepository.deleteAll();
}
}