refactor(mobile): services and providers (#9232)
* refactor(mobile): services and provider * providers
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import 'package:immich_mobile/constants/errors.dart';
|
||||
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class ActivityService with ErrorLoggerMixin {
|
||||
final ApiService _apiService;
|
||||
|
||||
@override
|
||||
final Logger logger = Logger("ActivityService");
|
||||
|
||||
ActivityService(this._apiService);
|
||||
|
||||
Future<List<Activity>> getAllActivities(
|
||||
String albumId, {
|
||||
String? assetId,
|
||||
}) async {
|
||||
return logError(
|
||||
() async {
|
||||
final list = await _apiService.activityApi
|
||||
.getActivities(albumId, assetId: assetId);
|
||||
return list != null ? list.map(Activity.fromDto).toList() : [];
|
||||
},
|
||||
defaultValue: [],
|
||||
errorMessage: "Failed to get all activities for album $albumId",
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> getStatistics(String albumId, {String? assetId}) async {
|
||||
return logError(
|
||||
() async {
|
||||
final dto = await _apiService.activityApi
|
||||
.getActivityStatistics(albumId, assetId: assetId);
|
||||
return dto?.comments ?? 0;
|
||||
},
|
||||
defaultValue: 0,
|
||||
errorMessage: "Failed to statistics for album $albumId",
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> removeActivity(String id) async {
|
||||
return logError(
|
||||
() async {
|
||||
await _apiService.activityApi.deleteActivity(id);
|
||||
return true;
|
||||
},
|
||||
defaultValue: false,
|
||||
errorMessage: "Failed to delete activity",
|
||||
);
|
||||
}
|
||||
|
||||
AsyncFuture<Activity> addActivity(
|
||||
String albumId,
|
||||
ActivityType type, {
|
||||
String? assetId,
|
||||
String? comment,
|
||||
}) async {
|
||||
return guardError(
|
||||
() async {
|
||||
final dto = await _apiService.activityApi.createActivity(
|
||||
ActivityCreateDto(
|
||||
albumId: albumId,
|
||||
type: type == ActivityType.comment
|
||||
? ReactionType.comment
|
||||
: ReactionType.like,
|
||||
assetId: assetId,
|
||||
comment: comment,
|
||||
),
|
||||
);
|
||||
if (dto != null) {
|
||||
return Activity.fromDto(dto);
|
||||
}
|
||||
throw NoResponseDtoError();
|
||||
},
|
||||
errorMessage: "Failed to create $type for album $albumId",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/services/backup.service.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.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:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
final albumServiceProvider = Provider(
|
||||
(ref) => AlbumService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(backupServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class AlbumService {
|
||||
final ApiService _apiService;
|
||||
final UserService _userService;
|
||||
final SyncService _syncService;
|
||||
final Isar _db;
|
||||
final BackupService _backupService;
|
||||
final Logger _log = Logger('AlbumService');
|
||||
Completer<bool> _localCompleter = Completer()..complete(false);
|
||||
Completer<bool> _remoteCompleter = Completer()..complete(false);
|
||||
|
||||
AlbumService(
|
||||
this._apiService,
|
||||
this._userService,
|
||||
this._syncService,
|
||||
this._db,
|
||||
this._backupService,
|
||||
);
|
||||
|
||||
/// Checks all selected device albums for changes of albums and their assets
|
||||
/// Updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshDeviceAlbums() async {
|
||||
if (!_localCompleter.isCompleted) {
|
||||
// guard against concurrent calls
|
||||
_log.info("refreshDeviceAlbums is already in progress");
|
||||
return _localCompleter.future;
|
||||
}
|
||||
_localCompleter = Completer();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
final List<String> excludedIds =
|
||||
await _backupService.excludedAlbumsQuery().idProperty().findAll();
|
||||
final List<String> selectedIds =
|
||||
await _backupService.selectedAlbumsQuery().idProperty().findAll();
|
||||
if (selectedIds.isEmpty) {
|
||||
final numLocal = await _db.albums.where().localIdIsNotNull().count();
|
||||
if (numLocal > 0) {
|
||||
_syncService.removeAllLocalAlbumsAndAssets();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final List<AssetPathEntity> onDevice =
|
||||
await PhotoManager.getAssetPathList(
|
||||
hasAll: true,
|
||||
filterOption: FilterOptionGroup(containsPathModified: true),
|
||||
);
|
||||
_log.info("Found ${onDevice.length} device albums");
|
||||
Set<String>? excludedAssets;
|
||||
if (excludedIds.isNotEmpty) {
|
||||
if (Platform.isIOS) {
|
||||
// iOS and Android device album working principle differ significantly
|
||||
// on iOS, an asset can be in multiple albums
|
||||
// on Android, an asset can only be in exactly one album (folder!) at the same time
|
||||
// thus, on Android, excluding an album can be done by ignoring that album
|
||||
// however, on iOS, it it necessary to load the assets from all excluded
|
||||
// albums and check every asset from any selected album against the set
|
||||
// of excluded assets
|
||||
excludedAssets = await _loadExcludedAssetIds(onDevice, excludedIds);
|
||||
_log.info("Found ${excludedAssets.length} assets to exclude");
|
||||
}
|
||||
// remove all excluded albums
|
||||
onDevice.removeWhere((e) => excludedIds.contains(e.id));
|
||||
_log.info(
|
||||
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
|
||||
);
|
||||
}
|
||||
final hasAll = selectedIds
|
||||
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
|
||||
.whereNotNull()
|
||||
.any((a) => a.isAll);
|
||||
if (hasAll) {
|
||||
if (Platform.isAndroid) {
|
||||
// remove the virtual "Recent" album and keep and individual albums
|
||||
// on Android, the virtual "Recent" `lastModified` value is always null
|
||||
onDevice.removeWhere((e) => e.isAll);
|
||||
_log.info("'Recents' is selected, keeping all individual albums");
|
||||
}
|
||||
} else {
|
||||
// keep only the explicitly selected albums
|
||||
onDevice.removeWhere((e) => !selectedIds.contains(e.id));
|
||||
_log.info("'Recents' is not selected, keeping only selected albums");
|
||||
}
|
||||
changes =
|
||||
await _syncService.syncLocalAlbumAssetsToDb(onDevice, excludedAssets);
|
||||
_log.info("Syncing completed. Changes: $changes");
|
||||
} finally {
|
||||
_localCompleter.complete(changes);
|
||||
}
|
||||
debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<Set<String>> _loadExcludedAssetIds(
|
||||
List<AssetPathEntity> 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));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Checks remote albums (owned if `isShared` is false) for changes,
|
||||
/// updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshRemoteAlbums({required bool isShared}) async {
|
||||
if (!_remoteCompleter.isCompleted) {
|
||||
// guard against concurrent calls
|
||||
return _remoteCompleter.future;
|
||||
}
|
||||
_remoteCompleter = Completer();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
await _userService.refreshUsers();
|
||||
final List<AlbumResponseDto>? serverAlbums = await _apiService.albumApi
|
||||
.getAllAlbums(shared: isShared ? true : null);
|
||||
if (serverAlbums == null) {
|
||||
return false;
|
||||
}
|
||||
changes = await _syncService.syncRemoteAlbumsToDb(
|
||||
serverAlbums,
|
||||
isShared: isShared,
|
||||
loadDetails: (dto) async => dto.assetCount == dto.assets.length
|
||||
? dto
|
||||
: (await _apiService.albumApi.getAlbumInfo(dto.id)) ?? dto,
|
||||
);
|
||||
} finally {
|
||||
_remoteCompleter.complete(changes);
|
||||
}
|
||||
debugPrint("refreshRemoteAlbums took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<Album?> createAlbum(
|
||||
String albumName,
|
||||
Iterable<Asset> assets, [
|
||||
Iterable<User> sharedUsers = const [],
|
||||
]) async {
|
||||
try {
|
||||
AlbumResponseDto? remote = await _apiService.albumApi.createAlbum(
|
||||
CreateAlbumDto(
|
||||
albumName: albumName,
|
||||
assetIds: assets.map((asset) => asset.remoteId!).toList(),
|
||||
sharedWithUserIds: sharedUsers.map((e) => e.id).toList(),
|
||||
),
|
||||
);
|
||||
if (remote != null) {
|
||||
Album album = await Album.remote(remote);
|
||||
await _db.writeTxn(() => _db.albums.store(album));
|
||||
return album;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error createSharedAlbum ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Creates names like Untitled, Untitled (1), Untitled (2), ...
|
||||
*/
|
||||
Future<String> _getNextAlbumName() async {
|
||||
const baseName = "Untitled";
|
||||
for (int round = 0;; round++) {
|
||||
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
|
||||
|
||||
if (null ==
|
||||
await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
|
||||
return proposedName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Album?> createAlbumWithGeneratedName(
|
||||
Iterable<Asset> assets,
|
||||
) async {
|
||||
return createAlbum(
|
||||
await _getNextAlbumName(),
|
||||
assets,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
Future<AlbumAddAssetsResponse?> addAdditionalAssetToAlbum(
|
||||
Iterable<Asset> assets,
|
||||
Album album,
|
||||
) async {
|
||||
try {
|
||||
var response = await _apiService.albumApi.addAssetsToAlbum(
|
||||
album.remoteId!,
|
||||
BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()),
|
||||
);
|
||||
|
||||
if (response != null) {
|
||||
List<Asset> successAssets = [];
|
||||
List<String> duplicatedAssets = [];
|
||||
|
||||
for (final result in response) {
|
||||
if (result.success) {
|
||||
successAssets
|
||||
.add(assets.firstWhere((asset) => asset.remoteId == result.id));
|
||||
} else if (!result.success &&
|
||||
result.error == BulkIdResponseDtoErrorEnum.duplicate) {
|
||||
duplicatedAssets.add(result.id);
|
||||
}
|
||||
}
|
||||
|
||||
await _updateAssets(album.id, add: successAssets);
|
||||
|
||||
return AlbumAddAssetsResponse(
|
||||
alreadyInAlbum: duplicatedAssets,
|
||||
successfullyAdded: successAssets.length,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _updateAssets(
|
||||
int albumId, {
|
||||
Iterable<Asset> add = const [],
|
||||
Iterable<Asset> remove = const [],
|
||||
}) {
|
||||
return _db.writeTxn(() async {
|
||||
final album = await _db.albums.get(albumId);
|
||||
if (album == null) return;
|
||||
await album.assets.update(link: add, unlink: remove);
|
||||
album.startDate =
|
||||
await album.assets.filter().fileCreatedAtProperty().min();
|
||||
album.endDate = await album.assets.filter().fileCreatedAtProperty().max();
|
||||
album.lastModifiedAssetTimestamp =
|
||||
await album.assets.filter().updatedAtProperty().max();
|
||||
await _db.albums.put(album);
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> addAdditionalUserToAlbum(
|
||||
List<String> sharedUserIds,
|
||||
Album album,
|
||||
) async {
|
||||
try {
|
||||
final result = await _apiService.albumApi.addUsersToAlbum(
|
||||
album.remoteId!,
|
||||
AddUsersDto(sharedUserIds: sharedUserIds),
|
||||
);
|
||||
if (result != null) {
|
||||
album.sharedUsers
|
||||
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
|
||||
album.shared = result.shared;
|
||||
await _db.writeTxn(() async {
|
||||
await _db.albums.put(album);
|
||||
await album.sharedUsers.save();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> setActivityEnabled(Album album, bool enabled) async {
|
||||
try {
|
||||
final result = await _apiService.albumApi.updateAlbumInfo(
|
||||
album.remoteId!,
|
||||
UpdateAlbumDto(isActivityEnabled: enabled),
|
||||
);
|
||||
if (result != null) {
|
||||
album.activityEnabled = enabled;
|
||||
await _db.writeTxn(() => _db.albums.put(album));
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error setActivityEnabled ${e.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> deleteAlbum(Album album) async {
|
||||
try {
|
||||
final userId = Store.get(StoreKey.currentUser).isarId;
|
||||
if (album.owner.value?.isarId == userId) {
|
||||
await _apiService.albumApi.deleteAlbum(album.remoteId!);
|
||||
}
|
||||
if (album.shared) {
|
||||
final foreignAssets =
|
||||
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
|
||||
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
final List<Album> albums =
|
||||
await _db.albums.filter().sharedEqualTo(true).findAll();
|
||||
final List<Asset> existing = [];
|
||||
for (Album a in albums) {
|
||||
existing.addAll(
|
||||
await a.assets.filter().not().ownerIdEqualTo(userId).findAll(),
|
||||
);
|
||||
}
|
||||
final List<int> idsToRemove =
|
||||
_syncService.sharedAssetsToRemove(foreignAssets, existing);
|
||||
if (idsToRemove.isNotEmpty) {
|
||||
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
|
||||
}
|
||||
} else {
|
||||
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error deleteAlbum ${e.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> leaveAlbum(Album album) async {
|
||||
try {
|
||||
await _apiService.albumApi.removeUserFromAlbum(album.remoteId!, "me");
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error leaveAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> removeAssetFromAlbum(
|
||||
Album album,
|
||||
Iterable<Asset> assets,
|
||||
) async {
|
||||
try {
|
||||
final response = await _apiService.albumApi.removeAssetFromAlbum(
|
||||
album.remoteId!,
|
||||
BulkIdsDto(
|
||||
ids: assets.map((asset) => asset.remoteId!).toList(),
|
||||
),
|
||||
);
|
||||
if (response != null) {
|
||||
final toRemove = response.every((e) => e.success)
|
||||
? assets
|
||||
: response
|
||||
.where((e) => e.success)
|
||||
.map((e) => assets.firstWhere((a) => a.remoteId == e.id));
|
||||
await _updateAssets(album.id, remove: toRemove);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error removeAssetFromAlbum ${e.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> removeUserFromAlbum(
|
||||
Album album,
|
||||
User user,
|
||||
) async {
|
||||
try {
|
||||
await _apiService.albumApi.removeUserFromAlbum(
|
||||
album.remoteId!,
|
||||
user.id,
|
||||
);
|
||||
|
||||
album.sharedUsers.remove(user);
|
||||
await _db.writeTxn(() async {
|
||||
await album.sharedUsers.update(unlink: [user]);
|
||||
final a = await _db.albums.get(album.id);
|
||||
// trigger watcher
|
||||
await _db.albums.put(a!);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error removeUserFromAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> changeTitleAlbum(
|
||||
Album album,
|
||||
String newAlbumTitle,
|
||||
) async {
|
||||
try {
|
||||
await _apiService.albumApi.updateAlbumInfo(
|
||||
album.remoteId!,
|
||||
UpdateAlbumDto(
|
||||
albumName: newAlbumTitle,
|
||||
),
|
||||
);
|
||||
album.name = newAlbumTitle;
|
||||
await _db.writeTxn(() => _db.albums.put(album));
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error changeTitleAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:http/http.dart';
|
||||
|
||||
class ApiService {
|
||||
late ApiClient _apiClient;
|
||||
|
||||
late UserApi userApi;
|
||||
late AuthenticationApi authenticationApi;
|
||||
late OAuthApi oAuthApi;
|
||||
late AlbumApi albumApi;
|
||||
late AssetApi assetApi;
|
||||
late SearchApi searchApi;
|
||||
late ServerInfoApi serverInfoApi;
|
||||
late PartnerApi partnerApi;
|
||||
late PersonApi personApi;
|
||||
late AuditApi auditApi;
|
||||
late SharedLinkApi sharedLinkApi;
|
||||
late SystemConfigApi systemConfigApi;
|
||||
late ActivityApi activityApi;
|
||||
late DownloadApi downloadApi;
|
||||
late TrashApi trashApi;
|
||||
|
||||
ApiService() {
|
||||
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
|
||||
if (endpoint != null && endpoint.isNotEmpty) {
|
||||
setEndpoint(endpoint);
|
||||
}
|
||||
}
|
||||
String? _accessToken;
|
||||
final _log = Logger("ApiService");
|
||||
|
||||
setEndpoint(String endpoint) {
|
||||
_apiClient = ApiClient(basePath: endpoint);
|
||||
if (_accessToken != null) {
|
||||
setAccessToken(_accessToken!);
|
||||
}
|
||||
userApi = UserApi(_apiClient);
|
||||
authenticationApi = AuthenticationApi(_apiClient);
|
||||
oAuthApi = OAuthApi(_apiClient);
|
||||
albumApi = AlbumApi(_apiClient);
|
||||
assetApi = AssetApi(_apiClient);
|
||||
serverInfoApi = ServerInfoApi(_apiClient);
|
||||
searchApi = SearchApi(_apiClient);
|
||||
partnerApi = PartnerApi(_apiClient);
|
||||
personApi = PersonApi(_apiClient);
|
||||
auditApi = AuditApi(_apiClient);
|
||||
sharedLinkApi = SharedLinkApi(_apiClient);
|
||||
systemConfigApi = SystemConfigApi(_apiClient);
|
||||
activityApi = ActivityApi(_apiClient);
|
||||
downloadApi = DownloadApi(_apiClient);
|
||||
trashApi = TrashApi(_apiClient);
|
||||
}
|
||||
|
||||
Future<String> resolveAndSetEndpoint(String serverUrl) async {
|
||||
final endpoint = await _resolveEndpoint(serverUrl);
|
||||
setEndpoint(endpoint);
|
||||
|
||||
// Save in hivebox for next startup
|
||||
Store.put(StoreKey.serverEndpoint, endpoint);
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
/// Takes a server URL and attempts to resolve the API endpoint.
|
||||
///
|
||||
/// Input: [schema://]host[:port][/path]
|
||||
/// schema - optional (default: https)
|
||||
/// host - required
|
||||
/// port - optional (default: based on schema)
|
||||
/// path - optional
|
||||
Future<String> _resolveEndpoint(String serverUrl) async {
|
||||
final url = sanitizeUrl(serverUrl);
|
||||
|
||||
if (!await _isEndpointAvailable(serverUrl)) {
|
||||
throw ApiException(503, "Server is not reachable");
|
||||
}
|
||||
|
||||
// Check for /.well-known/immich
|
||||
final wellKnownEndpoint = await _getWellKnownEndpoint(url);
|
||||
if (wellKnownEndpoint.isNotEmpty) return wellKnownEndpoint;
|
||||
|
||||
// Otherwise, assume the URL provided is the api endpoint
|
||||
return url;
|
||||
}
|
||||
|
||||
Future<bool> _isEndpointAvailable(String serverUrl) async {
|
||||
final Client client = Client();
|
||||
|
||||
if (!serverUrl.endsWith('/api')) {
|
||||
serverUrl += '/api';
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await client
|
||||
.get(Uri.parse("$serverUrl/server-info/ping"))
|
||||
.timeout(const Duration(seconds: 5));
|
||||
|
||||
_log.info("Pinging server with response code ${response.statusCode}");
|
||||
if (response.statusCode != 200) {
|
||||
_log.severe(
|
||||
"Server Gateway Error: ${response.body} - Cannot communicate to the server",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} on TimeoutException catch (_) {
|
||||
return false;
|
||||
} on SocketException catch (_) {
|
||||
return false;
|
||||
} catch (error, stackTrace) {
|
||||
_log.severe(
|
||||
"Error while checking server availability",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<String> _getWellKnownEndpoint(String baseUrl) async {
|
||||
final Client client = Client();
|
||||
|
||||
try {
|
||||
final res = await client.get(
|
||||
Uri.parse("$baseUrl/.well-known/immich"),
|
||||
headers: {"Accept": "application/json"},
|
||||
);
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
final data = jsonDecode(res.body);
|
||||
final endpoint = data['api']['endpoint'].toString();
|
||||
|
||||
if (endpoint.startsWith('/')) {
|
||||
// Full URL is relative to base
|
||||
return "$baseUrl$endpoint";
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Could not locate /.well-known/immich at $baseUrl");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
setAccessToken(String accessToken) {
|
||||
_accessToken = accessToken;
|
||||
_apiClient.addDefaultHeader('x-immich-user-token', accessToken);
|
||||
}
|
||||
|
||||
ApiClient get apiClient => _apiClient;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
enum AppSettingsEnum<T> {
|
||||
loadPreview<bool>(StoreKey.loadPreview, "loadPreview", true),
|
||||
loadOriginal<bool>(StoreKey.loadOriginal, "loadOriginal", false),
|
||||
themeMode<String>(
|
||||
StoreKey.themeMode,
|
||||
"themeMode",
|
||||
"system",
|
||||
), // "light","dark","system"
|
||||
tilesPerRow<int>(StoreKey.tilesPerRow, "tilesPerRow", 4),
|
||||
dynamicLayout<bool>(StoreKey.dynamicLayout, "dynamicLayout", false),
|
||||
groupAssetsBy<int>(StoreKey.groupAssetsBy, "groupBy", 0),
|
||||
uploadErrorNotificationGracePeriod<int>(
|
||||
StoreKey.uploadErrorNotificationGracePeriod,
|
||||
"uploadErrorNotificationGracePeriod",
|
||||
2,
|
||||
),
|
||||
backgroundBackupTotalProgress<bool>(
|
||||
StoreKey.backgroundBackupTotalProgress,
|
||||
"backgroundBackupTotalProgress",
|
||||
true,
|
||||
),
|
||||
backgroundBackupSingleProgress<bool>(
|
||||
StoreKey.backgroundBackupSingleProgress,
|
||||
"backgroundBackupSingleProgress",
|
||||
false,
|
||||
),
|
||||
storageIndicator<bool>(StoreKey.storageIndicator, "storageIndicator", true),
|
||||
thumbnailCacheSize<int>(
|
||||
StoreKey.thumbnailCacheSize,
|
||||
"thumbnailCacheSize",
|
||||
10000,
|
||||
),
|
||||
imageCacheSize<int>(StoreKey.imageCacheSize, "imageCacheSize", 350),
|
||||
albumThumbnailCacheSize<int>(
|
||||
StoreKey.albumThumbnailCacheSize,
|
||||
"albumThumbnailCacheSize",
|
||||
200,
|
||||
),
|
||||
selectedAlbumSortOrder<int>(
|
||||
StoreKey.selectedAlbumSortOrder,
|
||||
"selectedAlbumSortOrder",
|
||||
0,
|
||||
),
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
|
||||
mapThemeMode<int>(StoreKey.mapThemeMode, null, 0),
|
||||
mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
|
||||
mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false),
|
||||
mapwithPartners<bool>(StoreKey.mapwithPartners, null, false),
|
||||
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
|
||||
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
|
||||
ignoreIcloudAssets<bool>(StoreKey.ignoreIcloudAssets, null, false),
|
||||
selectedAlbumSortReverse<bool>(
|
||||
StoreKey.selectedAlbumSortReverse,
|
||||
null,
|
||||
false,
|
||||
),
|
||||
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
||||
;
|
||||
|
||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||
|
||||
final StoreKey<T> storeKey;
|
||||
final String? hiveKey;
|
||||
final T defaultValue;
|
||||
}
|
||||
|
||||
class AppSettingsService {
|
||||
T getSetting<T>(AppSettingsEnum<T> setting) {
|
||||
return Store.get(setting.storeKey, setting.defaultValue);
|
||||
}
|
||||
|
||||
void setSetting<T>(AppSettingsEnum<T> setting, T value) {
|
||||
Store.put(setting.storeKey, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
// ignore_for_file: null_argument_to_non_null_type
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
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/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/sync.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final assetServiceProvider = Provider(
|
||||
(ref) => AssetService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class AssetService {
|
||||
final ApiService _apiService;
|
||||
final SyncService _syncService;
|
||||
final log = Logger('AssetService');
|
||||
final Isar _db;
|
||||
|
||||
AssetService(
|
||||
this._apiService,
|
||||
this._syncService,
|
||||
this._db,
|
||||
);
|
||||
|
||||
/// Checks the server for updated assets and updates the local database if
|
||||
/// required. Returns `true` if there were any changes.
|
||||
Future<bool> refreshRemoteAssets([User? user]) async {
|
||||
user ??= Store.get<User>(StoreKey.currentUser);
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
final bool changes = await _syncService.syncRemoteAssetsToDb(
|
||||
user,
|
||||
_getRemoteAssetChanges,
|
||||
_getRemoteAssets,
|
||||
);
|
||||
debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// Returns `(null, null)` if changes are invalid -> requires full sync
|
||||
Future<(List<Asset>? toUpsert, List<String>? toDelete)>
|
||||
_getRemoteAssetChanges(User user, DateTime since) async {
|
||||
final deleted = await _apiService.auditApi
|
||||
.getAuditDeletes(since, EntityType.ASSET, userId: user.id);
|
||||
if (deleted == null || deleted.needsFullSync) return (null, null);
|
||||
final assetDto = await _apiService.assetApi
|
||||
.getAllAssets(userId: user.id, updatedAfter: since);
|
||||
if (assetDto == null) return (null, null);
|
||||
return (assetDto.map(Asset.remote).toList(), deleted.ids);
|
||||
}
|
||||
|
||||
/// Returns the list of people of the given asset id.
|
||||
// If the server is not reachable `null` is returned.
|
||||
Future<List<PersonWithFacesResponseDto>?> getRemotePeopleOfAsset(
|
||||
String remoteId,
|
||||
) async {
|
||||
try {
|
||||
final AssetResponseDto? dto =
|
||||
await _apiService.assetApi.getAssetInfo(remoteId);
|
||||
|
||||
return dto?.people;
|
||||
} catch (error, stack) {
|
||||
log.severe(
|
||||
'Error while getting remote asset info: ${error.toString()}',
|
||||
error,
|
||||
stack,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `null` if the server state did not change, else list of assets
|
||||
Future<List<Asset>?> _getRemoteAssets(User user) async {
|
||||
const int chunkSize = 10000;
|
||||
try {
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
final List<Asset> allAssets = [];
|
||||
for (int i = 0;; i += chunkSize) {
|
||||
final List<AssetResponseDto>? assets =
|
||||
await _apiService.assetApi.getAllAssets(
|
||||
userId: user.id,
|
||||
// updatedBefore is important! without it we could
|
||||
// a) get the same Asset multiple times in different versions (when
|
||||
// the asset is modified while the chunks are loaded from the server)
|
||||
// b) miss assets when new assets are inserted in between the calls
|
||||
updatedBefore: now,
|
||||
skip: i,
|
||||
take: chunkSize,
|
||||
);
|
||||
if (assets == null) {
|
||||
return null;
|
||||
}
|
||||
allAssets.addAll(assets.map(Asset.remote));
|
||||
if (assets.length < chunkSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return allAssets;
|
||||
} catch (error, stack) {
|
||||
log.severe(
|
||||
'Error while getting remote assets',
|
||||
error,
|
||||
stack,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> deleteAssets(
|
||||
Iterable<Asset> deleteAssets, {
|
||||
bool? force = false,
|
||||
}) async {
|
||||
try {
|
||||
final List<String> payload = [];
|
||||
|
||||
for (final asset in deleteAssets) {
|
||||
payload.add(asset.remoteId!);
|
||||
}
|
||||
|
||||
await _apiService.assetApi.deleteAssets(
|
||||
AssetBulkDeleteDto(
|
||||
ids: payload,
|
||||
force: force,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch (error, stack) {
|
||||
log.severe("Error while deleting assets", error, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Loads the exif information from the database. If there is none, loads
|
||||
/// the exif info from the server (remote assets only)
|
||||
Future<Asset> loadExif(Asset a) async {
|
||||
a.exifInfo ??= await _db.exifInfos.get(a.id);
|
||||
// fileSize is always filled on the server but not set on client
|
||||
if (a.exifInfo?.fileSize == null) {
|
||||
if (a.isRemote) {
|
||||
final dto = await _apiService.assetApi.getAssetInfo(a.remoteId!);
|
||||
if (dto != null && dto.exifInfo != null) {
|
||||
final newExif = Asset.remote(dto).exifInfo!.copyWith(id: a.id);
|
||||
if (newExif != a.exifInfo) {
|
||||
if (a.isInDb) {
|
||||
_db.writeTxn(() => a.put(_db));
|
||||
} else {
|
||||
debugPrint("[loadExif] parameter Asset is not from DB!");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO implement local exif info parsing
|
||||
}
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
Future<void> updateAssets(
|
||||
List<Asset> assets,
|
||||
UpdateAssetDto updateAssetDto,
|
||||
) async {
|
||||
return await _apiService.assetApi.updateAssets(
|
||||
AssetBulkUpdateDto(
|
||||
ids: assets.map((e) => e.remoteId!).toList(),
|
||||
dateTimeOriginal: updateAssetDto.dateTimeOriginal,
|
||||
isFavorite: updateAssetDto.isFavorite,
|
||||
isArchived: updateAssetDto.isArchived,
|
||||
latitude: updateAssetDto.latitude,
|
||||
longitude: updateAssetDto.longitude,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Asset?>> changeFavoriteStatus(
|
||||
List<Asset> assets,
|
||||
bool isFavorite,
|
||||
) async {
|
||||
try {
|
||||
await updateAssets(assets, UpdateAssetDto(isFavorite: isFavorite));
|
||||
|
||||
for (var element in assets) {
|
||||
element.isFavorite = isFavorite;
|
||||
}
|
||||
|
||||
await _syncService.upsertAssetsWithExif(assets);
|
||||
|
||||
return assets;
|
||||
} catch (error, stack) {
|
||||
log.severe("Error while changing favorite status", error, stack);
|
||||
return Future.value(null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Asset?>> changeArchiveStatus(
|
||||
List<Asset> assets,
|
||||
bool isArchived,
|
||||
) async {
|
||||
try {
|
||||
await updateAssets(assets, UpdateAssetDto(isArchived: isArchived));
|
||||
|
||||
for (var element in assets) {
|
||||
element.isArchived = isArchived;
|
||||
}
|
||||
|
||||
await _syncService.upsertAssetsWithExif(assets);
|
||||
|
||||
return assets;
|
||||
} catch (error, stack) {
|
||||
log.severe("Error while changing archive status", error, stack);
|
||||
return Future.value(null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Asset?>> changeDateTime(
|
||||
List<Asset> assets,
|
||||
String updatedDt,
|
||||
) async {
|
||||
try {
|
||||
await updateAssets(
|
||||
assets,
|
||||
UpdateAssetDto(dateTimeOriginal: updatedDt),
|
||||
);
|
||||
|
||||
for (var element in assets) {
|
||||
element.fileCreatedAt = DateTime.parse(updatedDt);
|
||||
element.exifInfo?.dateTimeOriginal = DateTime.parse(updatedDt);
|
||||
}
|
||||
|
||||
await _syncService.upsertAssetsWithExif(assets);
|
||||
|
||||
return assets;
|
||||
} catch (error, stack) {
|
||||
log.severe("Error while changing date/time status", error, stack);
|
||||
return Future.value(null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Asset?>> changeLocation(
|
||||
List<Asset> assets,
|
||||
LatLng location,
|
||||
) async {
|
||||
try {
|
||||
await updateAssets(
|
||||
assets,
|
||||
UpdateAssetDto(
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
),
|
||||
);
|
||||
|
||||
for (var element in assets) {
|
||||
element.exifInfo?.lat = location.latitude;
|
||||
element.exifInfo?.long = location.longitude;
|
||||
}
|
||||
|
||||
await _syncService.upsertAssetsWithExif(assets);
|
||||
|
||||
return assets;
|
||||
} catch (error, stack) {
|
||||
log.severe("Error while changing location status", error, stack);
|
||||
return Future.value(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AssetDescriptionService {
|
||||
AssetDescriptionService(this._db, this._api);
|
||||
|
||||
final Isar _db;
|
||||
final ApiService _api;
|
||||
|
||||
setDescription(
|
||||
String description,
|
||||
String remoteAssetId,
|
||||
int localExifId,
|
||||
) async {
|
||||
final result = await _api.assetApi.updateAsset(
|
||||
remoteAssetId,
|
||||
UpdateAssetDto(description: description),
|
||||
);
|
||||
|
||||
if (result?.exifInfo?.description != null) {
|
||||
var exifInfo = await _db.exifInfos.get(localExifId);
|
||||
|
||||
if (exifInfo != null) {
|
||||
exifInfo.description = result!.exifInfo!.description;
|
||||
await _db.writeTxn(
|
||||
() => _db.exifInfos.put(exifInfo),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> readLatest(String assetRemoteId, int localExifId) async {
|
||||
final latestAssetFromServer =
|
||||
await _api.assetApi.getAssetInfo(assetRemoteId);
|
||||
final localExifInfo = await _db.exifInfos.get(localExifId);
|
||||
|
||||
if (latestAssetFromServer != null && localExifInfo != null) {
|
||||
localExifInfo.description =
|
||||
latestAssetFromServer.exifInfo?.description ?? '';
|
||||
|
||||
await _db.writeTxn(
|
||||
() => _db.exifInfos.put(localExifInfo),
|
||||
);
|
||||
|
||||
return localExifInfo.description!;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
final assetDescriptionServiceProvider = Provider(
|
||||
(ref) => AssetDescriptionService(
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(apiServiceProvider),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AssetStackService {
|
||||
AssetStackService(this._api);
|
||||
|
||||
final ApiService _api;
|
||||
|
||||
Future<void> updateStack(
|
||||
Asset parentAsset, {
|
||||
List<Asset>? childrenToAdd,
|
||||
List<Asset>? childrenToRemove,
|
||||
}) async {
|
||||
// Guard [local asset]
|
||||
if (parentAsset.remoteId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (childrenToAdd != null) {
|
||||
final toAdd = childrenToAdd
|
||||
.where((e) => e.isRemote)
|
||||
.map((e) => e.remoteId!)
|
||||
.toList();
|
||||
|
||||
await _api.assetApi.updateAssets(
|
||||
AssetBulkUpdateDto(ids: toAdd, stackParentId: parentAsset.remoteId),
|
||||
);
|
||||
}
|
||||
|
||||
if (childrenToRemove != null) {
|
||||
final toRemove = childrenToRemove
|
||||
.where((e) => e.isRemote)
|
||||
.map((e) => e.remoteId!)
|
||||
.toList();
|
||||
await _api.assetApi.updateAssets(
|
||||
AssetBulkUpdateDto(ids: toRemove, removeParent: true),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("Error while updating stack children: ${error.toString()}");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateStackParent(Asset oldParent, Asset newParent) async {
|
||||
// Guard [local asset]
|
||||
if (oldParent.remoteId == null || newParent.remoteId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _api.assetApi.updateStackParent(
|
||||
UpdateStackParentDto(
|
||||
oldParentId: oldParent.remoteId!,
|
||||
newParentId: newParent.remoteId!,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint("Error while updating stack parent: ${error.toString()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final assetStackServiceProvider = Provider(
|
||||
(ref) => AssetStackService(
|
||||
ref.watch(apiServiceProvider),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,600 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/main.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/services/backup.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/backup_progress.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:path_provider_ios/path_provider_ios.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
final backgroundServiceProvider = Provider(
|
||||
(ref) => BackgroundService(),
|
||||
);
|
||||
|
||||
/// Background backup service
|
||||
class BackgroundService {
|
||||
static const String _portNameLock = "immichLock";
|
||||
static const MethodChannel _foregroundChannel =
|
||||
MethodChannel('immich/foregroundChannel');
|
||||
static const MethodChannel _backgroundChannel =
|
||||
MethodChannel('immich/backgroundChannel');
|
||||
static const notifyInterval = Duration(milliseconds: 400);
|
||||
bool _isBackgroundInitialized = false;
|
||||
CancellationToken? _cancellationToken;
|
||||
bool _canceledBySystem = false;
|
||||
int _wantsLockTime = 0;
|
||||
bool _hasLock = false;
|
||||
SendPort? _waitingIsolate;
|
||||
ReceivePort? _rp;
|
||||
bool _errorGracePeriodExceeded = true;
|
||||
int _uploadedAssetsCount = 0;
|
||||
int _assetsToUploadCount = 0;
|
||||
String _lastPrintedDetailContent = "";
|
||||
String? _lastPrintedDetailTitle;
|
||||
late final ThrottleProgressUpdate _throttledNotifiy =
|
||||
ThrottleProgressUpdate(_updateProgress, notifyInterval);
|
||||
late final ThrottleProgressUpdate _throttledDetailNotify =
|
||||
ThrottleProgressUpdate(_updateDetailProgress, notifyInterval);
|
||||
|
||||
bool get isBackgroundInitialized {
|
||||
return _isBackgroundInitialized;
|
||||
}
|
||||
|
||||
/// Ensures that the background service is enqueued if enabled in settings
|
||||
Future<bool> resumeServiceIfEnabled() async {
|
||||
return await isBackgroundBackupEnabled() && await enableService();
|
||||
}
|
||||
|
||||
/// Enqueues the background service
|
||||
Future<bool> enableService({bool immediate = false}) async {
|
||||
try {
|
||||
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
|
||||
final String title =
|
||||
"backup_background_service_default_notification".tr();
|
||||
final bool ok = await _foregroundChannel.invokeMethod(
|
||||
'enable',
|
||||
[callback.toRawHandle(), title, immediate, getServerUrl()],
|
||||
);
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the background service
|
||||
Future<bool> configureService({
|
||||
bool requireUnmetered = true,
|
||||
bool requireCharging = false,
|
||||
int triggerUpdateDelay = 5000,
|
||||
int triggerMaxDelay = 50000,
|
||||
}) async {
|
||||
try {
|
||||
final bool ok = await _foregroundChannel.invokeMethod(
|
||||
'configure',
|
||||
[
|
||||
requireUnmetered,
|
||||
requireCharging,
|
||||
triggerUpdateDelay,
|
||||
triggerMaxDelay,
|
||||
],
|
||||
);
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels the background service (if currently running) and removes it from work queue
|
||||
Future<bool> disableService() async {
|
||||
try {
|
||||
final ok = await _foregroundChannel.invokeMethod('disable');
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the background service is enabled
|
||||
Future<bool> isBackgroundBackupEnabled() async {
|
||||
try {
|
||||
return await _foregroundChannel.invokeMethod("isEnabled");
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if battery optimizations are disabled
|
||||
Future<bool> isIgnoringBatteryOptimizations() async {
|
||||
// iOS does not need battery optimizations enabled
|
||||
if (Platform.isIOS) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return await _foregroundChannel
|
||||
.invokeMethod('isIgnoringBatteryOptimizations');
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Yet to be implemented
|
||||
Future<Uint8List?> digestFile(String path) {
|
||||
return _foregroundChannel.invokeMethod<Uint8List>("digestFile", [path]);
|
||||
}
|
||||
|
||||
Future<List<Uint8List?>?> digestFiles(List<String> paths) {
|
||||
return _foregroundChannel.invokeListMethod<Uint8List?>(
|
||||
"digestFiles",
|
||||
paths,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates the notification shown by the background service
|
||||
Future<bool?> _updateNotification({
|
||||
String? title,
|
||||
String? content,
|
||||
int progress = 0,
|
||||
int max = 0,
|
||||
bool indeterminate = false,
|
||||
bool isDetail = false,
|
||||
bool onlyIfFG = false,
|
||||
}) async {
|
||||
try {
|
||||
if (_isBackgroundInitialized) {
|
||||
return _backgroundChannel.invokeMethod<bool>(
|
||||
'updateNotification',
|
||||
[title, content, progress, max, indeterminate, isDetail, onlyIfFG],
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[_updateNotification] failed to communicate with plugin");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Shows a new priority notification
|
||||
Future<bool> _showErrorNotification({
|
||||
required String title,
|
||||
String? content,
|
||||
String? individualTag,
|
||||
}) async {
|
||||
try {
|
||||
if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
|
||||
return await _backgroundChannel
|
||||
.invokeMethod('showError', [title, content, individualTag]);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[_showErrorNotification] failed to communicate with plugin");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _clearErrorNotifications() async {
|
||||
try {
|
||||
if (_isBackgroundInitialized) {
|
||||
return await _backgroundChannel.invokeMethod('clearErrorNotifications');
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
"[_clearErrorNotifications] failed to communicate with plugin",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// await to ensure this thread (foreground or background) has exclusive access
|
||||
Future<bool> acquireLock() async {
|
||||
if (_hasLock) {
|
||||
debugPrint("WARNING: [acquireLock] called more than once");
|
||||
return true;
|
||||
}
|
||||
final int lockTime = Timeline.now;
|
||||
_wantsLockTime = lockTime;
|
||||
final ReceivePort rp = ReceivePort(_portNameLock);
|
||||
_rp = rp;
|
||||
final SendPort sp = rp.sendPort;
|
||||
|
||||
while (!IsolateNameServer.registerPortWithName(sp, _portNameLock)) {
|
||||
try {
|
||||
await _checkLockReleasedWithHeartbeat(lockTime);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
if (_wantsLockTime != lockTime) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_hasLock = true;
|
||||
rp.listen(_heartbeatListener);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _checkLockReleasedWithHeartbeat(final int lockTime) async {
|
||||
SendPort? other = IsolateNameServer.lookupPortByName(_portNameLock);
|
||||
if (other != null) {
|
||||
final ReceivePort tempRp = ReceivePort();
|
||||
final SendPort tempSp = tempRp.sendPort;
|
||||
final bs = tempRp.asBroadcastStream();
|
||||
while (_wantsLockTime == lockTime) {
|
||||
other.send(tempSp);
|
||||
final dynamic answer = await bs.first
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => null);
|
||||
if (_wantsLockTime != lockTime) {
|
||||
break;
|
||||
}
|
||||
if (answer == null) {
|
||||
// other isolate failed to answer, assuming it exited without releasing the lock
|
||||
if (other == IsolateNameServer.lookupPortByName(_portNameLock)) {
|
||||
IsolateNameServer.removePortNameMapping(_portNameLock);
|
||||
}
|
||||
break;
|
||||
} else if (answer == true) {
|
||||
// other isolate released the lock
|
||||
break;
|
||||
} else if (answer == false) {
|
||||
// other isolate is still active
|
||||
}
|
||||
final dynamic isFinished = await bs.first
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => false);
|
||||
if (isFinished == true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tempRp.close();
|
||||
}
|
||||
}
|
||||
|
||||
void _heartbeatListener(dynamic msg) {
|
||||
if (msg is SendPort) {
|
||||
_waitingIsolate = msg;
|
||||
msg.send(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// releases the exclusive access lock
|
||||
void releaseLock() {
|
||||
_wantsLockTime = 0;
|
||||
if (_hasLock) {
|
||||
IsolateNameServer.removePortNameMapping(_portNameLock);
|
||||
_waitingIsolate?.send(true);
|
||||
_waitingIsolate = null;
|
||||
_hasLock = false;
|
||||
}
|
||||
_rp?.close();
|
||||
_rp = null;
|
||||
}
|
||||
|
||||
void _setupBackgroundCallHandler() {
|
||||
_backgroundChannel.setMethodCallHandler(_callHandler);
|
||||
_isBackgroundInitialized = true;
|
||||
_backgroundChannel.invokeMethod('initialized');
|
||||
}
|
||||
|
||||
Future<bool> _callHandler(MethodCall call) async {
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
if (Platform.isIOS) {
|
||||
// NOTE: I'm not sure this is strictly necessary anymore, but
|
||||
// out of an abundance of caution, we will keep it in until someone
|
||||
// can say for sure
|
||||
PathProviderIOS.registerWith();
|
||||
}
|
||||
switch (call.method) {
|
||||
case "backgroundProcessing":
|
||||
case "onAssetsChanged":
|
||||
try {
|
||||
_clearErrorNotifications();
|
||||
|
||||
// iOS should time out after some threshhold so it doesn't wait
|
||||
// indefinitely and can run later
|
||||
// Android is fine to wait here until the lock releases
|
||||
final waitForLock = Platform.isIOS
|
||||
? acquireLock().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => false,
|
||||
)
|
||||
: acquireLock();
|
||||
|
||||
final bool hasAccess = await waitForLock;
|
||||
if (!hasAccess) {
|
||||
debugPrint("[_callHandler] could not acquire lock, exiting");
|
||||
return false;
|
||||
}
|
||||
|
||||
final translationsOk = await loadTranslations();
|
||||
if (!translationsOk) {
|
||||
debugPrint("[_callHandler] could not load translations");
|
||||
}
|
||||
|
||||
final bool ok = await _onAssetsChanged();
|
||||
return ok;
|
||||
} catch (error) {
|
||||
debugPrint(error.toString());
|
||||
return false;
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
case "systemStop":
|
||||
_canceledBySystem = true;
|
||||
_cancellationToken?.cancel();
|
||||
return true;
|
||||
default:
|
||||
debugPrint("Unknown method ${call.method}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _onAssetsChanged() async {
|
||||
final Isar db = await loadDb();
|
||||
|
||||
ApiService apiService = ApiService();
|
||||
apiService.setAccessToken(Store.get(StoreKey.accessToken));
|
||||
AppSettingsService settingService = AppSettingsService();
|
||||
BackupService backupService = BackupService(apiService, db, settingService);
|
||||
AppSettingsService settingsService = AppSettingsService();
|
||||
|
||||
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
|
||||
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
|
||||
if (selectedAlbums.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await PhotoManager.setIgnorePermissionCheck(true);
|
||||
|
||||
do {
|
||||
final bool backupOk = await _runBackup(
|
||||
backupService,
|
||||
settingsService,
|
||||
selectedAlbums,
|
||||
excludedAlbums,
|
||||
);
|
||||
if (backupOk) {
|
||||
await Store.delete(StoreKey.backupFailedSince);
|
||||
final backupAlbums = [...selectedAlbums, ...excludedAlbums];
|
||||
backupAlbums.sortBy((e) => e.id);
|
||||
db.writeTxnSync(() {
|
||||
final dbAlbums = db.backupAlbums.where().sortById().findAllSync();
|
||||
final List<int> toDelete = [];
|
||||
final List<BackupAlbum> toUpsert = [];
|
||||
// stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state
|
||||
diffSortedListsSync(
|
||||
dbAlbums,
|
||||
backupAlbums,
|
||||
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
|
||||
both: (BackupAlbum a, BackupAlbum b) {
|
||||
a.lastBackup = a.lastBackup.isAfter(b.lastBackup)
|
||||
? a.lastBackup
|
||||
: b.lastBackup;
|
||||
toUpsert.add(a);
|
||||
return true;
|
||||
},
|
||||
onlyFirst: (BackupAlbum a) => toUpsert.add(a),
|
||||
onlySecond: (BackupAlbum b) => toDelete.add(b.isarId),
|
||||
);
|
||||
db.backupAlbums.deleteAllSync(toDelete);
|
||||
db.backupAlbums.putAllSync(toUpsert);
|
||||
});
|
||||
} else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
|
||||
Store.put(StoreKey.backupFailedSince, DateTime.now());
|
||||
return false;
|
||||
}
|
||||
// Android should check for new assets added while performing backup
|
||||
} while (Platform.isAndroid &&
|
||||
true ==
|
||||
await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _runBackup(
|
||||
BackupService backupService,
|
||||
AppSettingsService settingsService,
|
||||
List<BackupAlbum> selectedAlbums,
|
||||
List<BackupAlbum> excludedAlbums,
|
||||
) async {
|
||||
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
|
||||
final bool notifyTotalProgress = settingsService
|
||||
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
|
||||
final bool notifySingleProgress = settingsService
|
||||
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
|
||||
|
||||
if (_canceledBySystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<AssetEntity> toUpload = await backupService.buildUploadCandidates(
|
||||
selectedAlbums,
|
||||
excludedAlbums,
|
||||
);
|
||||
|
||||
try {
|
||||
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
|
||||
} catch (e) {
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_error_title".tr(),
|
||||
content: "backup_background_service_connection_failed_message".tr(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_canceledBySystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toUpload.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
_assetsToUploadCount = toUpload.length;
|
||||
_uploadedAssetsCount = 0;
|
||||
_updateNotification(
|
||||
title: "backup_background_service_in_progress_notification".tr(),
|
||||
content: notifyTotalProgress
|
||||
? formatAssetBackupProgress(
|
||||
_uploadedAssetsCount,
|
||||
_assetsToUploadCount,
|
||||
)
|
||||
: null,
|
||||
progress: 0,
|
||||
max: notifyTotalProgress ? _assetsToUploadCount : 0,
|
||||
indeterminate: !notifyTotalProgress,
|
||||
onlyIfFG: !notifyTotalProgress,
|
||||
);
|
||||
|
||||
_cancellationToken = CancellationToken();
|
||||
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
|
||||
|
||||
final bool ok = await backupService.backupAsset(
|
||||
toUpload,
|
||||
_cancellationToken!,
|
||||
pmProgressHandler,
|
||||
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {},
|
||||
notifySingleProgress ? _onProgress : (sent, total) {},
|
||||
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
|
||||
_onBackupError,
|
||||
sortAssets: true,
|
||||
);
|
||||
if (!ok && !_cancellationToken!.isCancelled) {
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_error_title".tr(),
|
||||
content: "backup_background_service_backup_failed_message".tr(),
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) {
|
||||
_uploadedAssetsCount++;
|
||||
_throttledNotifiy();
|
||||
}
|
||||
|
||||
void _onProgress(int sent, int total) {
|
||||
_throttledDetailNotify(progress: sent, total: total);
|
||||
}
|
||||
|
||||
void _updateDetailProgress(String? title, int progress, int total) {
|
||||
final String msg =
|
||||
total > 0 ? humanReadableBytesProgress(progress, total) : "";
|
||||
// only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
|
||||
if (msg != _lastPrintedDetailContent || _lastPrintedDetailTitle != title) {
|
||||
_lastPrintedDetailContent = msg;
|
||||
_lastPrintedDetailTitle = title;
|
||||
_updateNotification(
|
||||
progress: total > 0 ? (progress * 1000) ~/ total : 0,
|
||||
max: 1000,
|
||||
isDetail: true,
|
||||
title: title,
|
||||
content: msg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateProgress(String? title, int progress, int total) {
|
||||
_updateNotification(
|
||||
progress: _uploadedAssetsCount,
|
||||
max: _assetsToUploadCount,
|
||||
title: title,
|
||||
content: formatAssetBackupProgress(
|
||||
_uploadedAssetsCount,
|
||||
_assetsToUploadCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_upload_failure_notification"
|
||||
.tr(args: [errorAssetInfo.fileName]),
|
||||
individualTag: errorAssetInfo.id,
|
||||
);
|
||||
}
|
||||
|
||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||
_throttledDetailNotify.title =
|
||||
"backup_background_service_current_upload_notification"
|
||||
.tr(args: [currentUploadAsset.fileName]);
|
||||
_throttledDetailNotify.progress = 0;
|
||||
_throttledDetailNotify.total = 0;
|
||||
}
|
||||
|
||||
bool _isErrorGracePeriodExceeded(AppSettingsService appSettingsService) {
|
||||
final int value = appSettingsService
|
||||
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
|
||||
if (value == 0) {
|
||||
return true;
|
||||
} else if (value == 5) {
|
||||
return false;
|
||||
}
|
||||
final DateTime? failedSince = Store.tryGet(StoreKey.backupFailedSince);
|
||||
if (failedSince == null) {
|
||||
return false;
|
||||
}
|
||||
final Duration duration = DateTime.now().difference(failedSince);
|
||||
if (value == 1) {
|
||||
return duration > const Duration(minutes: 30);
|
||||
} else if (value == 2) {
|
||||
return duration > const Duration(hours: 2);
|
||||
} else if (value == 3) {
|
||||
return duration > const Duration(hours: 8);
|
||||
} else if (value == 4) {
|
||||
return duration > const Duration(hours: 24);
|
||||
}
|
||||
assert(false, "Invalid value");
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<DateTime?> getIOSBackupLastRun(IosBackgroundTask task) async {
|
||||
if (!Platform.isIOS) {
|
||||
return null;
|
||||
}
|
||||
// Seconds since last run
|
||||
final double? lastRun = task == IosBackgroundTask.fetch
|
||||
? await _foregroundChannel.invokeMethod('lastBackgroundFetchTime')
|
||||
: await _foregroundChannel.invokeMethod('lastBackgroundProcessingTime');
|
||||
if (lastRun == null) {
|
||||
return null;
|
||||
}
|
||||
final time = DateTime.fromMillisecondsSinceEpoch(lastRun.toInt() * 1000);
|
||||
return time;
|
||||
}
|
||||
|
||||
Future<int> getIOSBackupNumberOfProcesses() async {
|
||||
if (!Platform.isIOS) {
|
||||
return 0;
|
||||
}
|
||||
return await _foregroundChannel.invokeMethod('numberOfBackgroundProcesses');
|
||||
}
|
||||
|
||||
Future<bool> getIOSBackgroundAppRefreshEnabled() async {
|
||||
if (!Platform.isIOS) {
|
||||
return false;
|
||||
}
|
||||
return await _foregroundChannel.invokeMethod('backgroundAppRefreshEnabled');
|
||||
}
|
||||
}
|
||||
|
||||
enum IosBackgroundTask { fetch, processing }
|
||||
|
||||
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
||||
@pragma('vm:entry-point')
|
||||
void _nativeEntry() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
BackgroundService backgroundService = BackgroundService();
|
||||
backgroundService._setupBackgroundCallHandler();
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:cancellation_token_http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final backupServiceProvider = Provider(
|
||||
(ref) => BackupService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class BackupService {
|
||||
final httpClient = http.Client();
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
final Logger _log = Logger("BackupService");
|
||||
final AppSettingsService _appSetting;
|
||||
|
||||
BackupService(this._apiService, this._db, this._appSetting);
|
||||
|
||||
Future<List<String>?> getDeviceBackupAsset() async {
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
try {
|
||||
return await _apiService.assetApi.getAllUserAssetsByDeviceId(deviceId);
|
||||
} catch (e) {
|
||||
debugPrint('Error [getDeviceBackupAsset] ${e.toString()}');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveDuplicatedAssetIds(List<String> deviceAssetIds) {
|
||||
final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList();
|
||||
return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates));
|
||||
}
|
||||
|
||||
/// Get duplicated asset id from database
|
||||
Future<Set<String>> getDuplicatedAssetIds() async {
|
||||
final duplicates = await _db.duplicatedAssets.where().findAll();
|
||||
return duplicates.map((e) => e.id).toSet();
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
selectedAlbumsQuery() =>
|
||||
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.select);
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
excludedAlbumsQuery() =>
|
||||
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
|
||||
|
||||
/// Returns all assets newer than the last successful backup per album
|
||||
Future<List<AssetEntity>> buildUploadCandidates(
|
||||
List<BackupAlbum> selectedBackupAlbums,
|
||||
List<BackupAlbum> excludedBackupAlbums,
|
||||
) 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);
|
||||
if (selectedAlbums.every((e) => e == null)) {
|
||||
return [];
|
||||
}
|
||||
final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
|
||||
if (allIdx != -1) {
|
||||
final List<AssetPathEntity?> excludedAlbums =
|
||||
await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now);
|
||||
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedAlbums.slice(allIdx, allIdx + 1),
|
||||
selectedBackupAlbums.slice(allIdx, allIdx + 1),
|
||||
now,
|
||||
);
|
||||
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
|
||||
excludedAlbums,
|
||||
excludedBackupAlbums,
|
||||
now,
|
||||
);
|
||||
return toAdd.toSet().difference(toRemove.toSet()).toList();
|
||||
} else {
|
||||
return await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedAlbums,
|
||||
selectedBackupAlbums,
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
|
||||
List<BackupAlbum> albums,
|
||||
FilterOptionGroup filter,
|
||||
DateTime now,
|
||||
) async {
|
||||
List<AssetPathEntity?> result = [];
|
||||
for (BackupAlbum a in albums) {
|
||||
try {
|
||||
final AssetPathEntity album =
|
||||
await AssetPathEntity.obtainPathFromProperties(
|
||||
id: a.id,
|
||||
optionGroup: filter.copyWith(
|
||||
updateTimeCond: DateTimeCond(
|
||||
// subtract 2 seconds to prevent missing assets due to rounding issues
|
||||
min: a.lastBackup.subtract(const Duration(seconds: 2)),
|
||||
max: now,
|
||||
),
|
||||
),
|
||||
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<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
|
||||
List<AssetPathEntity?> albums,
|
||||
List<BackupAlbum> backupAlbums,
|
||||
DateTime now,
|
||||
) async {
|
||||
List<AssetEntity> result = [];
|
||||
for (int i = 0; i < albums.length; i++) {
|
||||
final AssetPathEntity? a = albums[i];
|
||||
if (a != null &&
|
||||
a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) {
|
||||
result.addAll(
|
||||
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
|
||||
);
|
||||
backupAlbums[i].lastBackup = now;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Returns a new list of assets not yet uploaded
|
||||
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
|
||||
List<AssetEntity> candidates,
|
||||
) async {
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
|
||||
candidates = duplicatedAssetIds.isEmpty
|
||||
? candidates
|
||||
: candidates
|
||||
.whereNot((asset) => duplicatedAssetIds.contains(asset.id))
|
||||
.toList();
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
final Set<String> existing = {};
|
||||
try {
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
final CheckExistingAssetsResponseDto? duplicates =
|
||||
await _apiService.assetApi.checkExistingAssets(
|
||||
CheckExistingAssetsDto(
|
||||
deviceAssetIds: candidates.map((e) => e.id).toList(),
|
||||
deviceId: deviceId,
|
||||
),
|
||||
);
|
||||
if (duplicates != null) {
|
||||
existing.addAll(duplicates.existingIds);
|
||||
}
|
||||
} on ApiException {
|
||||
// workaround for older server versions or when checking for too many assets at once
|
||||
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
|
||||
if (allAssetsInDatabase != null) {
|
||||
existing.addAll(allAssetsInDatabase);
|
||||
}
|
||||
}
|
||||
return existing.isEmpty
|
||||
? candidates
|
||||
: candidates.whereNot((e) => existing.contains(e.id)).toList();
|
||||
}
|
||||
|
||||
Future<bool> backupAsset(
|
||||
Iterable<AssetEntity> assetList,
|
||||
http.CancellationToken cancelToken,
|
||||
PMProgressHandler? pmProgressHandler,
|
||||
Function(String, String, bool) uploadSuccessCb,
|
||||
Function(int, int) uploadProgressCb,
|
||||
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
|
||||
Function(ErrorUploadAsset) errorCb, {
|
||||
bool sortAssets = false,
|
||||
}) async {
|
||||
final bool isIgnoreIcloudAssets =
|
||||
_appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets);
|
||||
|
||||
if (Platform.isAndroid &&
|
||||
!(await Permission.accessMediaLocation.status).isGranted) {
|
||||
// double check that permission is granted here, to guard against
|
||||
// uploading corrupt assets without EXIF information
|
||||
_log.warning("Media location permission is not granted. "
|
||||
"Cannot access original assets for backup.");
|
||||
return false;
|
||||
}
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
bool anyErrors = false;
|
||||
final List<String> duplicatedAssetIds = [];
|
||||
|
||||
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
|
||||
if (Platform.isIOS) {
|
||||
await PhotoManager.requestPermissionExtend();
|
||||
}
|
||||
|
||||
List<AssetEntity> assetsToUpload = sortAssets
|
||||
// Upload images before video assets
|
||||
// these are further sorted by using their creation date
|
||||
? assetList.sorted(
|
||||
(a, b) {
|
||||
final cmp = a.typeInt - b.typeInt;
|
||||
if (cmp != 0) return cmp;
|
||||
return a.createDateTime.compareTo(b.createDateTime);
|
||||
},
|
||||
)
|
||||
: assetList.toList();
|
||||
|
||||
for (var entity in assetsToUpload) {
|
||||
File? file;
|
||||
File? livePhotoFile;
|
||||
|
||||
try {
|
||||
final isAvailableLocally =
|
||||
await entity.isLocallyAvailable(isOrigin: true);
|
||||
|
||||
// Handle getting files from iCloud
|
||||
if (!isAvailableLocally && Platform.isIOS) {
|
||||
// Skip iCloud assets if the user has disabled this feature
|
||||
if (isIgnoreIcloudAssets) {
|
||||
continue;
|
||||
}
|
||||
|
||||
setCurrentUploadAssetCb(
|
||||
CurrentUploadAsset(
|
||||
id: entity.id,
|
||||
fileCreatedAt: entity.createDateTime.year == 1970
|
||||
? entity.modifiedDateTime
|
||||
: entity.createDateTime,
|
||||
fileName: await entity.titleAsync,
|
||||
fileType: _getAssetType(entity.type),
|
||||
iCloudAsset: true,
|
||||
),
|
||||
);
|
||||
|
||||
file = await entity.loadFile(progressHandler: pmProgressHandler);
|
||||
livePhotoFile = await entity.loadFile(
|
||||
withSubtype: true,
|
||||
progressHandler: pmProgressHandler,
|
||||
);
|
||||
} else {
|
||||
if (entity.type == AssetType.video) {
|
||||
file = await entity.originFile;
|
||||
} else {
|
||||
file = await entity.originFile.timeout(const Duration(seconds: 5));
|
||||
if (entity.isLivePhoto) {
|
||||
livePhotoFile = await entity.originFileWithSubtype
|
||||
.timeout(const Duration(seconds: 5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (file != null) {
|
||||
String originalFileName = await entity.titleAsync;
|
||||
var fileStream = file.openRead();
|
||||
var assetRawUploadData = http.MultipartFile(
|
||||
"assetData",
|
||||
fileStream,
|
||||
file.lengthSync(),
|
||||
filename: originalFileName,
|
||||
);
|
||||
|
||||
var req = MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$savedEndpoint/asset/upload'),
|
||||
onProgress: ((bytes, totalBytes) =>
|
||||
uploadProgressCb(bytes, totalBytes)),
|
||||
);
|
||||
req.headers["x-immich-user-token"] = Store.get(StoreKey.accessToken);
|
||||
req.headers["Transfer-Encoding"] = "chunked";
|
||||
|
||||
req.fields['deviceAssetId'] = entity.id;
|
||||
req.fields['deviceId'] = deviceId;
|
||||
req.fields['fileCreatedAt'] =
|
||||
entity.createDateTime.toUtc().toIso8601String();
|
||||
req.fields['fileModifiedAt'] =
|
||||
entity.modifiedDateTime.toUtc().toIso8601String();
|
||||
req.fields['isFavorite'] = entity.isFavorite.toString();
|
||||
req.fields['duration'] = entity.videoDuration.toString();
|
||||
|
||||
req.files.add(assetRawUploadData);
|
||||
|
||||
var fileSize = file.lengthSync();
|
||||
|
||||
if (entity.isLivePhoto) {
|
||||
if (livePhotoFile != null) {
|
||||
final livePhotoTitle = p.setExtension(
|
||||
originalFileName,
|
||||
p.extension(livePhotoFile.path),
|
||||
);
|
||||
final fileStream = livePhotoFile.openRead();
|
||||
final livePhotoRawUploadData = http.MultipartFile(
|
||||
"livePhotoData",
|
||||
fileStream,
|
||||
livePhotoFile.lengthSync(),
|
||||
filename: livePhotoTitle,
|
||||
);
|
||||
req.files.add(livePhotoRawUploadData);
|
||||
fileSize += livePhotoFile.lengthSync();
|
||||
} else {
|
||||
_log.warning(
|
||||
"Failed to obtain motion part of the livePhoto - $originalFileName",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentUploadAssetCb(
|
||||
CurrentUploadAsset(
|
||||
id: entity.id,
|
||||
fileCreatedAt: entity.createDateTime.year == 1970
|
||||
? entity.modifiedDateTime
|
||||
: entity.createDateTime,
|
||||
fileName: originalFileName,
|
||||
fileType: _getAssetType(entity.type),
|
||||
fileSize: fileSize,
|
||||
iCloudAsset: false,
|
||||
),
|
||||
);
|
||||
|
||||
var response =
|
||||
await httpClient.send(req, cancellationToken: cancelToken);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// asset is a duplicate (already exists on the server)
|
||||
duplicatedAssetIds.add(entity.id);
|
||||
uploadSuccessCb(entity.id, deviceId, true);
|
||||
} else if (response.statusCode == 201) {
|
||||
// stored a new asset on the server
|
||||
uploadSuccessCb(entity.id, deviceId, false);
|
||||
} else {
|
||||
var data = await response.stream.bytesToString();
|
||||
var error = jsonDecode(data);
|
||||
var errorMessage = error['message'] ?? error['error'];
|
||||
|
||||
debugPrint(
|
||||
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
|
||||
);
|
||||
|
||||
errorCb(
|
||||
ErrorUploadAsset(
|
||||
asset: entity,
|
||||
id: entity.id,
|
||||
fileCreatedAt: entity.createDateTime,
|
||||
fileName: originalFileName,
|
||||
fileType: _getAssetType(entity.type),
|
||||
errorMessage: errorMessage,
|
||||
),
|
||||
);
|
||||
|
||||
if (errorMessage == "Quota has been exceeded!") {
|
||||
anyErrors = true;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} on http.CancelledException {
|
||||
debugPrint("Backup was cancelled by the user");
|
||||
anyErrors = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
debugPrint("ERROR backupAsset: ${e.toString()}");
|
||||
anyErrors = true;
|
||||
continue;
|
||||
} finally {
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
await file?.delete();
|
||||
await livePhotoFile?.delete();
|
||||
} catch (e) {
|
||||
debugPrint("ERROR deleting file: ${e.toString()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (duplicatedAssetIds.isNotEmpty) {
|
||||
await _saveDuplicatedAssetIds(duplicatedAssetIds);
|
||||
}
|
||||
return !anyErrors;
|
||||
}
|
||||
|
||||
String _getAssetType(AssetType assetType) {
|
||||
switch (assetType) {
|
||||
case AssetType.audio:
|
||||
return "AUDIO";
|
||||
case AssetType.image:
|
||||
return "IMAGE";
|
||||
case AssetType.video:
|
||||
return "VIDEO";
|
||||
case AssetType.other:
|
||||
return "OTHER";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MultipartRequest extends http.MultipartRequest {
|
||||
/// Creates a new [MultipartRequest].
|
||||
MultipartRequest(
|
||||
super.method,
|
||||
super.url, {
|
||||
required this.onProgress,
|
||||
});
|
||||
|
||||
final void Function(int bytes, int totalBytes) onProgress;
|
||||
|
||||
/// Freezes all mutable fields and returns a
|
||||
/// single-subscription [http.ByteStream]
|
||||
/// that will emit the request body.
|
||||
@override
|
||||
http.ByteStream finalize() {
|
||||
final byteStream = super.finalize();
|
||||
|
||||
final total = contentLength;
|
||||
var bytes = 0;
|
||||
|
||||
final t = StreamTransformer.fromHandlers(
|
||||
handleData: (List<int> data, EventSink<List<int>> sink) {
|
||||
bytes += data.length;
|
||||
onProgress.call(bytes, total);
|
||||
sink.add(data);
|
||||
},
|
||||
);
|
||||
final stream = byteStream.transform(t);
|
||||
return http.ByteStream(stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/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/providers/db.provider.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;
|
||||
|
||||
BackupVerificationService(this._db);
|
||||
|
||||
/// Returns at most [limit] assets that were backed up without exif
|
||||
Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
|
||||
final owner = Store.get(StoreKey.currentUser).isarId;
|
||||
final List<Asset> onlyLocal = await _db.assets
|
||||
.where()
|
||||
.remoteIdIsNull()
|
||||
.filter()
|
||||
.ownerIdEqualTo(owner)
|
||||
.localIdIsNotNull()
|
||||
.findAll();
|
||||
final List<Asset> remoteMatches = await _getMatches(
|
||||
_db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(),
|
||||
owner,
|
||||
onlyLocal,
|
||||
limit,
|
||||
);
|
||||
final List<Asset> localMatches = await _getMatches(
|
||||
_db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(),
|
||||
owner,
|
||||
remoteMatches,
|
||||
limit,
|
||||
);
|
||||
|
||||
final List<Asset> deleteCandidates = [], originals = [];
|
||||
|
||||
await diffSortedLists(
|
||||
remoteMatches,
|
||||
localMatches,
|
||||
compare: (a, b) => a.fileName.compareTo(b.fileName),
|
||||
both: (a, b) async {
|
||||
a.exifInfo = await _db.exifInfos.get(a.id);
|
||||
deleteCandidates.add(a);
|
||||
originals.add(b);
|
||||
return false;
|
||||
},
|
||||
onlyFirst: (a) {},
|
||||
onlySecond: (b) {},
|
||||
);
|
||||
final isolateToken = ServicesBinding.rootIsolateToken!;
|
||||
final List<Asset> toDelete;
|
||||
if (deleteCandidates.length > 10) {
|
||||
// performs 2 checks in parallel for a nice speedup
|
||||
final half = deleteCandidates.length ~/ 2;
|
||||
final lower = compute(
|
||||
_computeSaveToDelete,
|
||||
(
|
||||
deleteCandidates: deleteCandidates.slice(0, half),
|
||||
originals: originals.slice(0, half),
|
||||
auth: Store.get(StoreKey.accessToken),
|
||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||
rootIsolateToken: isolateToken,
|
||||
),
|
||||
);
|
||||
final upper = compute(
|
||||
_computeSaveToDelete,
|
||||
(
|
||||
deleteCandidates: deleteCandidates.slice(half),
|
||||
originals: originals.slice(half),
|
||||
auth: Store.get(StoreKey.accessToken),
|
||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||
rootIsolateToken: isolateToken,
|
||||
),
|
||||
);
|
||||
toDelete = await lower + await upper;
|
||||
} else {
|
||||
toDelete = await compute(
|
||||
_computeSaveToDelete,
|
||||
(
|
||||
deleteCandidates: deleteCandidates,
|
||||
originals: originals,
|
||||
auth: Store.get(StoreKey.accessToken),
|
||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||
rootIsolateToken: isolateToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
return toDelete;
|
||||
}
|
||||
|
||||
static Future<List<Asset>> _computeSaveToDelete(
|
||||
({
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> originals,
|
||||
String auth,
|
||||
String endpoint,
|
||||
RootIsolateToken rootIsolateToken,
|
||||
}) tuple,
|
||||
) async {
|
||||
assert(tuple.deleteCandidates.length == tuple.originals.length);
|
||||
final List<Asset> result = [];
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
|
||||
await PhotoManager.setIgnorePermissionCheck(true);
|
||||
final ApiService apiService = ApiService();
|
||||
apiService.setEndpoint(tuple.endpoint);
|
||||
apiService.setAccessToken(tuple.auth);
|
||||
for (int i = 0; i < tuple.deleteCandidates.length; i++) {
|
||||
if (await _compareAssets(
|
||||
tuple.deleteCandidates[i],
|
||||
tuple.originals[i],
|
||||
apiService,
|
||||
)) {
|
||||
result.add(tuple.deleteCandidates[i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<bool> _compareAssets(
|
||||
Asset remote,
|
||||
Asset local,
|
||||
ApiService apiService,
|
||||
) async {
|
||||
if (remote.checksum == local.checksum) return false;
|
||||
ExifInfo? exif = remote.exifInfo;
|
||||
if (exif != null && exif.lat != null) return false;
|
||||
if (exif == null || exif.fileSize == null) {
|
||||
final dto = await apiService.assetApi.getAssetInfo(remote.remoteId!);
|
||||
if (dto != null && dto.exifInfo != null) {
|
||||
exif = ExifInfo.fromDto(dto.exifInfo!);
|
||||
}
|
||||
}
|
||||
final file = await local.local!.originFile;
|
||||
if (exif != null && file != null && exif.fileSize != null) {
|
||||
final origSize = await file.length();
|
||||
if (exif.fileSize! == origSize || exif.fileSize! != origSize) {
|
||||
final latLng = await local.local!.latlngAsync();
|
||||
|
||||
if (exif.lat == null &&
|
||||
latLng.latitude != null &&
|
||||
(remote.fileCreatedAt.isAtSameMomentAs(local.fileCreatedAt) ||
|
||||
remote.fileModifiedAt.isAtSameMomentAs(local.fileModifiedAt) ||
|
||||
_sameExceptTimeZone(
|
||||
remote.fileCreatedAt,
|
||||
local.fileCreatedAt,
|
||||
))) {
|
||||
if (remote.type == AssetType.video) {
|
||||
// it's very unlikely that a video of same length, filesize, name
|
||||
// and date is wrong match. Cannot easily compare videos anyway
|
||||
return true;
|
||||
}
|
||||
|
||||
// for images: make sure they are pixel-wise identical
|
||||
// (skip first few KBs containing metadata)
|
||||
final Uint64List localImage =
|
||||
_fakeDecodeImg(local, await file.readAsBytes());
|
||||
final res = await apiService.downloadApi
|
||||
.downloadFileWithHttpInfo(remote.remoteId!);
|
||||
final Uint64List remoteImage = _fakeDecodeImg(remote, res.bodyBytes);
|
||||
|
||||
final eq = const ListEquality().equals(remoteImage, localImage);
|
||||
return eq;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Uint64List _fakeDecodeImg(Asset asset, Uint8List bytes) {
|
||||
const headerLength = 131072; // assume header is at most 128 KB
|
||||
final start = bytes.length < headerLength * 2
|
||||
? (bytes.length ~/ (4 * 8)) * 8
|
||||
: headerLength;
|
||||
return bytes.buffer.asUint64List(start);
|
||||
}
|
||||
|
||||
static Future<List<Asset>> _getMatches(
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> query,
|
||||
int ownerId,
|
||||
List<Asset> assets,
|
||||
int limit,
|
||||
) =>
|
||||
query
|
||||
.ownerIdEqualTo(ownerId)
|
||||
.anyOf(
|
||||
assets,
|
||||
(q, Asset a) => q
|
||||
.fileNameEqualTo(a.fileName)
|
||||
.and()
|
||||
.durationInSecondsEqualTo(a.durationInSeconds)
|
||||
.and()
|
||||
.fileCreatedAtBetween(
|
||||
a.fileCreatedAt.subtract(const Duration(hours: 12)),
|
||||
a.fileCreatedAt.add(const Duration(hours: 12)),
|
||||
)
|
||||
.and()
|
||||
.not()
|
||||
.checksumEqualTo(a.checksum),
|
||||
)
|
||||
.sortByFileName()
|
||||
.thenByFileCreatedAt()
|
||||
.thenByFileModifiedAt()
|
||||
.limit(limit)
|
||||
.findAll();
|
||||
|
||||
static bool _sameExceptTimeZone(DateTime a, DateTime b) {
|
||||
final ms = a.isAfter(b)
|
||||
? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch
|
||||
: b.millisecondsSinceEpoch - a.microsecondsSinceEpoch;
|
||||
final x = ms / (1000 * 60 * 30);
|
||||
final y = ms ~/ (1000 * 60 * 30);
|
||||
return y.toDouble() == x && y < 24;
|
||||
}
|
||||
}
|
||||
|
||||
final backupVerificationServiceProvider = Provider(
|
||||
(ref) => BackupVerificationService(
|
||||
ref.watch(dbProvider),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,156 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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';
|
||||
import 'package:immich_mobile/entities/device_asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
|
||||
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);
|
||||
final Isar _db;
|
||||
final BackgroundService _backgroundService;
|
||||
final _log = Logger('HashService');
|
||||
|
||||
/// Returns all assets that were successfully hashed
|
||||
Future<List<Asset>> getHashedAssets(
|
||||
AssetPathEntity album, {
|
||||
int start = 0,
|
||||
int end = 0x7fffffffffffffff,
|
||||
Set<String>? excludedAssets,
|
||||
}) async {
|
||||
final entities = await album.getAssetListRange(start: start, end: end);
|
||||
final filtered = excludedAssets == null
|
||||
? entities
|
||||
: entities.where((e) => !excludedAssets.contains(e.id)).toList();
|
||||
return _hashAssets(filtered);
|
||||
}
|
||||
|
||||
/// Converts a list of [AssetEntity]s to [Asset]s including 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 {
|
||||
const int batchFileCount = 128;
|
||||
const int batchDataSize = 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
final ids = assetEntities
|
||||
.map(Platform.isAndroid ? (a) => a.id.toInt() : (a) => a.id)
|
||||
.toList();
|
||||
final List<DeviceAsset?> hashes = await _lookupHashes(ids);
|
||||
final List<DeviceAsset> toAdd = [];
|
||||
final List<String> toHash = [];
|
||||
|
||||
int bytes = 0;
|
||||
|
||||
for (int i = 0; i < assetEntities.length; i++) {
|
||||
if (hashes[i] != null) {
|
||||
continue;
|
||||
}
|
||||
final file = await assetEntities[i].originFile;
|
||||
if (file == null) {
|
||||
final fileName = await assetEntities[i].titleAsync.catchError((error) {
|
||||
_log.warning(
|
||||
"Failed to get title for asset ${assetEntities[i].id}",
|
||||
);
|
||||
|
||||
return "";
|
||||
});
|
||||
|
||||
_log.warning(
|
||||
"Failed to get file for asset ${assetEntities[i].id}, name: $fileName, created on: ${assetEntities[i].createDateTime}, skipping",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
bytes += await file.length();
|
||||
toHash.add(file.path);
|
||||
final deviceAsset = Platform.isAndroid
|
||||
? AndroidDeviceAsset(id: ids[i] as int, hash: const [])
|
||||
: IOSDeviceAsset(id: ids[i] as String, hash: const []);
|
||||
toAdd.add(deviceAsset);
|
||||
hashes[i] = deviceAsset;
|
||||
if (toHash.length == batchFileCount || bytes >= batchDataSize) {
|
||||
await _processBatch(toHash, toAdd);
|
||||
toAdd.clear();
|
||||
toHash.clear();
|
||||
bytes = 0;
|
||||
}
|
||||
}
|
||||
if (toHash.isNotEmpty) {
|
||||
await _processBatch(toHash, toAdd);
|
||||
}
|
||||
return _mapAllHashedAssets(assetEntities, hashes);
|
||||
}
|
||||
|
||||
/// Lookup hashes of assets by their local ID
|
||||
Future<List<DeviceAsset?>> _lookupHashes(List<Object> ids) =>
|
||||
Platform.isAndroid
|
||||
? _db.androidDeviceAssets.getAll(ids.cast())
|
||||
: _db.iOSDeviceAssets.getAllById(ids.cast());
|
||||
|
||||
/// Processes a batch of files and saves any successfully hashed
|
||||
/// values to the DB table.
|
||||
Future<void> _processBatch(
|
||||
final List<String> toHash,
|
||||
final List<DeviceAsset> toAdd,
|
||||
) async {
|
||||
final hashes = await _hashFiles(toHash);
|
||||
bool anyNull = false;
|
||||
for (int j = 0; j < hashes.length; j++) {
|
||||
if (hashes[j]?.length == 20) {
|
||||
toAdd[j].hash = hashes[j]!;
|
||||
} else {
|
||||
_log.warning("Failed to hash file ${toHash[j]}, skipping");
|
||||
anyNull = true;
|
||||
}
|
||||
}
|
||||
final validHashes = anyNull
|
||||
? toAdd.where((e) => e.hash.length == 20).toList(growable: false)
|
||||
: toAdd;
|
||||
await _db.writeTxn(
|
||||
() => Platform.isAndroid
|
||||
? _db.androidDeviceAssets.putAll(validHashes.cast())
|
||||
: _db.iOSDeviceAssets.putAll(validHashes.cast()),
|
||||
);
|
||||
_log.fine("Hashed ${validHashes.length}/${toHash.length} assets");
|
||||
}
|
||||
|
||||
/// Hashes the given files and returns a list of the same length
|
||||
/// files that could not be hashed have a `null` value
|
||||
Future<List<Uint8List?>> _hashFiles(List<String> paths) async {
|
||||
final List<Uint8List?>? hashes =
|
||||
await _backgroundService.digestFiles(paths);
|
||||
if (hashes == null) {
|
||||
throw Exception("Hashing ${paths.length} files failed");
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
/// Converts [AssetEntity]s that were successfully hashed to [Asset]s
|
||||
List<Asset> _mapAllHashedAssets(
|
||||
List<AssetEntity> 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));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
final hashServiceProvider = Provider(
|
||||
(ref) => HashService(
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(backgroundServiceProvider),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
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/providers/api.provider.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)));
|
||||
|
||||
class ImageViewerService {
|
||||
final ApiService _apiService;
|
||||
final Logger _log = Logger("ImageViewerService");
|
||||
|
||||
ImageViewerService(this._apiService);
|
||||
|
||||
Future<bool> downloadAssetToDevice(Asset asset) async {
|
||||
File? imageFile;
|
||||
File? videoFile;
|
||||
try {
|
||||
// Download LivePhotos image and motion part
|
||||
if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) {
|
||||
var imageResponse =
|
||||
await _apiService.downloadApi.downloadFileWithHttpInfo(
|
||||
asset.remoteId!,
|
||||
);
|
||||
|
||||
var motionReponse =
|
||||
await _apiService.downloadApi.downloadFileWithHttpInfo(
|
||||
asset.livePhotoVideoId!,
|
||||
);
|
||||
|
||||
if (imageResponse.statusCode != 200 ||
|
||||
motionReponse.statusCode != 200) {
|
||||
final failedResponse =
|
||||
imageResponse.statusCode != 200 ? imageResponse : motionReponse;
|
||||
_log.severe(
|
||||
"Motion asset download failed",
|
||||
failedResponse.toLoggerString(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
AssetEntity? entity;
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
videoFile = await File('${tempDir.path}/livephoto.mov').create();
|
||||
imageFile = await File('${tempDir.path}/livephoto.heic').create();
|
||||
videoFile.writeAsBytesSync(motionReponse.bodyBytes);
|
||||
imageFile.writeAsBytesSync(imageResponse.bodyBytes);
|
||||
|
||||
entity = await PhotoManager.editor.darwin.saveLivePhoto(
|
||||
imageFile: imageFile,
|
||||
videoFile: videoFile,
|
||||
title: asset.fileName,
|
||||
);
|
||||
|
||||
if (entity == 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,
|
||||
);
|
||||
}
|
||||
|
||||
return entity != null;
|
||||
} else {
|
||||
var res = await _apiService.downloadApi
|
||||
.downloadFileWithHttpInfo(asset.remoteId!);
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
_log.severe("Asset download failed", res.toLoggerString());
|
||||
return false;
|
||||
}
|
||||
|
||||
final AssetEntity? entity;
|
||||
|
||||
if (asset.isImage) {
|
||||
entity = await PhotoManager.editor.saveImage(
|
||||
res.bodyBytes,
|
||||
title: asset.fileName,
|
||||
);
|
||||
} else {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
videoFile = await File('${tempDir.path}/${asset.fileName}').create();
|
||||
videoFile.writeAsBytesSync(res.bodyBytes);
|
||||
entity = await PhotoManager.editor
|
||||
.saveVideo(videoFile, title: asset.fileName);
|
||||
}
|
||||
return entity != null;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.severe("Error saving downloaded asset", error, stack);
|
||||
return false;
|
||||
} finally {
|
||||
// Clear temp files
|
||||
imageFile?.delete();
|
||||
videoFile?.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/entities/logger_message.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
/// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
|
||||
/// The logs are written to the database and onto console, using `debugPrint` method.
|
||||
///
|
||||
/// The logs are deleted when exceeding the `maxLogEntries` (default 500) property
|
||||
/// in the class.
|
||||
///
|
||||
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
|
||||
/// and generate a csv file.
|
||||
class ImmichLogger {
|
||||
static final ImmichLogger _instance = ImmichLogger._internal();
|
||||
final maxLogEntries = 500;
|
||||
final Isar _db = Isar.getInstance()!;
|
||||
List<LoggerMessage> _msgBuffer = [];
|
||||
Timer? _timer;
|
||||
|
||||
factory ImmichLogger() => _instance;
|
||||
|
||||
ImmichLogger._internal() {
|
||||
_removeOverflowMessages();
|
||||
final int levelId = Store.get(StoreKey.logLevel, 5); // 5 is INFO
|
||||
Logger.root.level = Level.LEVELS[levelId];
|
||||
Logger.root.onRecord.listen(_writeLogToDatabase);
|
||||
}
|
||||
|
||||
set level(Level level) => Logger.root.level = level;
|
||||
|
||||
List<LoggerMessage> get messages {
|
||||
final inDb =
|
||||
_db.loggerMessages.where(sort: Sort.desc).anyId().findAllSync();
|
||||
return _msgBuffer.isEmpty ? inDb : _msgBuffer.reversed.toList() + inDb;
|
||||
}
|
||||
|
||||
void _removeOverflowMessages() {
|
||||
final msgCount = _db.loggerMessages.countSync();
|
||||
if (msgCount > maxLogEntries) {
|
||||
final numberOfEntryToBeDeleted = msgCount - maxLogEntries;
|
||||
_db.writeTxn(
|
||||
() => _db.loggerMessages
|
||||
.where()
|
||||
.limit(numberOfEntryToBeDeleted)
|
||||
.deleteAll(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _writeLogToDatabase(LogRecord record) {
|
||||
debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
|
||||
final lm = LoggerMessage(
|
||||
message: record.message,
|
||||
details: record.error?.toString(),
|
||||
level: record.level.toLogLevel(),
|
||||
createdAt: record.time,
|
||||
context1: record.loggerName,
|
||||
context2: record.stackTrace?.toString(),
|
||||
);
|
||||
_msgBuffer.add(lm);
|
||||
|
||||
// delayed batch writing to database: increases performance when logging
|
||||
// messages in quick succession and reduces NAND wear
|
||||
_timer ??= Timer(const Duration(seconds: 5), _flushBufferToDatabase);
|
||||
}
|
||||
|
||||
void _flushBufferToDatabase() {
|
||||
_timer = null;
|
||||
final buffer = _msgBuffer;
|
||||
_msgBuffer = [];
|
||||
_db.writeTxn(() => _db.loggerMessages.putAll(buffer));
|
||||
}
|
||||
|
||||
void clearLogs() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_msgBuffer.clear();
|
||||
_db.writeTxn(() => _db.loggerMessages.clear());
|
||||
}
|
||||
|
||||
Future<void> shareLogs() async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final dateTime = DateTime.now().toIso8601String();
|
||||
final filePath = '${tempDir.path}/Immich_log_$dateTime.log';
|
||||
final logFile = await File(filePath).create();
|
||||
final io = logFile.openWrite();
|
||||
try {
|
||||
// Write messages
|
||||
for (final m in messages) {
|
||||
final created = m.createdAt;
|
||||
final level = m.level.name.padRight(8);
|
||||
final logger = (m.context1 ?? "<UNKNOWN_LOGGER>").padRight(20);
|
||||
final message = m.message;
|
||||
final error = m.details != null ? " ${m.details} |" : "";
|
||||
final stack = m.context2 != null ? "\n${m.context2!}" : "";
|
||||
io.write('$created | $level | $logger | $message |$error$stack\n');
|
||||
}
|
||||
} finally {
|
||||
await io.flush();
|
||||
await io.close();
|
||||
}
|
||||
|
||||
// Share file
|
||||
await Share.shareXFiles(
|
||||
[XFile(filePath)],
|
||||
subject: "Immich logs $dateTime",
|
||||
sharePositionOrigin: Rect.zero,
|
||||
).then(
|
||||
(value) => logFile.delete(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Flush pending log messages to persistent storage
|
||||
void flush() {
|
||||
if (_timer != null) {
|
||||
_timer!.cancel();
|
||||
_db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
final localNotificationService = Provider(
|
||||
(ref) => LocalNotificationService(
|
||||
ref.watch(notificationPermissionProvider),
|
||||
ref,
|
||||
),
|
||||
);
|
||||
|
||||
class LocalNotificationService {
|
||||
final FlutterLocalNotificationsPlugin _localNotificationPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
final PermissionStatus _permissionStatus;
|
||||
final Ref ref;
|
||||
|
||||
LocalNotificationService(this._permissionStatus, this.ref);
|
||||
|
||||
static const manualUploadNotificationID = 4;
|
||||
static const manualUploadDetailedNotificationID = 5;
|
||||
static const manualUploadChannelName = 'Manual Asset Upload';
|
||||
static const manualUploadChannelID = 'immich/manualUpload';
|
||||
static const manualUploadChannelNameDetailed = 'Manual Asset Upload Detailed';
|
||||
static const manualUploadDetailedChannelID = 'immich/manualUploadDetailed';
|
||||
static const cancelUploadActionID = 'cancel_upload';
|
||||
|
||||
Future<void> setup() async {
|
||||
const androidSetting = AndroidInitializationSettings('notification_icon');
|
||||
const iosSetting = DarwinInitializationSettings();
|
||||
|
||||
const initSettings =
|
||||
InitializationSettings(android: androidSetting, iOS: iosSetting);
|
||||
|
||||
await _localNotificationPlugin.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse:
|
||||
_onDidReceiveForegroundNotificationResponse,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showOrUpdateNotification(
|
||||
int id,
|
||||
String title,
|
||||
String body,
|
||||
AndroidNotificationDetails androidNotificationDetails,
|
||||
DarwinNotificationDetails iosNotificationDetails,
|
||||
) async {
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: androidNotificationDetails,
|
||||
iOS: iosNotificationDetails,
|
||||
);
|
||||
|
||||
if (_permissionStatus == PermissionStatus.granted) {
|
||||
await _localNotificationPlugin.show(id, title, body, notificationDetails);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> closeNotification(int id) {
|
||||
return _localNotificationPlugin.cancel(id);
|
||||
}
|
||||
|
||||
Future<void> showOrUpdateManualUploadStatus(
|
||||
String title,
|
||||
String body, {
|
||||
bool? isDetailed,
|
||||
bool? presentBanner,
|
||||
bool? showActions,
|
||||
int? maxProgress,
|
||||
int? progress,
|
||||
}) {
|
||||
var notificationlId = manualUploadNotificationID;
|
||||
var androidChannelID = manualUploadChannelID;
|
||||
var androidChannelName = manualUploadChannelName;
|
||||
// Separate Notification for Info/Alerts and Progress
|
||||
if (isDetailed != null && isDetailed) {
|
||||
notificationlId = manualUploadDetailedNotificationID;
|
||||
androidChannelID = manualUploadDetailedChannelID;
|
||||
androidChannelName = manualUploadChannelNameDetailed;
|
||||
}
|
||||
// Progress notification
|
||||
final androidNotificationDetails = (maxProgress != null && progress != null)
|
||||
? AndroidNotificationDetails(
|
||||
androidChannelID,
|
||||
androidChannelName,
|
||||
ticker: title,
|
||||
showProgress: true,
|
||||
onlyAlertOnce: true,
|
||||
maxProgress: maxProgress,
|
||||
progress: progress,
|
||||
indeterminate: false,
|
||||
playSound: false,
|
||||
priority: Priority.low,
|
||||
importance: Importance.low,
|
||||
ongoing: true,
|
||||
actions: (showActions ?? false)
|
||||
? <AndroidNotificationAction>[
|
||||
const AndroidNotificationAction(
|
||||
cancelUploadActionID,
|
||||
'Cancel',
|
||||
showsUserInterface: true,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
)
|
||||
// Non-progress notification
|
||||
: AndroidNotificationDetails(
|
||||
androidChannelID,
|
||||
androidChannelName,
|
||||
playSound: false,
|
||||
);
|
||||
|
||||
final iosNotificationDetails = DarwinNotificationDetails(
|
||||
presentBadge: true,
|
||||
presentList: true,
|
||||
presentBanner: presentBanner,
|
||||
);
|
||||
|
||||
return _showOrUpdateNotification(
|
||||
notificationlId,
|
||||
title,
|
||||
body,
|
||||
androidNotificationDetails,
|
||||
iosNotificationDetails,
|
||||
);
|
||||
}
|
||||
|
||||
void _onDidReceiveForegroundNotificationResponse(
|
||||
NotificationResponse notificationResponse,
|
||||
) {
|
||||
// Handle notification actions
|
||||
switch (notificationResponse.actionId) {
|
||||
case cancelUploadActionID:
|
||||
{
|
||||
debugPrint("User cancelled manual upload operation");
|
||||
ref.read(manualUploadProvider.notifier).cancelBackup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// ignore_for_file: implementation_imports
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:easy_localization/src/asset_loader.dart';
|
||||
import 'package:easy_localization/src/easy_localization_controller.dart';
|
||||
import 'package:easy_localization/src/localization.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
|
||||
/// Workaround to manually load translations in another Isolate
|
||||
Future<bool> loadTranslations() async {
|
||||
await EasyLocalizationController.initEasyLocation();
|
||||
|
||||
final controller = EasyLocalizationController(
|
||||
supportedLocales: locales.values.toList(),
|
||||
useFallbackTranslations: true,
|
||||
saveLocale: true,
|
||||
assetLoader: const RootBundleAssetLoader(),
|
||||
path: translationsPath,
|
||||
useOnlyLangCode: false,
|
||||
onLoadError: (e) => debugPrint(e.toString()),
|
||||
fallbackLocale: locales.values.first,
|
||||
);
|
||||
|
||||
await controller.loadTranslations();
|
||||
|
||||
return Localization.load(
|
||||
controller.locale,
|
||||
translations: controller.translations,
|
||||
fallbackTranslations: controller.fallbackTranslations,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
|
||||
import 'package:immich_mobile/models/map/map_marker.model.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class MapSerivce with ErrorLoggerMixin {
|
||||
final ApiService _apiService;
|
||||
@override
|
||||
final logger = Logger("MapService");
|
||||
|
||||
MapSerivce(this._apiService);
|
||||
|
||||
Future<Iterable<MapMarker>> getMapMarkers({
|
||||
bool? isFavorite,
|
||||
bool? withArchived,
|
||||
bool? withPartners,
|
||||
DateTime? fileCreatedAfter,
|
||||
DateTime? fileCreatedBefore,
|
||||
}) async {
|
||||
return logError(
|
||||
() async {
|
||||
final markers = await _apiService.assetApi.getMapMarkers(
|
||||
isFavorite: isFavorite,
|
||||
isArchived: withArchived,
|
||||
withPartners: withPartners,
|
||||
fileCreatedAfter: fileCreatedAfter,
|
||||
fileCreatedBefore: fileCreatedBefore,
|
||||
);
|
||||
|
||||
return markers?.map(MapMarker.fromDto) ?? [];
|
||||
},
|
||||
defaultValue: [],
|
||||
errorMessage: "Failed to get map markers",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/models/memories/memory.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final memoryServiceProvider = StateProvider<MemoryService>((ref) {
|
||||
return MemoryService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class MemoryService {
|
||||
final log = Logger("MemoryService");
|
||||
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
|
||||
MemoryService(this._apiService, this._db);
|
||||
|
||||
Future<List<Memory>?> getMemoryLane() async {
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final data = await _apiService.assetApi.getMemoryLane(
|
||||
now.day,
|
||||
now.month,
|
||||
);
|
||||
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Memory> memories = [];
|
||||
for (final MemoryLaneResponseDto(:title, :assets) in data) {
|
||||
memories.add(
|
||||
Memory(
|
||||
title: title,
|
||||
assets: await _db.assets.getAllByRemoteId(assets.map((e) => e.id)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return memories.isNotEmpty ? memories : null;
|
||||
} catch (error, stack) {
|
||||
log.severe("Cannot get memories", error, stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:flutter_web_auth/flutter_web_auth.dart';
|
||||
|
||||
// Redirect URL = app.immich://
|
||||
|
||||
class OAuthService {
|
||||
final ApiService _apiService;
|
||||
final callbackUrlScheme = 'app.immich';
|
||||
final log = Logger('OAuthService');
|
||||
OAuthService(this._apiService);
|
||||
|
||||
Future<String?> getOAuthServerUrl(
|
||||
String serverUrl,
|
||||
) async {
|
||||
// Resolve API server endpoint from user provided serverUrl
|
||||
await _apiService.resolveAndSetEndpoint(serverUrl);
|
||||
|
||||
final dto = await _apiService.oAuthApi.startOAuth(
|
||||
OAuthConfigDto(redirectUri: '$callbackUrlScheme:/'),
|
||||
);
|
||||
return dto?.url;
|
||||
}
|
||||
|
||||
Future<LoginResponseDto?> oAuthLogin(String oauthUrl) async {
|
||||
try {
|
||||
var result = await FlutterWebAuth.authenticate(
|
||||
url: oauthUrl,
|
||||
callbackUrlScheme: callbackUrlScheme,
|
||||
);
|
||||
|
||||
return await _apiService.oAuthApi.finishOAuth(
|
||||
OAuthCallbackDto(
|
||||
url: result,
|
||||
),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
log.severe("OAuth login failed", e, stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final partnerServiceProvider = Provider(
|
||||
(ref) => PartnerService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
),
|
||||
);
|
||||
|
||||
enum PartnerDirection {
|
||||
sharedWith("shared-with"),
|
||||
sharedBy("shared-by");
|
||||
|
||||
const PartnerDirection(
|
||||
this._value,
|
||||
);
|
||||
|
||||
final String _value;
|
||||
}
|
||||
|
||||
class PartnerService {
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
final Logger _log = Logger("PartnerService");
|
||||
|
||||
PartnerService(this._apiService, this._db);
|
||||
|
||||
Future<List<User>?> getPartners(PartnerDirection direction) async {
|
||||
try {
|
||||
final userDtos =
|
||||
await _apiService.partnerApi.getPartners(direction._value);
|
||||
if (userDtos != null) {
|
||||
return userDtos.map((u) => User.fromPartnerDto(u)).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
_log.warning("Failed to get partners for direction $direction", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> removePartner(User partner) async {
|
||||
try {
|
||||
await _apiService.partnerApi.removePartner(partner.id);
|
||||
partner.isPartnerSharedBy = false;
|
||||
await _db.writeTxn(() => _db.users.put(partner));
|
||||
} catch (e) {
|
||||
_log.warning("Failed to remove partner ${partner.id}", e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> addPartner(User partner) async {
|
||||
try {
|
||||
final dto = await _apiService.partnerApi.createPartner(partner.id);
|
||||
if (dto != null) {
|
||||
partner.isPartnerSharedBy = true;
|
||||
await _db.writeTxn(() => _db.users.put(partner));
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.warning("Failed to add partner ${partner.id}", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> updatePartner(User partner, {required bool inTimeline}) async {
|
||||
try {
|
||||
final dto = await _apiService.partnerApi
|
||||
.updatePartner(partner.id, UpdatePartnerDto(inTimeline: inTimeline));
|
||||
if (dto != null) {
|
||||
partner.inTimeline = dto.inTimeline ?? partner.inTimeline;
|
||||
await _db.writeTxn(() => _db.users.put(partner));
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.warning("Failed to update partner ${partner.id}", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'person.service.g.dart';
|
||||
|
||||
@riverpod
|
||||
PersonService personService(PersonServiceRef ref) =>
|
||||
PersonService(ref.read(apiServiceProvider), ref.read(dbProvider));
|
||||
|
||||
class PersonService {
|
||||
final Logger _log = Logger("PersonService");
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
|
||||
PersonService(this._apiService, this._db);
|
||||
|
||||
Future<List<PersonResponseDto>> getAllPeople() async {
|
||||
try {
|
||||
final peopleResponseDto = await _apiService.personApi.getAllPeople();
|
||||
return peopleResponseDto?.people ?? [];
|
||||
} catch (error, stack) {
|
||||
_log.severe("Error while fetching curated people", error, stack);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Asset>?> getPersonAssets(String id) async {
|
||||
try {
|
||||
final assets = await _apiService.personApi.getPersonAssets(id);
|
||||
if (assets == null) return null;
|
||||
return await _db.assets.getAllByRemoteId(assets.map((e) => e.id));
|
||||
} catch (error, stack) {
|
||||
_log.severe("Error while fetching person assets", error, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<PersonResponseDto?> updateName(String id, String name) async {
|
||||
try {
|
||||
return await _apiService.personApi.updatePerson(
|
||||
id,
|
||||
PersonUpdateDto(
|
||||
name: name,
|
||||
),
|
||||
);
|
||||
} catch (error, stack) {
|
||||
_log.severe("Error while updating person name", error, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'person.service.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$personServiceHash() => r'54e6df4b8eea744f6de009f8315c9fe6230f6798';
|
||||
|
||||
/// See also [personService].
|
||||
@ProviderFor(personService)
|
||||
final personServiceProvider = AutoDisposeProvider<PersonService>.internal(
|
||||
personService,
|
||||
name: r'personServiceProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$personServiceHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef PersonServiceRef = AutoDisposeProviderRef<PersonService>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final searchServiceProvider = Provider(
|
||||
(ref) => SearchService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class SearchService {
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
|
||||
final _log = Logger("SearchService");
|
||||
SearchService(this._apiService, this._db);
|
||||
|
||||
Future<List<String>?> getSearchSuggestions(
|
||||
SearchSuggestionType type, {
|
||||
String? country,
|
||||
String? state,
|
||||
String? make,
|
||||
String? model,
|
||||
}) async {
|
||||
try {
|
||||
return await _apiService.searchApi.getSearchSuggestions(
|
||||
type,
|
||||
country: country,
|
||||
state: state,
|
||||
make: make,
|
||||
model: model,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint("[ERROR] [getSearchSuggestions] ${e.toString()}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Asset>?> search(SearchFilter filter, int page) async {
|
||||
try {
|
||||
SearchResponseDto? response;
|
||||
AssetTypeEnum? type;
|
||||
if (filter.mediaType == AssetType.image) {
|
||||
type = AssetTypeEnum.IMAGE;
|
||||
} else if (filter.mediaType == AssetType.video) {
|
||||
type = AssetTypeEnum.VIDEO;
|
||||
}
|
||||
|
||||
if (filter.context != null && filter.context!.isNotEmpty) {
|
||||
response = await _apiService.searchApi.searchSmart(
|
||||
SmartSearchDto(
|
||||
query: filter.context!,
|
||||
country: filter.location.country,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
make: filter.camera.make,
|
||||
model: filter.camera.model,
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
isArchived: filter.display.isArchive,
|
||||
isFavorite: filter.display.isFavorite,
|
||||
isNotInAlbum: filter.display.isNotInAlbum,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
type: type,
|
||||
page: page,
|
||||
size: 1000,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
response = await _apiService.searchApi.searchMetadata(
|
||||
MetadataSearchDto(
|
||||
originalFileName:
|
||||
filter.filename != null && filter.filename!.isNotEmpty
|
||||
? filter.filename
|
||||
: null,
|
||||
country: filter.location.country,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
make: filter.camera.make,
|
||||
model: filter.camera.model,
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
isArchived: filter.display.isArchive,
|
||||
isFavorite: filter.display.isFavorite,
|
||||
isNotInAlbum: filter.display.isNotInAlbum,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
type: type,
|
||||
page: page,
|
||||
size: 1000,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _db.assets
|
||||
.getAllByRemoteId(response.assets.items.map((e) => e.id));
|
||||
} catch (error, stackTrace) {
|
||||
_log.severe("Failed to search for assets", error, stackTrace);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<SearchExploreResponseDto>?> getExploreData() async {
|
||||
try {
|
||||
return await _apiService.searchApi.getExploreData();
|
||||
} catch (error, stackTrace) {
|
||||
_log.severe("Failed to getExploreData", error, stackTrace);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<AssetResponseDto>?> getAllPlaces() async {
|
||||
try {
|
||||
return await _apiService.searchApi.getAssetsByCity();
|
||||
} catch (error, stackTrace) {
|
||||
_log.severe("Failed to getAllPlaces", error, stackTrace);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_config.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_features.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
|
||||
final serverInfoServiceProvider = Provider(
|
||||
(ref) => ServerInfoService(
|
||||
ref.watch(apiServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class ServerInfoService {
|
||||
final ApiService _apiService;
|
||||
|
||||
ServerInfoService(this._apiService);
|
||||
|
||||
Future<ServerDiskInfo?> getServerInfo() async {
|
||||
try {
|
||||
final dto = await _apiService.serverInfoApi.getServerInfo();
|
||||
if (dto != null) {
|
||||
return ServerDiskInfo.fromDto(dto);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error [getServerInfo] ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<ServerVersion?> getServerVersion() async {
|
||||
try {
|
||||
final dto = await _apiService.serverInfoApi.getServerVersion();
|
||||
if (dto != null) {
|
||||
return ServerVersion.fromDto(dto);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error [getServerVersion] ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<ServerFeatures?> getServerFeatures() async {
|
||||
try {
|
||||
final dto = await _apiService.serverInfoApi.getServerFeatures();
|
||||
if (dto != null) {
|
||||
return ServerFeatures.fromDto(dto);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error [getServerFeatures] ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<ServerConfig?> getServerConfig() async {
|
||||
try {
|
||||
final dto = await _apiService.serverInfoApi.getServerConfig();
|
||||
if (dto != null) {
|
||||
return ServerConfig.fromDto(dto);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error [getServerConfig] ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
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/providers/api.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'api.service.dart';
|
||||
|
||||
final shareServiceProvider =
|
||||
Provider((ref) => ShareService(ref.watch(apiServiceProvider)));
|
||||
|
||||
class ShareService {
|
||||
final ApiService _apiService;
|
||||
final Logger _log = Logger("ShareService");
|
||||
|
||||
ShareService(this._apiService);
|
||||
|
||||
Future<bool> shareAsset(Asset asset) async {
|
||||
return await shareAssets([asset]);
|
||||
}
|
||||
|
||||
Future<bool> shareAssets(List<Asset> assets) async {
|
||||
try {
|
||||
final downloadedXFiles = <XFile>[];
|
||||
|
||||
for (var asset in assets) {
|
||||
if (asset.isLocal) {
|
||||
// Prefer local assets to share
|
||||
File? f = await asset.local!.file;
|
||||
downloadedXFiles.add(XFile(f!.path));
|
||||
} else if (asset.isRemote) {
|
||||
// Download remote asset otherwise
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final fileName = asset.fileName;
|
||||
final tempFile = await File('${tempDir.path}/$fileName').create();
|
||||
final res = await _apiService.downloadApi
|
||||
.downloadFileWithHttpInfo(asset.remoteId!);
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
_log.severe(
|
||||
"Asset download for ${asset.fileName} failed",
|
||||
res.toLoggerString(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
tempFile.writeAsBytesSync(res.bodyBytes);
|
||||
downloadedXFiles.add(XFile(tempFile.path));
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadedXFiles.isEmpty) {
|
||||
_log.warning("No asset can be retrieved for share");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (downloadedXFiles.length != assets.length) {
|
||||
_log.warning(
|
||||
"Partial share - Requested: ${assets.length}, Sharing: ${downloadedXFiles.length}",
|
||||
);
|
||||
}
|
||||
|
||||
Share.shareXFiles(
|
||||
downloadedXFiles,
|
||||
sharePositionOrigin: Rect.zero,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
_log.severe("Share failed", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final sharedLinkServiceProvider = Provider(
|
||||
(ref) => SharedLinkService(ref.watch(apiServiceProvider)),
|
||||
);
|
||||
|
||||
class SharedLinkService {
|
||||
final ApiService _apiService;
|
||||
final Logger _log = Logger("SharedLinkService");
|
||||
|
||||
SharedLinkService(this._apiService);
|
||||
|
||||
Future<AsyncValue<List<SharedLink>>> getAllSharedLinks() async {
|
||||
try {
|
||||
final list = await _apiService.sharedLinkApi.getAllSharedLinks();
|
||||
return list != null
|
||||
? AsyncData(list.map(SharedLink.fromDto).toList())
|
||||
: const AsyncData([]);
|
||||
} catch (e, stack) {
|
||||
_log.severe("Failed to fetch shared links", e, stack);
|
||||
return AsyncError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSharedLink(String id) async {
|
||||
try {
|
||||
return await _apiService.sharedLinkApi.removeSharedLink(id);
|
||||
} catch (e) {
|
||||
_log.severe("Failed to delete shared link id - $id", e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SharedLink?> createSharedLink({
|
||||
required bool showMeta,
|
||||
required bool allowDownload,
|
||||
required bool allowUpload,
|
||||
String? description,
|
||||
String? password,
|
||||
String? albumId,
|
||||
List<String>? assetIds,
|
||||
DateTime? expiresAt,
|
||||
}) async {
|
||||
try {
|
||||
final type =
|
||||
albumId != null ? SharedLinkType.ALBUM : SharedLinkType.INDIVIDUAL;
|
||||
SharedLinkCreateDto? dto;
|
||||
if (type == SharedLinkType.ALBUM) {
|
||||
dto = SharedLinkCreateDto(
|
||||
type: type,
|
||||
albumId: albumId,
|
||||
showMetadata: showMeta,
|
||||
allowDownload: allowDownload,
|
||||
allowUpload: allowUpload,
|
||||
expiresAt: expiresAt,
|
||||
description: description,
|
||||
password: password,
|
||||
);
|
||||
} else if (assetIds != null) {
|
||||
dto = SharedLinkCreateDto(
|
||||
type: type,
|
||||
showMetadata: showMeta,
|
||||
allowDownload: allowDownload,
|
||||
allowUpload: allowUpload,
|
||||
expiresAt: expiresAt,
|
||||
description: description,
|
||||
password: password,
|
||||
assetIds: assetIds,
|
||||
);
|
||||
}
|
||||
|
||||
if (dto != null) {
|
||||
final responseDto =
|
||||
await _apiService.sharedLinkApi.createSharedLink(dto);
|
||||
if (responseDto != null) {
|
||||
return SharedLink.fromDto(responseDto);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe("Failed to create shared link", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<SharedLink?> updateSharedLink(
|
||||
String id, {
|
||||
required bool? showMeta,
|
||||
required bool? allowDownload,
|
||||
required bool? allowUpload,
|
||||
bool? changeExpiry = false,
|
||||
String? description,
|
||||
String? password,
|
||||
DateTime? expiresAt,
|
||||
}) async {
|
||||
try {
|
||||
final responseDto = await _apiService.sharedLinkApi.updateSharedLink(
|
||||
id,
|
||||
SharedLinkEditDto(
|
||||
showMetadata: showMeta,
|
||||
allowDownload: allowDownload,
|
||||
allowUpload: allowUpload,
|
||||
expiresAt: expiresAt,
|
||||
description: description,
|
||||
password: password,
|
||||
changeExpiryTime: changeExpiry,
|
||||
),
|
||||
);
|
||||
if (responseDto != null) {
|
||||
return SharedLink.fromDto(responseDto);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe("Failed to update shared link id - $id", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.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/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/providers/db.provider.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';
|
||||
import 'package:immich_mobile/utils/datetime_comparison.dart';
|
||||
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)),
|
||||
);
|
||||
|
||||
class SyncService {
|
||||
final Isar _db;
|
||||
final HashService _hashService;
|
||||
final AsyncMutex _lock = AsyncMutex();
|
||||
final Logger _log = Logger('SyncService');
|
||||
|
||||
SyncService(this._db, this._hashService);
|
||||
|
||||
// public methods:
|
||||
|
||||
/// Syncs users from the server to the local database
|
||||
/// Returns `true`if there were any changes
|
||||
Future<bool> syncUsersFromServer(List<User> users) =>
|
||||
_lock.run(() => _syncUsersFromServer(users));
|
||||
|
||||
/// Syncs remote assets owned by the logged-in user to the DB
|
||||
/// Returns `true` if there were any changes
|
||||
Future<bool> syncRemoteAssetsToDb(
|
||||
User user,
|
||||
Future<(List<Asset>? toUpsert, List<String>? toDelete)> Function(
|
||||
User user,
|
||||
DateTime since,
|
||||
) getChangedAssets,
|
||||
FutureOr<List<Asset>?> Function(User user) loadAssets,
|
||||
) =>
|
||||
_lock.run(
|
||||
() async =>
|
||||
await _syncRemoteAssetChanges(user, getChangedAssets) ??
|
||||
await _syncRemoteAssetsFull(user, loadAssets),
|
||||
);
|
||||
|
||||
/// Syncs remote albums to the database
|
||||
/// returns `true` if there were any changes
|
||||
Future<bool> syncRemoteAlbumsToDb(
|
||||
List<AlbumResponseDto> remote, {
|
||||
required bool isShared,
|
||||
required FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
|
||||
}) =>
|
||||
_lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails));
|
||||
|
||||
/// Syncs all device albums and their assets to the database
|
||||
/// Returns `true` if there were any changes
|
||||
Future<bool> syncLocalAlbumAssetsToDb(
|
||||
List<AssetPathEntity> onDevice, [
|
||||
Set<String>? excludedAssets,
|
||||
]) =>
|
||||
_lock.run(() => _syncLocalAlbumAssetsToDb(onDevice, excludedAssets));
|
||||
|
||||
/// returns all Asset IDs that are not contained in the existing list
|
||||
List<int> sharedAssetsToRemove(
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> existing,
|
||||
) {
|
||||
if (deleteCandidates.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
deleteCandidates.sort(Asset.compareById);
|
||||
existing.sort(Asset.compareById);
|
||||
return _diffAssets(existing, deleteCandidates, compare: Asset.compareById)
|
||||
.$3
|
||||
.map((e) => e.id)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Syncs a new asset to the db. Returns `true` if successful
|
||||
Future<bool> syncNewAssetToDb(Asset newAsset) =>
|
||||
_lock.run(() => _syncNewAssetToDb(newAsset));
|
||||
|
||||
Future<bool> removeAllLocalAlbumsAndAssets() =>
|
||||
_lock.run(_removeAllLocalAlbumsAndAssets);
|
||||
|
||||
// private methods:
|
||||
|
||||
/// Syncs users from the server to the local database
|
||||
/// Returns `true`if there were any changes
|
||||
Future<bool> _syncUsersFromServer(List<User> users) async {
|
||||
users.sortBy((u) => u.id);
|
||||
final dbUsers = await _db.users.where().sortById().findAll();
|
||||
assert(dbUsers.isSortedBy((u) => u.id), "dbUsers not sorted!");
|
||||
final List<int> toDelete = [];
|
||||
final List<User> toUpsert = [];
|
||||
final changes = diffSortedListsSync(
|
||||
users,
|
||||
dbUsers,
|
||||
compare: (User a, User b) => a.id.compareTo(b.id),
|
||||
both: (User a, User b) {
|
||||
if (!a.updatedAt.isAtSameMomentAs(b.updatedAt) ||
|
||||
a.isPartnerSharedBy != b.isPartnerSharedBy ||
|
||||
a.isPartnerSharedWith != b.isPartnerSharedWith) {
|
||||
toUpsert.add(a);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onlyFirst: (User a) => toUpsert.add(a),
|
||||
onlySecond: (User b) => toDelete.add(b.isarId),
|
||||
);
|
||||
if (changes) {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.users.deleteAll(toDelete);
|
||||
await _db.users.putAll(toUpsert);
|
||||
});
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// Syncs a new asset to the db. Returns `true` if successful
|
||||
Future<bool> _syncNewAssetToDb(Asset a) async {
|
||||
final Asset? inDb =
|
||||
await _db.assets.getByOwnerIdChecksum(a.ownerId, a.checksum);
|
||||
if (inDb != null) {
|
||||
// unify local/remote assets by replacing the
|
||||
// local-only asset in the DB with a local&remote asset
|
||||
a = inDb.updatedCopy(a);
|
||||
}
|
||||
try {
|
||||
await _db.writeTxn(() => a.put(_db));
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to put new asset into db", e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Efficiently syncs assets via changes. Returns `null` when a full sync is required.
|
||||
Future<bool?> _syncRemoteAssetChanges(
|
||||
User user,
|
||||
Future<(List<Asset>? toUpsert, List<String>? toDelete)> Function(
|
||||
User user,
|
||||
DateTime since,
|
||||
) getChangedAssets,
|
||||
) async {
|
||||
final DateTime? since = _db.eTags.getByIdSync(user.id)?.time?.toUtc();
|
||||
if (since == null) return null;
|
||||
final DateTime now = DateTime.now();
|
||||
final (toUpsert, toDelete) = await getChangedAssets(user, since);
|
||||
if (toUpsert == null || toDelete == null) return null;
|
||||
try {
|
||||
if (toDelete.isNotEmpty) {
|
||||
await handleRemoteAssetRemoval(toDelete);
|
||||
}
|
||||
if (toUpsert.isNotEmpty) {
|
||||
final (_, updated) = await _linkWithExistingFromDb(toUpsert);
|
||||
await upsertAssetsWithExif(updated);
|
||||
}
|
||||
if (toUpsert.isNotEmpty || toDelete.isNotEmpty) {
|
||||
await _updateUserAssetsETag(user, now);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to sync remote assets to db", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Deletes remote-only assets, updates merged assets to be local-only
|
||||
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) {
|
||||
return _db.writeTxn(() async {
|
||||
final idsToRemove = await _db.assets
|
||||
.remote(idsToDelete)
|
||||
.filter()
|
||||
.localIdIsNull()
|
||||
.idProperty()
|
||||
.findAll();
|
||||
await _db.assets.deleteAll(idsToRemove);
|
||||
await _db.exifInfos.deleteAll(idsToRemove);
|
||||
final onlyLocal = await _db.assets.remote(idsToDelete).findAll();
|
||||
if (onlyLocal.isNotEmpty) {
|
||||
for (final Asset a in onlyLocal) {
|
||||
a.remoteId = null;
|
||||
a.isTrashed = false;
|
||||
}
|
||||
await _db.assets.putAll(onlyLocal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Syncs assets by loading and comparing all assets from the server.
|
||||
Future<bool> _syncRemoteAssetsFull(
|
||||
User user,
|
||||
FutureOr<List<Asset>?> Function(User user) loadAssets,
|
||||
) async {
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
final List<Asset>? remote = await loadAssets(user);
|
||||
if (remote == null) {
|
||||
return false;
|
||||
}
|
||||
final List<Asset> inDb = await _db.assets
|
||||
.where()
|
||||
.ownerIdEqualToAnyChecksum(user.isarId)
|
||||
.sortByChecksum()
|
||||
.findAll();
|
||||
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
|
||||
|
||||
remote.sort(Asset.compareByChecksum);
|
||||
|
||||
// filter our duplicates that might be introduced by the chunked retrieval
|
||||
remote.uniqueConsecutive(compare: Asset.compareByChecksum);
|
||||
|
||||
final (toAdd, toUpdate, toRemove) = _diffAssets(remote, inDb, remote: true);
|
||||
if (toAdd.isEmpty && toUpdate.isEmpty && toRemove.isEmpty) {
|
||||
await _updateUserAssetsETag(user, now);
|
||||
return false;
|
||||
}
|
||||
final idsToDelete = toRemove.map((e) => e.id).toList();
|
||||
try {
|
||||
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
|
||||
await upsertAssetsWithExif(toAdd + toUpdate);
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to sync remote assets to db", e);
|
||||
}
|
||||
await _updateUserAssetsETag(user, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _updateUserAssetsETag(User user, DateTime time) =>
|
||||
_db.writeTxn(() => _db.eTags.put(ETag(id: user.id, time: time)));
|
||||
|
||||
/// Syncs remote albums to the database
|
||||
/// returns `true` if there were any changes
|
||||
Future<bool> _syncRemoteAlbumsToDb(
|
||||
List<AlbumResponseDto> remote,
|
||||
bool isShared,
|
||||
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
|
||||
) async {
|
||||
remote.sortBy((e) => e.id);
|
||||
|
||||
final baseQuery = _db.albums.where().remoteIdIsNotNull().filter();
|
||||
final QueryBuilder<Album, Album, QAfterFilterCondition> query;
|
||||
if (isShared) {
|
||||
query = baseQuery.sharedEqualTo(true);
|
||||
} else {
|
||||
final User me = Store.get(StoreKey.currentUser);
|
||||
query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId));
|
||||
}
|
||||
final List<Album> dbAlbums = await query.sortByRemoteId().findAll();
|
||||
assert(dbAlbums.isSortedBy((e) => e.remoteId!), "dbAlbums not sorted!");
|
||||
|
||||
final List<Asset> toDelete = [];
|
||||
final List<Asset> existing = [];
|
||||
|
||||
final bool changes = await diffSortedLists(
|
||||
remote,
|
||||
dbAlbums,
|
||||
compare: (AlbumResponseDto a, Album b) => a.id.compareTo(b.remoteId!),
|
||||
both: (AlbumResponseDto a, Album b) =>
|
||||
_syncRemoteAlbum(a, b, toDelete, existing, loadDetails),
|
||||
onlyFirst: (AlbumResponseDto a) =>
|
||||
_addAlbumFromServer(a, existing, loadDetails),
|
||||
onlySecond: (Album a) => _removeAlbumFromDb(a, toDelete),
|
||||
);
|
||||
|
||||
if (isShared && toDelete.isNotEmpty) {
|
||||
final List<int> idsToRemove = sharedAssetsToRemove(toDelete, existing);
|
||||
if (idsToRemove.isNotEmpty) {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.deleteAll(idsToRemove);
|
||||
await _db.exifInfos.deleteAll(idsToRemove);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
assert(toDelete.isEmpty);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// syncs albums from the server to the local database (does not support
|
||||
/// syncing changes from local back to server)
|
||||
/// accumulates
|
||||
Future<bool> _syncRemoteAlbum(
|
||||
AlbumResponseDto dto,
|
||||
Album album,
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> existing,
|
||||
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
|
||||
) async {
|
||||
if (!_hasAlbumResponseDtoChanged(dto, album)) {
|
||||
return false;
|
||||
}
|
||||
// loadDetails (/api/album/:id) will not include lastModifiedAssetTimestamp,
|
||||
// i.e. it will always be null. Save it here.
|
||||
final originalDto = dto;
|
||||
dto = await loadDetails(dto);
|
||||
if (dto.assetCount != dto.assets.length) {
|
||||
return false;
|
||||
}
|
||||
final assetsInDb =
|
||||
await album.assets.filter().sortByOwnerId().thenByChecksum().findAll();
|
||||
assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!");
|
||||
final List<Asset> assetsOnRemote = dto.getAssets();
|
||||
assetsOnRemote.sort(Asset.compareByOwnerChecksum);
|
||||
final (toAdd, toUpdate, toUnlink) = _diffAssets(
|
||||
assetsOnRemote,
|
||||
assetsInDb,
|
||||
compare: Asset.compareByOwnerChecksum,
|
||||
);
|
||||
|
||||
// update shared users
|
||||
final List<User> sharedUsers = album.sharedUsers.toList(growable: false);
|
||||
sharedUsers.sort((a, b) => a.id.compareTo(b.id));
|
||||
dto.sharedUsers.sort((a, b) => a.id.compareTo(b.id));
|
||||
final List<String> userIdsToAdd = [];
|
||||
final List<User> usersToUnlink = [];
|
||||
diffSortedListsSync(
|
||||
dto.sharedUsers,
|
||||
sharedUsers,
|
||||
compare: (UserResponseDto a, User b) => a.id.compareTo(b.id),
|
||||
both: (a, b) => false,
|
||||
onlyFirst: (UserResponseDto a) => userIdsToAdd.add(a.id),
|
||||
onlySecond: (User a) => usersToUnlink.add(a),
|
||||
);
|
||||
|
||||
// for shared album: put missing album assets into local DB
|
||||
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
|
||||
await upsertAssetsWithExif(updated);
|
||||
final assetsToLink = existingInDb + updated;
|
||||
final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>();
|
||||
|
||||
album.name = dto.albumName;
|
||||
album.shared = dto.shared;
|
||||
album.createdAt = dto.createdAt;
|
||||
album.modifiedAt = dto.updatedAt;
|
||||
album.startDate = dto.startDate;
|
||||
album.endDate = dto.endDate;
|
||||
album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp;
|
||||
album.shared = dto.shared;
|
||||
album.activityEnabled = dto.isActivityEnabled;
|
||||
if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) {
|
||||
album.thumbnail.value = await _db.assets
|
||||
.where()
|
||||
.remoteIdEqualTo(dto.albumThumbnailAssetId)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
// write & commit all changes to DB
|
||||
try {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(toUpdate);
|
||||
await album.thumbnail.save();
|
||||
await album.sharedUsers
|
||||
.update(link: usersToLink, unlink: usersToUnlink);
|
||||
await album.assets.update(link: assetsToLink, unlink: toUnlink.cast());
|
||||
await _db.albums.put(album);
|
||||
});
|
||||
_log.info("Synced changes of remote album ${album.name} to DB");
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to sync remote album to database", e);
|
||||
}
|
||||
|
||||
if (album.shared || dto.shared) {
|
||||
final userId = Store.get(StoreKey.currentUser).isarId;
|
||||
final foreign =
|
||||
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
|
||||
existing.addAll(foreign);
|
||||
|
||||
// delete assets in DB unless they belong to this user or part of some other shared album
|
||||
deleteCandidates.addAll(toUnlink.where((a) => a.ownerId != userId));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Adds a remote album to the database while making sure to add any foreign
|
||||
/// (shared) assets to the database beforehand
|
||||
/// accumulates assets already existing in the database
|
||||
Future<void> _addAlbumFromServer(
|
||||
AlbumResponseDto dto,
|
||||
List<Asset> existing,
|
||||
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
|
||||
) async {
|
||||
if (dto.assetCount != dto.assets.length) {
|
||||
dto = await loadDetails(dto);
|
||||
}
|
||||
if (dto.assetCount == dto.assets.length) {
|
||||
// in case an album contains assets not yet present in local DB:
|
||||
// put missing album assets into local DB
|
||||
final (existingInDb, updated) =
|
||||
await _linkWithExistingFromDb(dto.getAssets());
|
||||
existing.addAll(existingInDb);
|
||||
await upsertAssetsWithExif(updated);
|
||||
|
||||
final Album a = await Album.remote(dto);
|
||||
await _db.writeTxn(() => _db.albums.store(a));
|
||||
} else {
|
||||
_log.warning(
|
||||
"Failed to add album from server: assetCount ${dto.assetCount} != "
|
||||
"asset array length ${dto.assets.length} for album ${dto.albumName}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulates all suitable album assets to the `deleteCandidates` and
|
||||
/// removes the album from the database.
|
||||
Future<void> _removeAlbumFromDb(
|
||||
Album album,
|
||||
List<Asset> deleteCandidates,
|
||||
) async {
|
||||
if (album.isLocal) {
|
||||
_log.info("Removing local album $album from DB");
|
||||
// delete assets in DB unless they are remote or part of some other album
|
||||
deleteCandidates.addAll(
|
||||
await album.assets.filter().remoteIdIsNull().findAll(),
|
||||
);
|
||||
} else if (album.shared) {
|
||||
final User user = Store.get(StoreKey.currentUser);
|
||||
// delete assets in DB unless they belong to this user or are part of some other shared album or belong to a partner
|
||||
final userIds = await _db.users
|
||||
.filter()
|
||||
.isPartnerSharedWithEqualTo(true)
|
||||
.isarIdProperty()
|
||||
.findAll();
|
||||
userIds.add(user.isarId);
|
||||
final orphanedAssets = await album.assets
|
||||
.filter()
|
||||
.not()
|
||||
.anyOf(userIds, (q, int id) => q.ownerIdEqualTo(id))
|
||||
.findAll();
|
||||
deleteCandidates.addAll(orphanedAssets);
|
||||
}
|
||||
try {
|
||||
final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
assert(ok);
|
||||
_log.info("Removed local album $album from DB");
|
||||
} catch (e) {
|
||||
_log.severe("Failed to remove local album $album from DB", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Syncs all device albums and their assets to the database
|
||||
/// Returns `true` if there were any changes
|
||||
Future<bool> _syncLocalAlbumAssetsToDb(
|
||||
List<AssetPathEntity> onDevice, [
|
||||
Set<String>? excludedAssets,
|
||||
]) async {
|
||||
onDevice.sort((a, b) => a.id.compareTo(b.id));
|
||||
final inDb =
|
||||
await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll();
|
||||
final List<Asset> deleteCandidates = [];
|
||||
final List<Asset> existing = [];
|
||||
assert(inDb.isSorted((a, b) => a.localId!.compareTo(b.localId!)), "sort!");
|
||||
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,
|
||||
deleteCandidates,
|
||||
existing,
|
||||
excludedAssets,
|
||||
),
|
||||
onlyFirst: (AssetPathEntity ape) =>
|
||||
_addAlbumFromDevice(ape, existing, excludedAssets),
|
||||
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
|
||||
);
|
||||
_log.fine(
|
||||
"Syncing all local albums almost done. Collected ${deleteCandidates.length} asset candidates to delete",
|
||||
);
|
||||
final (toDelete, toUpdate) =
|
||||
_handleAssetRemoval(deleteCandidates, existing, remote: false);
|
||||
_log.fine(
|
||||
"${toDelete.length} assets to delete, ${toUpdate.length} to update",
|
||||
);
|
||||
if (toDelete.isNotEmpty || toUpdate.isNotEmpty) {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.deleteAll(toDelete);
|
||||
await _db.exifInfos.deleteAll(toDelete);
|
||||
await _db.assets.putAll(toUpdate);
|
||||
});
|
||||
_log.info(
|
||||
"Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB",
|
||||
);
|
||||
}
|
||||
return anyChanges;
|
||||
}
|
||||
|
||||
/// Syncs the device album to the album in the database
|
||||
/// returns `true` if there were any changes
|
||||
/// Accumulates asset candidates to delete and those already existing in DB
|
||||
Future<bool> _syncAlbumInDbAndOnDevice(
|
||||
AssetPathEntity ape,
|
||||
Album album,
|
||||
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.");
|
||||
return false;
|
||||
}
|
||||
if (!forceRefresh &&
|
||||
excludedAssets == null &&
|
||||
await _syncDeviceAlbumFast(ape, album)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
|
||||
final inDb = await album.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);
|
||||
_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!)) {
|
||||
// changes only affeted excluded albums
|
||||
_log.fine(
|
||||
"Only excluded assets in local album ${ape.name} changed. Stopping sync.",
|
||||
);
|
||||
if (assetCountOnDevice !=
|
||||
_db.eTags.getByIdSync(ape.eTagKeyAssetCount)?.assetCount) {
|
||||
await _db.writeTxn(
|
||||
() => _db.eTags.put(
|
||||
ETag(id: ape.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",
|
||||
);
|
||||
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
|
||||
_log.fine(
|
||||
"Linking assets to add with existing from db. ${existingInDb.length} existing, ${updated.length} to update",
|
||||
);
|
||||
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;
|
||||
}
|
||||
try {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(updated);
|
||||
await _db.assets.putAll(toUpdate);
|
||||
await album.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.eTags.put(
|
||||
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
|
||||
);
|
||||
});
|
||||
_log.info("Synced changes of local album ${ape.name} to DB");
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to update synced album ${ape.name} in DB", e);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 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)) {
|
||||
return false;
|
||||
}
|
||||
final int totalOnDevice = await ape.assetCountAsync;
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
final List<Asset> newAssets = await _hashService.getHashedAssets(modified);
|
||||
|
||||
if (totalOnDevice != lastKnownTotal + newAssets.length) {
|
||||
return false;
|
||||
}
|
||||
album.modifiedAt = ape.lastModified ?? DateTime.now();
|
||||
_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));
|
||||
});
|
||||
_log.info("Fast synced local album ${ape.name} to DB");
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to fast sync local album ${ape.name} to DB", e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Adds a new album from the device to the database and Accumulates all
|
||||
/// assets already existing in the database to the list of `existing` assets
|
||||
Future<void> _addAlbumFromDevice(
|
||||
AssetPathEntity ape,
|
||||
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);
|
||||
_removeDuplicates(assets);
|
||||
final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
|
||||
_log.info(
|
||||
"${existingInDb.length} assets already existed in DB, to upsert ${updated.length}",
|
||||
);
|
||||
await upsertAssetsWithExif(updated);
|
||||
existing.addAll(existingInDb);
|
||||
a.assets.addAll(existingInDb);
|
||||
a.assets.addAll(updated);
|
||||
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
|
||||
a.thumbnail.value = thumb;
|
||||
try {
|
||||
await _db.writeTxn(() => _db.albums.store(a));
|
||||
_log.info("Added a new local album to DB: ${ape.name}");
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to add new local album ${ape.name} to DB", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a tuple (existing, updated)
|
||||
Future<(List<Asset> existing, List<Asset> updated)> _linkWithExistingFromDb(
|
||||
List<Asset> assets,
|
||||
) async {
|
||||
if (assets.isEmpty) return ([].cast<Asset>(), [].cast<Asset>());
|
||||
|
||||
final List<Asset?> inDb = await _db.assets.getAllByOwnerIdChecksum(
|
||||
assets.map((a) => a.ownerId).toInt64List(),
|
||||
assets.map((a) => a.checksum).toList(growable: false),
|
||||
);
|
||||
assert(inDb.length == assets.length);
|
||||
final List<Asset> existing = [], toUpsert = [];
|
||||
for (int i = 0; i < assets.length; i++) {
|
||||
final Asset? b = inDb[i];
|
||||
if (b == null) {
|
||||
toUpsert.add(assets[i]);
|
||||
continue;
|
||||
}
|
||||
if (b.canUpdate(assets[i])) {
|
||||
final updated = b.updatedCopy(assets[i]);
|
||||
assert(updated.id != Isar.autoIncrement);
|
||||
toUpsert.add(updated);
|
||||
} else {
|
||||
existing.add(b);
|
||||
}
|
||||
}
|
||||
assert(existing.length + toUpsert.length == assets.length);
|
||||
return (existing, toUpsert);
|
||||
}
|
||||
|
||||
/// Inserts or updates the assets in the database with their ExifInfo (if any)
|
||||
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
|
||||
if (assets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final exifInfos = assets.map((e) => e.exifInfo).whereNotNull().toList();
|
||||
try {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(assets);
|
||||
for (final Asset added in assets) {
|
||||
added.exifInfo?.id = added.id;
|
||||
}
|
||||
await _db.exifInfos.putAll(exifInfos);
|
||||
});
|
||||
_log.info("Upserted ${assets.length} assets into the DB");
|
||||
} on IsarError catch (e) {
|
||||
_log.severe("Failed to upsert ${assets.length} assets into the DB", e);
|
||||
// give details on the errors
|
||||
assets.sort(Asset.compareByOwnerChecksum);
|
||||
final inDb = await _db.assets.getAllByOwnerIdChecksum(
|
||||
assets.map((e) => e.ownerId).toInt64List(),
|
||||
assets.map((e) => e.checksum).toList(growable: false),
|
||||
);
|
||||
for (int i = 0; i < assets.length; i++) {
|
||||
final Asset a = assets[i];
|
||||
final Asset? b = inDb[i];
|
||||
if (b == null) {
|
||||
if (a.id != Isar.autoIncrement) {
|
||||
_log.warning(
|
||||
"Trying to update an asset that does not exist in DB:\n$a",
|
||||
);
|
||||
}
|
||||
} else if (a.id != b.id) {
|
||||
_log.warning(
|
||||
"Trying to insert another asset with the same checksum+owner. In DB:\n$b\nTo insert:\n$a",
|
||||
);
|
||||
}
|
||||
}
|
||||
for (int i = 1; i < assets.length; i++) {
|
||||
if (Asset.compareByOwnerChecksum(assets[i - 1], assets[i]) == 0) {
|
||||
_log.warning(
|
||||
"Trying to insert duplicate assets:\n${assets[i - 1]}\n${assets[i]}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Asset> _removeDuplicates(List<Asset> assets) {
|
||||
final int before = assets.length;
|
||||
assets.sort(Asset.compareByOwnerChecksumCreatedModified);
|
||||
assets.uniqueConsecutive(
|
||||
compare: Asset.compareByOwnerChecksum,
|
||||
onDuplicate: (a, b) =>
|
||||
_log.info("Ignoring duplicate assets on device:\n$a\n$b"),
|
||||
);
|
||||
final int duplicates = before - assets.length;
|
||||
if (duplicates > 0) {
|
||||
_log.warning("Ignored $duplicates duplicate assets on device");
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
/// returns `true` if the albums differ on the surface
|
||||
Future<bool> _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> _removeAllLocalAlbumsAndAssets() async {
|
||||
try {
|
||||
final assets = await _db.assets.where().localIdIsNotNull().findAll();
|
||||
final (toDelete, toUpdate) =
|
||||
_handleAssetRemoval(assets, [], remote: false);
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.deleteAll(toDelete);
|
||||
await _db.assets.putAll(toUpdate);
|
||||
await _db.albums.where().localIdIsNotNull().deleteAll();
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.severe("Failed to remove all local albums and assets", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a triple(toAdd, toUpdate, toRemove)
|
||||
(List<Asset> toAdd, List<Asset> toUpdate, List<Asset> toRemove) _diffAssets(
|
||||
List<Asset> assets,
|
||||
List<Asset> inDb, {
|
||||
bool? remote,
|
||||
int Function(Asset, Asset) compare = Asset.compareByChecksum,
|
||||
}) {
|
||||
// fast paths for trivial cases: reduces memory usage during initial sync etc.
|
||||
if (assets.isEmpty && inDb.isEmpty) {
|
||||
return const ([], [], []);
|
||||
} else if (assets.isEmpty && remote == null) {
|
||||
// remove all from database
|
||||
return (const [], const [], inDb);
|
||||
} else if (inDb.isEmpty) {
|
||||
// add all assets
|
||||
return (assets, const [], const []);
|
||||
}
|
||||
|
||||
final List<Asset> toAdd = [];
|
||||
final List<Asset> toUpdate = [];
|
||||
final List<Asset> toRemove = [];
|
||||
diffSortedListsSync(
|
||||
inDb,
|
||||
assets,
|
||||
compare: compare,
|
||||
both: (Asset a, Asset b) {
|
||||
if (a.canUpdate(b)) {
|
||||
toUpdate.add(a.updatedCopy(b));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onlyFirst: (Asset a) {
|
||||
if (remote == true && a.isLocal) {
|
||||
if (a.remoteId != null) {
|
||||
a.remoteId = null;
|
||||
toUpdate.add(a);
|
||||
}
|
||||
} else if (remote == false && a.isRemote) {
|
||||
if (a.isLocal) {
|
||||
a.localId = null;
|
||||
toUpdate.add(a);
|
||||
}
|
||||
} else {
|
||||
toRemove.add(a);
|
||||
}
|
||||
},
|
||||
onlySecond: (Asset b) => toAdd.add(b),
|
||||
);
|
||||
return (toAdd, toUpdate, toRemove);
|
||||
}
|
||||
|
||||
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
|
||||
(List<int> toDelete, List<Asset> toUpdate) _handleAssetRemoval(
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> existing, {
|
||||
bool? remote,
|
||||
}) {
|
||||
if (deleteCandidates.isEmpty) {
|
||||
return const ([], []);
|
||||
}
|
||||
deleteCandidates.sort(Asset.compareById);
|
||||
deleteCandidates.uniqueConsecutive(compare: Asset.compareById);
|
||||
existing.sort(Asset.compareById);
|
||||
existing.uniqueConsecutive(compare: Asset.compareById);
|
||||
final (tooAdd, toUpdate, toRemove) = _diffAssets(
|
||||
existing,
|
||||
deleteCandidates,
|
||||
compare: Asset.compareById,
|
||||
remote: remote,
|
||||
);
|
||||
assert(tooAdd.isEmpty, "toAdd should be empty in _handleAssetRemoval");
|
||||
return (toRemove.map((e) => e.id).toList(), toUpdate);
|
||||
}
|
||||
|
||||
/// returns `true` if the albums differ on the surface
|
||||
bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) {
|
||||
return dto.assetCount != a.assetCount ||
|
||||
dto.albumName != a.name ||
|
||||
dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId ||
|
||||
dto.shared != a.shared ||
|
||||
dto.sharedUsers.length != a.sharedUsers.length ||
|
||||
!dto.updatedAt.isAtSameMomentAs(a.modifiedAt) ||
|
||||
!isAtSameMomentAs(dto.startDate, a.startDate) ||
|
||||
!isAtSameMomentAs(dto.endDate, a.endDate) ||
|
||||
!isAtSameMomentAs(
|
||||
dto.lastModifiedAssetTimestamp,
|
||||
a.lastModifiedAssetTimestamp,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final trashServiceProvider = Provider<TrashService>((ref) {
|
||||
return TrashService(
|
||||
ref.watch(apiServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class TrashService {
|
||||
final _log = Logger("TrashService");
|
||||
|
||||
final ApiService _apiService;
|
||||
|
||||
TrashService(this._apiService);
|
||||
|
||||
Future<bool> restoreAssets(Iterable<Asset> assetList) async {
|
||||
try {
|
||||
List<String> remoteIds =
|
||||
assetList.where((a) => a.isRemote).map((e) => e.remoteId!).toList();
|
||||
await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteIds));
|
||||
return true;
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore assets", error, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> restoreAsset(Asset asset) async {
|
||||
try {
|
||||
if (asset.isRemote) {
|
||||
List<String> remoteId = [asset.remoteId!];
|
||||
|
||||
await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteId));
|
||||
}
|
||||
return true;
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore assets", error, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> emptyTrash() async {
|
||||
try {
|
||||
await _apiService.trashApi.emptyTrash();
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot empty trash", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> restoreTrash() async {
|
||||
try {
|
||||
await _apiService.trashApi.restoreTrash();
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore trash", error, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/services/partner.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/sync.service.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final userServiceProvider = Provider(
|
||||
(ref) => UserService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(partnerServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class UserService {
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
final SyncService _syncService;
|
||||
final PartnerService _partnerService;
|
||||
final Logger _log = Logger("UserService");
|
||||
|
||||
UserService(
|
||||
this._apiService,
|
||||
this._db,
|
||||
this._syncService,
|
||||
this._partnerService,
|
||||
);
|
||||
|
||||
Future<List<User>?> _getAllUsers({required bool isAll}) async {
|
||||
try {
|
||||
final dto = await _apiService.userApi.getAllUsers(isAll);
|
||||
return dto?.map(User.fromUserDto).toList();
|
||||
} catch (e) {
|
||||
_log.warning("Failed get all users", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<User>> getUsersInDb({bool self = false}) async {
|
||||
if (self) {
|
||||
return _db.users.where().findAll();
|
||||
}
|
||||
final int userId = Store.get(StoreKey.currentUser).isarId;
|
||||
return _db.users.where().isarIdNotEqualTo(userId).findAll();
|
||||
}
|
||||
|
||||
Future<CreateProfileImageResponseDto?> uploadProfileImage(XFile image) async {
|
||||
try {
|
||||
return await _apiService.userApi.createProfileImage(
|
||||
MultipartFile.fromBytes(
|
||||
'file',
|
||||
await image.readAsBytes(),
|
||||
filename: image.name,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
_log.warning("Failed to upload profile image", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> refreshUsers() async {
|
||||
final List<User>? users = await _getAllUsers(isAll: true);
|
||||
final List<User>? sharedBy =
|
||||
await _partnerService.getPartners(PartnerDirection.sharedBy);
|
||||
final List<User>? sharedWith =
|
||||
await _partnerService.getPartners(PartnerDirection.sharedWith);
|
||||
|
||||
if (users == null || sharedBy == null || sharedWith == null) {
|
||||
_log.warning("Failed to refresh users");
|
||||
return false;
|
||||
}
|
||||
|
||||
users.sortBy((u) => u.id);
|
||||
sharedBy.sortBy((u) => u.id);
|
||||
sharedWith.sortBy((u) => u.id);
|
||||
|
||||
diffSortedListsSync(
|
||||
users,
|
||||
sharedBy,
|
||||
compare: (User a, User b) => a.id.compareTo(b.id),
|
||||
both: (User a, User b) => a.isPartnerSharedBy = true,
|
||||
onlyFirst: (_) {},
|
||||
onlySecond: (_) {},
|
||||
);
|
||||
|
||||
diffSortedListsSync(
|
||||
users,
|
||||
sharedWith,
|
||||
compare: (User a, User b) => a.id.compareTo(b.id),
|
||||
both: (User a, User b) {
|
||||
a.isPartnerSharedWith = true;
|
||||
a.inTimeline = b.inTimeline;
|
||||
return true;
|
||||
},
|
||||
onlyFirst: (_) {},
|
||||
onlySecond: (_) {},
|
||||
);
|
||||
|
||||
return _syncService.syncUsersFromServer(users);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user