Merge branch 'main' into fix/bring-back-delete-backed-up-only

This commit is contained in:
Alex
2025-10-02 11:30:47 -05:00
committed by GitHub
266 changed files with 12110 additions and 4362 deletions
@@ -15,6 +15,7 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -138,6 +139,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
Future<void> _handleBetaTimelineResume() async {
_ref.read(backupProvider.notifier).cancelBackup();
unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock());
// Give isolates time to complete any ongoing database transactions
await Future.delayed(const Duration(milliseconds: 500));
@@ -146,17 +148,22 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
try {
bool syncSuccess = false;
await Future.wait([
_safeRun(backgroundManager.syncLocal(), "syncLocal"),
_safeRun(backgroundManager.syncRemote(), "syncRemote"),
]);
await Future.wait([
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
_resumeBackup();
}),
_resumeBackup(),
_safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"),
]);
if (syncSuccess) {
await Future.wait([
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
_resumeBackup();
}),
_resumeBackup(),
]);
} else {
_ref.read(driftBackupProvider.notifier).updateError(BackupError.syncFailed);
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
}
if (isAlbumLinkedSyncEnable) {
await _safeRun(backgroundManager.syncLinkedAlbum(), "syncLinkedAlbum");
@@ -209,6 +216,9 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_pauseOperation = Completer<void>();
try {
if (Store.isBetaTimelineEnabled) {
unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock());
}
await _performPause();
} catch (e, stackTrace) {
_log.severe("Error during app pause", e, stackTrace);
@@ -240,6 +250,10 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
Future<void> handleAppDetached() async {
state = AppLifeCycleEnum.detached;
if (Store.isBetaTimelineEnabled) {
unawaited(_ref.read(backgroundWorkerLockServiceProvider).unlock());
}
// Flush logs before closing database
try {
LogService.I.flush();
@@ -1,6 +1,5 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:async';
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
@@ -8,12 +7,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
class EnqueueStatus {
final int enqueueCount;
@@ -36,6 +36,7 @@ class DriftUploadStatus {
final int fileSize;
final String networkSpeedAsString;
final bool? isFailed;
final String? error;
const DriftUploadStatus({
required this.taskId,
@@ -44,6 +45,7 @@ class DriftUploadStatus {
required this.fileSize,
required this.networkSpeedAsString,
this.isFailed,
this.error,
});
DriftUploadStatus copyWith({
@@ -53,6 +55,7 @@ class DriftUploadStatus {
int? fileSize,
String? networkSpeedAsString,
bool? isFailed,
String? error,
}) {
return DriftUploadStatus(
taskId: taskId ?? this.taskId,
@@ -61,12 +64,13 @@ class DriftUploadStatus {
fileSize: fileSize ?? this.fileSize,
networkSpeedAsString: networkSpeedAsString ?? this.networkSpeedAsString,
isFailed: isFailed ?? this.isFailed,
error: error ?? this.error,
);
}
@override
String toString() {
return 'DriftUploadStatus(taskId: $taskId, filename: $filename, progress: $progress, fileSize: $fileSize, networkSpeedAsString: $networkSpeedAsString, isFailed: $isFailed)';
return 'DriftUploadStatus(taskId: $taskId, filename: $filename, progress: $progress, fileSize: $fileSize, networkSpeedAsString: $networkSpeedAsString, isFailed: $isFailed, error: $error)';
}
@override
@@ -78,7 +82,8 @@ class DriftUploadStatus {
other.progress == progress &&
other.fileSize == fileSize &&
other.networkSpeedAsString == networkSpeedAsString &&
other.isFailed == isFailed;
other.isFailed == isFailed &&
other.error == error;
}
@override
@@ -88,37 +93,13 @@ class DriftUploadStatus {
progress.hashCode ^
fileSize.hashCode ^
networkSpeedAsString.hashCode ^
isFailed.hashCode;
isFailed.hashCode ^
error.hashCode;
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'taskId': taskId,
'filename': filename,
'progress': progress,
'fileSize': fileSize,
'networkSpeedAsString': networkSpeedAsString,
'isFailed': isFailed,
};
}
factory DriftUploadStatus.fromMap(Map<String, dynamic> map) {
return DriftUploadStatus(
taskId: map['taskId'] as String,
filename: map['filename'] as String,
progress: map['progress'] as double,
fileSize: map['fileSize'] as int,
networkSpeedAsString: map['networkSpeedAsString'] as String,
isFailed: map['isFailed'] != null ? map['isFailed'] as bool : null,
);
}
String toJson() => json.encode(toMap());
factory DriftUploadStatus.fromJson(String source) =>
DriftUploadStatus.fromMap(json.decode(source) as Map<String, dynamic>);
}
enum BackupError { none, syncFailed }
class DriftBackupState {
final int totalCount;
final int backupCount;
@@ -128,7 +109,9 @@ class DriftBackupState {
final int enqueueCount;
final int enqueueTotalCount;
final bool isSyncing;
final bool isCanceling;
final BackupError error;
final Map<String, DriftUploadStatus> uploadItems;
@@ -140,7 +123,9 @@ class DriftBackupState {
required this.enqueueCount,
required this.enqueueTotalCount,
required this.isCanceling,
required this.isSyncing,
required this.uploadItems,
this.error = BackupError.none,
});
DriftBackupState copyWith({
@@ -151,7 +136,9 @@ class DriftBackupState {
int? enqueueCount,
int? enqueueTotalCount,
bool? isCanceling,
bool? isSyncing,
Map<String, DriftUploadStatus>? uploadItems,
BackupError? error,
}) {
return DriftBackupState(
totalCount: totalCount ?? this.totalCount,
@@ -161,13 +148,15 @@ class DriftBackupState {
enqueueCount: enqueueCount ?? this.enqueueCount,
enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount,
isCanceling: isCanceling ?? this.isCanceling,
isSyncing: isSyncing ?? this.isSyncing,
uploadItems: uploadItems ?? this.uploadItems,
error: error ?? this.error,
);
}
@override
String toString() {
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, uploadItems: $uploadItems)';
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, isSyncing: $isSyncing, uploadItems: $uploadItems, error: $error)';
}
@override
@@ -182,7 +171,9 @@ class DriftBackupState {
other.enqueueCount == enqueueCount &&
other.enqueueTotalCount == enqueueTotalCount &&
other.isCanceling == isCanceling &&
mapEquals(other.uploadItems, uploadItems);
other.isSyncing == isSyncing &&
mapEquals(other.uploadItems, uploadItems) &&
other.error == error;
}
@override
@@ -194,7 +185,9 @@ class DriftBackupState {
enqueueCount.hashCode ^
enqueueTotalCount.hashCode ^
isCanceling.hashCode ^
uploadItems.hashCode;
isSyncing.hashCode ^
uploadItems.hashCode ^
error.hashCode;
}
}
@@ -213,7 +206,9 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
enqueueCount: 0,
enqueueTotalCount: 0,
isCanceling: false,
isSyncing: false,
uploadItems: {},
error: BackupError.none,
),
) {
{
@@ -266,7 +261,24 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
return;
}
state = state.copyWith(uploadItems: {...state.uploadItems, taskId: currentItem.copyWith(isFailed: true)});
String? error;
final exception = update.exception;
if (exception != null && exception is TaskHttpException) {
final message = tryJsonDecode(exception.description)?['message'] as String?;
if (message != null) {
final responseCode = exception.httpResponseCode;
error = "${exception.exceptionType}, response code $responseCode: $message";
}
}
error ??= update.exception?.toString();
state = state.copyWith(
uploadItems: {
...state.uploadItems,
taskId: currentItem.copyWith(isFailed: true, error: error),
},
);
_logger.fine("Upload failed for taskId: $taskId, exception: ${update.exception}");
break;
case TaskStatus.canceled:
@@ -330,7 +342,16 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
);
}
void updateError(BackupError error) async {
state = state.copyWith(error: error);
}
void updateSyncing(bool isSyncing) async {
state = state.copyWith(isSyncing: isSyncing);
}
Future<void> startBackup(String userId) {
state = state.copyWith(error: BackupError.none);
return _uploadService.startBackup(userId, _updateEnqueueCount);
}
@@ -340,7 +361,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
Future<void> cancel() async {
dPrint(() => "Canceling backup tasks...");
state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true);
state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none);
final activeTaskCount = await _uploadService.cancelBackup();
@@ -356,6 +377,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
Future<void> handleBackupResume(String userId) async {
_logger.info("Resuming backup tasks...");
state = state.copyWith(error: BackupError.none);
final tasks = await _uploadService.getActiveTasks(kBackupGroup);
_logger.info("Found ${tasks.length} tasks");
@@ -383,7 +405,7 @@ final driftBackupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>
return [];
}
return ref.read(backupRepositoryProvider).getCandidates(user.id);
return ref.read(backupRepositoryProvider).getCandidates(user.id, onlyHashed: false);
});
final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family<List<LocalAlbum>, String>((
@@ -3,7 +3,10 @@ import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -36,6 +39,7 @@ class ActionNotifier extends Notifier<void> {
late ActionService _service;
late UploadService _uploadService;
late DownloadService _downloadService;
late AssetService _assetService;
ActionNotifier() : super();
@@ -43,6 +47,7 @@ class ActionNotifier extends Notifier<void> {
void build() {
_uploadService = ref.watch(uploadServiceProvider);
_service = ref.watch(actionServiceProvider);
_assetService = ref.watch(assetServiceProvider);
_downloadService = ref.watch(downloadServiceProvider);
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
@@ -342,6 +347,14 @@ class ActionNotifier extends Notifier<void> {
final assets = _getOwnedRemoteAssetsForSource(source);
try {
await _service.unStack(assets.map((e) => e.stackId).nonNulls.toList());
if (source == ActionSource.viewer) {
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
if (updatedParent != null) {
ref.read(currentAssetNotifier.notifier).setAsset(updatedParent);
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
}
}
return ActionResult(count: assets.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to unstack assets', error, stack);
@@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
final localAssetRepository = Provider<DriftLocalAssetRepository>(
(ref) => DriftLocalAssetRepository(ref.watch(driftProvider)),
@@ -19,9 +20,13 @@ final assetServiceProvider = Provider(
),
);
final placesProvider = FutureProvider<List<(String, String)>>(
(ref) => AssetService(
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository),
).getPlaces(),
);
final placesProvider = FutureProvider<List<(String, String)>>((ref) {
final assetService = ref.watch(assetServiceProvider);
final auth = ref.watch(currentUserProvider);
if (auth == null) {
return Future.value(const []);
}
return assetService.getPlaces(auth.id);
});
@@ -1,12 +1,17 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/background_worker.service.dart';
import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/thumbnail_api.g.dart';
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
final backgroundWorkerLockServiceProvider = Provider<BackgroundWorkerLockService>(
(_) => BackgroundWorkerLockService(BackgroundWorkerLockApi()),
);
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
@@ -28,6 +28,8 @@ class MultiSelectState {
bool get hasRemote =>
selectedAssets.any((asset) => asset.storage == AssetState.remote || asset.storage == AssetState.merged);
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
bool get hasLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);