1
0
mirror of https://github.com/immich-app/immich.git synced 2025-08-08 23:07:06 +02:00

refactor(mobile): encapsulate most access to photomanager in repository (#12754)

* refactor(mobile): encapsulate most access to photomanager in repository
This commit is contained in:
Fynn Petersen-Frey
2024-09-18 17:15:52 +02:00
committed by GitHub
parent 6740c67ed8
commit 6995cc2b38
45 changed files with 1205 additions and 500 deletions

View File

@ -6,6 +6,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/album.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart';
@ -19,13 +20,13 @@ import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final albumServiceProvider = Provider(
(ref) => AlbumService(
@ -36,6 +37,7 @@ final albumServiceProvider = Provider(
ref.watch(assetRepositoryProvider),
ref.watch(userRepositoryProvider),
ref.watch(backupRepositoryProvider),
ref.watch(albumMediaRepositoryProvider),
),
);
@ -47,6 +49,7 @@ class AlbumService {
final IAssetRepository _assetRepository;
final IUserRepository _userRepository;
final IBackupRepository _backupAlbumRepository;
final IAlbumMediaRepository _albumMediaRepository;
final Logger _log = Logger('AlbumService');
Completer<bool> _localCompleter = Completer()..complete(false);
Completer<bool> _remoteCompleter = Completer()..complete(false);
@ -59,6 +62,7 @@ class AlbumService {
this._assetRepository,
this._userRepository,
this._backupAlbumRepository,
this._albumMediaRepository,
);
/// Checks all selected device albums for changes of albums and their assets
@ -84,11 +88,7 @@ class AlbumService {
}
return false;
}
final List<AssetPathEntity> onDevice =
await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
);
final List<Album> onDevice = await _albumMediaRepository.getAll();
_log.info("Found ${onDevice.length} device albums");
Set<String>? excludedAssets;
if (excludedIds.isNotEmpty) {
@ -104,13 +104,15 @@ class AlbumService {
_log.info("Found ${excludedAssets.length} assets to exclude");
}
// remove all excluded albums
onDevice.removeWhere((e) => excludedIds.contains(e.id));
onDevice.removeWhere((e) => excludedIds.contains(e.localId));
_log.info(
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
);
}
final hasAll = selectedIds
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
.map(
(id) => onDevice.firstWhereOrNull((album) => album.localId == id),
)
.whereNotNull()
.any((a) => a.isAll);
if (hasAll) {
@ -122,7 +124,7 @@ class AlbumService {
}
} else {
// keep only the explicitly selected albums
onDevice.removeWhere((e) => !selectedIds.contains(e.id));
onDevice.removeWhere((e) => !selectedIds.contains(e.localId));
_log.info("'Recents' is not selected, keeping only selected albums");
}
changes =
@ -136,15 +138,15 @@ class AlbumService {
}
Future<Set<String>> _loadExcludedAssetIds(
List<AssetPathEntity> albums,
List<Album> albums,
List<String> excludedAlbumIds,
) async {
final Set<String> result = HashSet<String>();
for (AssetPathEntity a in albums) {
if (excludedAlbumIds.contains(a.id)) {
final List<AssetEntity> assets =
await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
result.addAll(assets.map((e) => e.id));
for (Album album in albums) {
if (excludedAlbumIds.contains(album.localId)) {
final assetIds =
await _albumMediaRepository.getAssetIds(album.localId!);
result.addAll(assetIds);
}
}
return result;

View File

@ -321,7 +321,7 @@ class AssetService {
for (BackupCandidate candidate in candidates) {
final asset = remoteAssets.firstWhereOrNull(
(a) => a.localId == candidate.asset.id,
(a) => a.localId == candidate.asset.localId,
);
if (asset != null) {

View File

@ -15,6 +15,8 @@ import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
@ -34,7 +36,7 @@ import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:isar/isar.dart';
import 'package:path_provider_ios/path_provider_ios.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final backgroundServiceProvider = Provider(
(ref) => BackgroundService(),
@ -363,8 +365,10 @@ class BackgroundService {
AssetRepository assetRepository = AssetRepository(db);
UserRepository userRepository = UserRepository(db);
BackupRepository backupAlbumRepository = BackupRepository(db);
HashService hashService = HashService(db, this);
SyncService syncSerive = SyncService(db, hashService);
AlbumMediaRepository albumMediaRepository = AlbumMediaRepository();
FileMediaRepository fileMediaRepository = FileMediaRepository();
HashService hashService = HashService(db, this, albumMediaRepository);
SyncService syncSerive = SyncService(db, hashService, albumMediaRepository);
UserService userService =
UserService(apiService, db, syncSerive, partnerService);
AlbumService albumService = AlbumService(
@ -375,9 +379,16 @@ class BackgroundService {
assetRepository,
userRepository,
backupAlbumRepository,
albumMediaRepository,
);
BackupService backupService = BackupService(
apiService,
db,
settingService,
albumService,
albumMediaRepository,
fileMediaRepository,
);
BackupService backupService =
BackupService(apiService, db, settingService, albumService);
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
@ -385,7 +396,7 @@ class BackgroundService {
return true;
}
await PhotoManager.setIgnorePermissionCheck(true);
await fileMediaRepository.enableBackgroundAccess();
do {
final bool backupOk = await _runBackup(

View File

@ -6,9 +6,13 @@ import 'package:cancellation_token_http/http.dart' as http;
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.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';
@ -16,6 +20,8 @@ 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/providers/db.provider.dart';
import 'package:immich_mobile/repositories/album_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';
@ -24,7 +30,7 @@ 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';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final backupServiceProvider = Provider(
(ref) => BackupService(
@ -32,6 +38,8 @@ final backupServiceProvider = Provider(
ref.watch(dbProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(fileMediaRepositoryProvider),
),
);
@ -42,12 +50,16 @@ class BackupService {
final Logger _log = Logger("BackupService");
final AppSettingsService _appSetting;
final AlbumService _albumService;
final IAlbumMediaRepository _albumMediaRepository;
final IFileMediaRepository _fileMediaRepository;
BackupService(
this._apiService,
this._db,
this._appSetting,
this._albumService,
this._albumMediaRepository,
this._fileMediaRepository,
);
Future<List<String>?> getDeviceBackupAsset() async {
@ -86,44 +98,17 @@ class BackupService {
List<BackupAlbum> excludedBackupAlbums, {
bool useTimeFilter = true,
}) async {
final filter = FilterOptionGroup(
containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)],
// title is needed to create Assets
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true),
);
final now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums =
await _loadAlbumsWithTimeFilter(
selectedBackupAlbums,
filter,
now,
useTimeFilter: useTimeFilter,
);
if (selectedAlbums.every((e) => e == null)) {
return {};
}
final List<AssetPathEntity?> excludedAlbums =
await _loadAlbumsWithTimeFilter(
excludedBackupAlbums,
filter,
now,
useTimeFilter: useTimeFilter,
);
final Set<BackupCandidate> toAdd = await _fetchAssetsAndUpdateLastBackup(
selectedAlbums,
selectedBackupAlbums,
now,
useTimeFilter: useTimeFilter,
);
if (toAdd.isEmpty) return {};
final Set<BackupCandidate> toRemove = await _fetchAssetsAndUpdateLastBackup(
excludedAlbums,
excludedBackupAlbums,
now,
useTimeFilter: useTimeFilter,
@ -132,92 +117,62 @@ class BackupService {
return toAdd.difference(toRemove);
}
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
List<BackupAlbum> albums,
FilterOptionGroup filter,
DateTime now, {
bool useTimeFilter = true,
}) async {
List<AssetPathEntity?> result = [];
for (BackupAlbum backupAlbum in albums) {
try {
final optionGroup = useTimeFilter
? filter.copyWith(
updateTimeCond: DateTimeCond(
// subtract 2 seconds to prevent missing assets due to rounding issues
min: backupAlbum.lastBackup
.subtract(const Duration(seconds: 2)),
max: now,
),
)
: filter;
final AssetPathEntity album =
await AssetPathEntity.obtainPathFromProperties(
id: backupAlbum.id,
optionGroup: optionGroup,
maxDateTimeToNow: false,
);
result.add(album);
} on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists
}
}
return result;
}
Future<Set<BackupCandidate>> _fetchAssetsAndUpdateLastBackup(
List<AssetPathEntity?> localAlbums,
List<BackupAlbum> backupAlbums,
DateTime now, {
bool useTimeFilter = true,
}) async {
Set<BackupCandidate> candidate = {};
Set<BackupCandidate> candidates = {};
for (int i = 0; i < localAlbums.length; i++) {
final localAlbum = localAlbums[i];
if (localAlbum == null) {
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.lastModified?.isBefore(backupAlbums[i].lastBackup) ==
true) {
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;
}
final assets = await localAlbum.getAssetListRange(
start: 0,
end: await localAlbum.assetCountAsync,
);
// Add album's name to the asset info
for (final asset in assets) {
List<String> albumNames = [localAlbum.name];
final existingAsset = candidate.firstWhereOrNull(
(a) => a.asset.id == asset.id,
final existingAsset = candidates.firstWhereOrNull(
(candidate) => candidate.asset.localId == asset.localId,
);
if (existingAsset != null) {
albumNames.addAll(existingAsset.albumNames);
candidate.remove(existingAsset);
candidates.remove(existingAsset);
}
candidate.add(
BackupCandidate(
asset: asset,
albumNames: albumNames,
),
);
candidates.add(BackupCandidate(asset: asset, albumNames: albumNames));
}
backupAlbums[i].lastBackup = now;
backupAlbum.lastBackup = now;
}
return candidate;
return candidates;
}
/// Returns a new list of assets not yet uploaded
@ -230,7 +185,7 @@ class BackupService {
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
candidates.removeWhere(
(candidate) => duplicatedAssetIds.contains(candidate.asset.id),
(candidate) => duplicatedAssetIds.contains(candidate.asset.localId),
);
if (candidates.isEmpty) {
@ -243,7 +198,7 @@ class BackupService {
final CheckExistingAssetsResponseDto? duplicates =
await _apiService.assetsApi.checkExistingAssets(
CheckExistingAssetsDto(
deviceAssetIds: candidates.map((c) => c.asset.id).toList(),
deviceAssetIds: candidates.map((c) => c.asset.localId!).toList(),
deviceId: deviceId,
),
);
@ -259,7 +214,7 @@ class BackupService {
}
if (existing.isNotEmpty) {
candidates.removeWhere((c) => existing.contains(c.asset.id));
candidates.removeWhere((c) => existing.contains(c.asset.localId));
}
return candidates;
@ -278,7 +233,7 @@ class BackupService {
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
if (Platform.isIOS) {
await PhotoManager.requestPermissionExtend();
await _fileMediaRepository.requestExtendedPermissions();
}
return true;
@ -289,9 +244,9 @@ class BackupService {
List<BackupCandidate> _sortPhotosFirst(List<BackupCandidate> candidates) {
return candidates.sorted(
(a, b) {
final cmp = a.asset.typeInt - b.asset.typeInt;
final cmp = a.asset.type.index - b.asset.type.index;
if (cmp != 0) return cmp;
return a.asset.createDateTime.compareTo(b.asset.createDateTime);
return a.asset.fileCreatedAt.compareTo(b.asset.fileCreatedAt);
},
);
}
@ -325,13 +280,13 @@ class BackupService {
}
for (final candidate in candidates) {
final AssetEntity entity = candidate.asset;
final Asset asset = candidate.asset;
File? file;
File? livePhotoFile;
try {
final isAvailableLocally =
await entity.isLocallyAvailable(isOrigin: true);
await asset.local!.isLocallyAvailable(isOrigin: true);
// Handle getting files from iCloud
if (!isAvailableLocally && Platform.isIOS) {
@ -342,39 +297,41 @@ class BackupService {
onCurrentAsset(
CurrentUploadAsset(
id: entity.id,
fileCreatedAt: entity.createDateTime.year == 1970
? entity.modifiedDateTime
: entity.createDateTime,
fileName: await entity.titleAsync,
fileType: _getAssetType(entity.type),
id: asset.localId!,
fileCreatedAt: asset.fileCreatedAt.year == 1970
? asset.fileModifiedAt
: asset.fileCreatedAt,
fileName: asset.fileName,
fileType: _getAssetType(asset.type),
iCloudAsset: true,
),
);
file = await entity.loadFile(progressHandler: pmProgressHandler);
if (entity.isLivePhoto) {
livePhotoFile = await entity.loadFile(
file =
await asset.local!.loadFile(progressHandler: pmProgressHandler);
if (asset.local!.isLivePhoto) {
livePhotoFile = await asset.local!.loadFile(
withSubtype: true,
progressHandler: pmProgressHandler,
);
}
} else {
if (entity.type == AssetType.video) {
file = await entity.originFile;
if (asset.type == AssetType.video) {
file = await asset.local!.originFile;
} else {
file = await entity.originFile.timeout(const Duration(seconds: 5));
if (entity.isLivePhoto) {
livePhotoFile = await entity.originFileWithSubtype
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 entity.titleAsync;
String originalFileName = asset.fileName;
if (entity.isLivePhoto) {
if (asset.local!.isLivePhoto) {
if (livePhotoFile == null) {
_log.warning(
"Failed to obtain motion part of the livePhoto - $originalFileName",
@ -398,31 +355,31 @@ class BackupService {
baseRequest.headers.addAll(ApiService.getRequestHeaders());
baseRequest.headers["Transfer-Encoding"] = "chunked";
baseRequest.fields['deviceAssetId'] = entity.id;
baseRequest.fields['deviceAssetId'] = asset.localId!;
baseRequest.fields['deviceId'] = deviceId;
baseRequest.fields['fileCreatedAt'] =
entity.createDateTime.toUtc().toIso8601String();
asset.fileCreatedAt.toUtc().toIso8601String();
baseRequest.fields['fileModifiedAt'] =
entity.modifiedDateTime.toUtc().toIso8601String();
baseRequest.fields['isFavorite'] = entity.isFavorite.toString();
baseRequest.fields['duration'] = entity.videoDuration.toString();
asset.fileModifiedAt.toUtc().toIso8601String();
baseRequest.fields['isFavorite'] = asset.isFavorite.toString();
baseRequest.fields['duration'] = asset.duration.toString();
baseRequest.files.add(assetRawUploadData);
onCurrentAsset(
CurrentUploadAsset(
id: entity.id,
fileCreatedAt: entity.createDateTime.year == 1970
? entity.modifiedDateTime
: entity.createDateTime,
id: asset.localId!,
fileCreatedAt: asset.fileCreatedAt.year == 1970
? asset.fileModifiedAt
: asset.fileCreatedAt,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
fileType: _getAssetType(asset.type),
fileSize: file.lengthSync(),
iCloudAsset: false,
),
);
String? livePhotoVideoId;
if (entity.isLivePhoto && livePhotoFile != null) {
if (asset.local!.isLivePhoto && livePhotoFile != null) {
livePhotoVideoId = await uploadLivePhotoVideo(
originalFileName,
livePhotoFile,
@ -448,16 +405,16 @@ class BackupService {
final errorMessage = error['message'] ?? error['error'];
debugPrint(
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
"Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}",
);
onError(
ErrorUploadAsset(
asset: entity,
id: entity.id,
fileCreatedAt: entity.createDateTime,
asset: asset,
id: asset.localId!,
fileCreatedAt: asset.fileCreatedAt,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
fileType: _getAssetType(candidate.asset.type),
errorMessage: errorMessage,
),
);
@ -473,7 +430,7 @@ class BackupService {
bool isDuplicate = false;
if (response.statusCode == 200) {
isDuplicate = true;
duplicatedAssetIds.add(entity.id);
duplicatedAssetIds.add(asset.localId!);
}
onSuccess(

View File

@ -8,17 +8,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:photo_manager/photo_manager.dart' show PhotoManager;
/// Finds duplicates originating from missing EXIF information
class BackupVerificationService {
final Isar _db;
final IFileMediaRepository _fileMediaRepository;
BackupVerificationService(this._db);
BackupVerificationService(this._db, this._fileMediaRepository);
/// Returns at most [limit] assets that were backed up without exif
Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
@ -71,6 +73,7 @@ class BackupVerificationService {
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
),
);
final upper = compute(
@ -81,6 +84,7 @@ class BackupVerificationService {
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
),
);
toDelete = await lower + await upper;
@ -93,6 +97,7 @@ class BackupVerificationService {
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
),
);
}
@ -106,12 +111,13 @@ class BackupVerificationService {
String auth,
String endpoint,
RootIsolateToken rootIsolateToken,
IFileMediaRepository fileMediaRepository,
}) tuple,
) async {
assert(tuple.deleteCandidates.length == tuple.originals.length);
final List<Asset> result = [];
BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
await PhotoManager.setIgnorePermissionCheck(true);
await tuple.fileMediaRepository.enableBackgroundAccess();
final ApiService apiService = ApiService();
apiService.setEndpoint(tuple.endpoint);
apiService.setAccessToken(tuple.auth);
@ -228,5 +234,6 @@ class BackupVerificationService {
final backupVerificationServiceProvider = Provider(
(ref) => BackupVerificationService(
ref.watch(dbProvider),
ref.watch(fileMediaRepositoryProvider),
),
);

View File

@ -2,6 +2,9 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
@ -11,38 +14,46 @@ import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
class HashService {
HashService(this._db, this._backgroundService);
HashService(this._db, this._backgroundService, this._albumMediaRepository);
final Isar _db;
final BackgroundService _backgroundService;
final IAlbumMediaRepository _albumMediaRepository;
final _log = Logger('HashService');
/// Returns all assets that were successfully hashed
Future<List<Asset>> getHashedAssets(
AssetPathEntity album, {
Album album, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
Set<String>? excludedAssets,
}) async {
final entities = await album.getAssetListRange(start: start, end: end);
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.id)).toList();
: entities.where((e) => !excludedAssets.contains(e.localId!)).toList();
return _hashAssets(filtered);
}
/// Converts a list of [AssetEntity]s to [Asset]s including only those
/// 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
/// [AndroidDeviceAsset] / [IOSDeviceAsset] by local id. Only missing
/// entries are newly hashed and added to the DB table.
Future<List<Asset>> _hashAssets(List<AssetEntity> assetEntities) async {
Future<List<Asset>> _hashAssets(List<Asset> assets) async {
const int batchFileCount = 128;
const int batchDataSize = 1024 * 1024 * 1024; // 1GB
final ids = assetEntities
.map(Platform.isAndroid ? (a) => a.id.toInt() : (a) => a.id)
final ids = assets
.map(Platform.isAndroid ? (a) => a.localId!.toInt() : (a) => a.localId!)
.toList();
final List<DeviceAsset?> hashes = await _lookupHashes(ids);
final List<DeviceAsset> toAdd = [];
@ -50,22 +61,16 @@ class HashService {
int bytes = 0;
for (int i = 0; i < assetEntities.length; i++) {
for (int i = 0; i < assets.length; i++) {
if (hashes[i] != null) {
continue;
}
final file = await assetEntities[i].originFile;
final file = await assets[i].local!.originFile;
if (file == null) {
final fileName = await assetEntities[i].titleAsync.catchError((error) {
_log.warning(
"Failed to get title for asset ${assetEntities[i].id}",
);
return "";
});
final fileName = assets[i].fileName;
_log.warning(
"Failed to get file for asset ${assetEntities[i].id}, name: $fileName, created on: ${assetEntities[i].createDateTime}, skipping",
"Failed to get file for asset ${assets[i].localId}, name: $fileName, created on: ${assets[i].fileCreatedAt}, skipping",
);
continue;
}
@ -86,7 +91,7 @@ class HashService {
if (toHash.isNotEmpty) {
await _processBatch(toHash, toAdd);
}
return _mapAllHashedAssets(assetEntities, hashes);
return _getHashedAssets(assets, hashes);
}
/// Lookup hashes of assets by their local ID
@ -133,15 +138,16 @@ class HashService {
return hashes;
}
/// Converts [AssetEntity]s that were successfully hashed to [Asset]s
List<Asset> _mapAllHashedAssets(
List<AssetEntity> assets,
/// Returns all successfully hashed [Asset]s with their hash value set
List<Asset> _getHashedAssets(
List<Asset> assets,
List<DeviceAsset?> hashes,
) {
final List<Asset> result = [];
for (int i = 0; i < assets.length; i++) {
if (hashes[i] != null && hashes[i]!.hash.isNotEmpty) {
result.add(Asset.local(assets[i], hashes[i]!.hash));
assets[i].byteHash = hashes[i]!.hash;
result.add(assets[i]);
}
}
return result;
@ -152,5 +158,6 @@ final hashServiceProvider = Provider(
(ref) => HashService(
ref.watch(dbProvider),
ref.watch(backgroundServiceProvider),
ref.watch(albumMediaRepositoryProvider),
),
);

View File

@ -3,21 +3,27 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:path_provider/path_provider.dart';
final imageViewerServiceProvider =
Provider((ref) => ImageViewerService(ref.watch(apiServiceProvider)));
final imageViewerServiceProvider = Provider(
(ref) => ImageViewerService(
ref.watch(apiServiceProvider),
ref.watch(fileMediaRepositoryProvider),
),
);
class ImageViewerService {
final ApiService _apiService;
final IFileMediaRepository _fileMediaRepository;
final Logger _log = Logger("ImageViewerService");
ImageViewerService(this._apiService);
ImageViewerService(this._apiService, this._fileMediaRepository);
Future<bool> downloadAsset(Asset asset) async {
File? imageFile;
@ -46,7 +52,7 @@ class ImageViewerService {
return false;
}
AssetEntity? entity;
Asset? resultAsset;
final tempDir = await getTemporaryDirectory();
videoFile = await File('${tempDir.path}/livephoto.mov').create();
@ -54,24 +60,21 @@ class ImageViewerService {
videoFile.writeAsBytesSync(motionResponse.bodyBytes);
imageFile.writeAsBytesSync(imageResponse.bodyBytes);
entity = await PhotoManager.editor.darwin.saveLivePhoto(
imageFile: imageFile,
videoFile: videoFile,
resultAsset = await _fileMediaRepository.saveLivePhoto(
image: imageFile,
video: videoFile,
title: asset.fileName,
);
if (entity == null) {
if (resultAsset == null) {
_log.warning(
"Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file",
);
entity = await PhotoManager.editor.saveImage(
imageResponse.bodyBytes,
title: asset.fileName,
);
resultAsset = await _fileMediaRepository
.saveImage(imageResponse.bodyBytes, title: asset.fileName);
}
return entity != null;
return resultAsset != null;
} else {
var res = await _apiService.assetsApi
.downloadAssetWithHttpInfo(asset.remoteId!);
@ -81,11 +84,11 @@ class ImageViewerService {
return false;
}
final AssetEntity? entity;
final Asset? resultAsset;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
if (asset.isImage) {
entity = await PhotoManager.editor.saveImage(
resultAsset = await _fileMediaRepository.saveImage(
res.bodyBytes,
title: asset.fileName,
relativePath: relativePath,
@ -94,13 +97,13 @@ class ImageViewerService {
final tempDir = await getTemporaryDirectory();
videoFile = await File('${tempDir.path}/${asset.fileName}').create();
videoFile.writeAsBytesSync(res.bodyBytes);
entity = await PhotoManager.editor.saveVideo(
resultAsset = await _fileMediaRepository.saveVideo(
videoFile,
title: asset.fileName,
relativePath: relativePath,
);
}
return entity != null;
return resultAsset != null;
}
} catch (error, stack) {
_log.severe("Error saving downloaded asset", error, stack);

View File

@ -8,7 +8,9 @@ import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
@ -17,19 +19,23 @@ import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final syncServiceProvider = Provider(
(ref) => SyncService(ref.watch(dbProvider), ref.watch(hashServiceProvider)),
(ref) => SyncService(
ref.watch(dbProvider),
ref.watch(hashServiceProvider),
ref.watch(albumMediaRepositoryProvider),
),
);
class SyncService {
final Isar _db;
final HashService _hashService;
final IAlbumMediaRepository _albumMediaRepository;
final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService');
SyncService(this._db, this._hashService);
SyncService(this._db, this._hashService, this._albumMediaRepository);
// public methods:
@ -68,7 +74,7 @@ class SyncService {
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> syncLocalAlbumAssetsToDb(
List<AssetPathEntity> onDevice, [
List<Album> onDevice, [
Set<String>? excludedAssets,
]) =>
_lock.run(() => _syncLocalAlbumAssetsToDb(onDevice, excludedAssets));
@ -492,7 +498,7 @@ class SyncService {
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> _syncLocalAlbumAssetsToDb(
List<AssetPathEntity> onDevice, [
List<Album> onDevice, [
Set<String>? excludedAssets,
]) async {
onDevice.sort((a, b) => a.id.compareTo(b.id));
@ -504,16 +510,15 @@ class SyncService {
final bool anyChanges = await diffSortedLists(
onDevice,
inDb,
compare: (AssetPathEntity a, Album b) => a.id.compareTo(b.localId!),
both: (AssetPathEntity ape, Album album) => _syncAlbumInDbAndOnDevice(
ape,
album,
compare: (Album a, Album b) => a.localId!.compareTo(b.localId!),
both: (Album a, Album b) => _syncAlbumInDbAndOnDevice(
a,
b,
deleteCandidates,
existing,
excludedAssets,
),
onlyFirst: (AssetPathEntity ape) =>
_addAlbumFromDevice(ape, existing, excludedAssets),
onlyFirst: (Album a) => _addAlbumFromDevice(a, existing, excludedAssets),
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
);
_log.fine(
@ -541,58 +546,65 @@ class SyncService {
/// returns `true` if there were any changes
/// Accumulates asset candidates to delete and those already existing in DB
Future<bool> _syncAlbumInDbAndOnDevice(
AssetPathEntity ape,
Album album,
Album deviceAlbum,
Album dbAlbum,
List<Asset> deleteCandidates,
List<Asset> existing, [
Set<String>? excludedAssets,
bool forceRefresh = false,
]) async {
if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) {
_log.fine("Local album ${ape.name} has not changed. Skipping sync.");
if (!forceRefresh && !await _hasAlbumChangeOnDevice(deviceAlbum, dbAlbum)) {
_log.fine(
"Local album ${deviceAlbum.name} has not changed. Skipping sync.",
);
return false;
}
if (!forceRefresh &&
excludedAssets == null &&
await _syncDeviceAlbumFast(ape, album)) {
await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) {
return true;
}
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
final inDb = await album.assets
final inDb = await dbAlbum.assets
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.sortByChecksum()
.findAll();
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
final int assetCountOnDevice = await ape.assetCountAsync;
final List<Asset> onDevice =
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets);
final int assetCountOnDevice =
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
final List<Asset> onDevice = await _hashService.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 &&
album.name == ape.name &&
ape.lastModified != null &&
album.modifiedAt.isAtSameMomentAs(ape.lastModified!)) {
dbAlbum.name == deviceAlbum.name &&
dbAlbum.modifiedAt.isAtSameMomentAs(deviceAlbum.modifiedAt)) {
// changes only affeted excluded albums
_log.fine(
"Only excluded assets in local album ${ape.name} changed. Stopping sync.",
"Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.",
);
if (assetCountOnDevice !=
_db.eTags.getByIdSync(ape.eTagKeyAssetCount)?.assetCount) {
_db.eTags.getByIdSync(deviceAlbum.eTagKeyAssetCount)?.assetCount) {
await _db.writeTxn(
() => _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
ETag(
id: deviceAlbum.eTagKeyAssetCount,
assetCount: assetCountOnDevice,
),
),
);
}
return false;
}
_log.fine(
"Syncing local album ${ape.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
"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.fine(
@ -600,28 +612,31 @@ class SyncService {
);
deleteCandidates.addAll(toDelete);
existing.addAll(existingInDb);
album.name = ape.name;
album.modifiedAt = ape.lastModified ?? DateTime.now();
if (album.thumbnail.value != null &&
toDelete.contains(album.thumbnail.value)) {
album.thumbnail.value = null;
dbAlbum.name = deviceAlbum.name;
dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
if (dbAlbum.thumbnail.value != null &&
toDelete.contains(dbAlbum.thumbnail.value)) {
dbAlbum.thumbnail.value = null;
}
try {
await _db.writeTxn(() async {
await _db.assets.putAll(updated);
await _db.assets.putAll(toUpdate);
await album.assets
await dbAlbum.assets
.update(link: existingInDb + updated, unlink: toDelete);
await _db.albums.put(album);
album.thumbnail.value ??= await album.assets.filter().findFirst();
await album.thumbnail.save();
await _db.albums.put(dbAlbum);
dbAlbum.thumbnail.value ??= await dbAlbum.assets.filter().findFirst();
await dbAlbum.thumbnail.save();
await _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
ETag(
id: deviceAlbum.eTagKeyAssetCount,
assetCount: assetCountOnDevice,
),
);
});
_log.info("Synced changes of local album ${ape.name} to DB");
_log.info("Synced changes of local album ${deviceAlbum.name} to DB");
} on IsarError catch (e) {
_log.severe("Failed to update synced album ${ape.name} in DB", e);
_log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e);
}
return true;
@ -629,45 +644,45 @@ class SyncService {
/// fast path for common case: only new assets were added to device album
/// returns `true` if successfull, else `false`
Future<bool> _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async {
if (!(ape.lastModified ?? DateTime.now()).isAfter(album.modifiedAt)) {
Future<bool> _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async {
if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) {
return false;
}
final int totalOnDevice = await ape.assetCountAsync;
final int totalOnDevice =
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
final int lastKnownTotal =
(await _db.eTags.getById(ape.eTagKeyAssetCount))?.assetCount ?? 0;
final AssetPathEntity? modified = totalOnDevice > lastKnownTotal
? await ape.fetchPathProperties(
filterOptionGroup: FilterOptionGroup(
updateTimeCond: DateTimeCond(
min: album.modifiedAt.add(const Duration(seconds: 1)),
max: ape.lastModified ?? DateTime.now(),
),
),
)
: null;
if (modified == null) {
(await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))?.assetCount ??
0;
if (totalOnDevice <= lastKnownTotal) {
return false;
}
final List<Asset> newAssets = await _hashService.getHashedAssets(modified);
final List<Asset> newAssets = await _hashService.getHashedAssets(
deviceAlbum,
modifiedFrom: dbAlbum.modifiedAt.add(const Duration(seconds: 1)),
modifiedUntil: deviceAlbum.modifiedAt,
);
if (totalOnDevice != lastKnownTotal + newAssets.length) {
return false;
}
album.modifiedAt = ape.lastModified ?? DateTime.now();
dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
_removeDuplicates(newAssets);
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
try {
await _db.writeTxn(() async {
await _db.assets.putAll(updated);
await album.assets.update(link: existingInDb + updated);
await _db.albums.put(album);
await _db.eTags
.put(ETag(id: ape.eTagKeyAssetCount, assetCount: totalOnDevice));
await dbAlbum.assets.update(link: existingInDb + updated);
await _db.albums.put(dbAlbum);
await _db.eTags.put(
ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice),
);
});
_log.info("Fast synced local album ${ape.name} to DB");
_log.info("Fast synced local album ${deviceAlbum.name} to DB");
} on IsarError catch (e) {
_log.severe("Failed to fast sync local album ${ape.name} to DB", e);
_log.severe(
"Failed to fast sync local album ${deviceAlbum.name} to DB",
e,
);
return false;
}
@ -677,14 +692,15 @@ class SyncService {
/// 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(
AssetPathEntity ape,
Album album,
List<Asset> existing, [
Set<String>? excludedAssets,
]) async {
_log.info("Syncing a new local album to DB: ${ape.name}");
final Album a = Album.local(ape);
final assets =
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets);
_log.info("Syncing a new local album to DB: ${album.name}");
final assets = await _hashService.getHashedAssets(
album,
excludedAssets: excludedAssets,
);
_removeDuplicates(assets);
final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
_log.info(
@ -692,15 +708,15 @@ class SyncService {
);
await upsertAssetsWithExif(updated);
existing.addAll(existingInDb);
a.assets.addAll(existingInDb);
a.assets.addAll(updated);
album.assets.addAll(existingInDb);
album.assets.addAll(updated);
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
a.thumbnail.value = thumb;
album.thumbnail.value = thumb;
try {
await _db.writeTxn(() => _db.albums.store(a));
_log.info("Added a new local album to DB: ${ape.name}");
await _db.writeTxn(() => _db.albums.store(album));
_log.info("Added a new local album to DB: ${album.name}");
} on IsarError catch (e) {
_log.severe("Failed to add new local album ${ape.name} to DB", e);
_log.severe("Failed to add new local album ${album.name} to DB", e);
}
}
@ -798,12 +814,15 @@ class SyncService {
}
/// returns `true` if the albums differ on the surface
Future<bool> _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async {
return a.name != b.name ||
a.lastModified == null ||
!a.lastModified!.isAtSameMomentAs(b.modifiedAt) ||
await a.assetCountAsync !=
(await _db.eTags.getById(a.eTagKeyAssetCount))?.assetCount;
Future<bool> _hasAlbumChangeOnDevice(
Album deviceAlbum,
Album dbAlbum,
) async {
return deviceAlbum.name != dbAlbum.name ||
!deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) ||
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) !=
(await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))
?.assetCount;
}
Future<bool> _removeAllLocalAlbumsAndAssets() async {