background upload plugin

add schemas

sync variants

formatting

initial implementation

use existing db, wip

move to separate folder

fix table definitions

wip

wiring it up
This commit is contained in:
mertalev
2025-11-09 20:09:08 -05:00
parent 1e1c4ac9d2
commit 41f013387f
67 changed files with 17482 additions and 1260 deletions
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
@@ -11,7 +10,6 @@ import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
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';
@@ -148,21 +146,13 @@ 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().then((success) => syncSuccess = success), "syncRemote"),
_safeRun(backgroundManager.syncRemote(), "syncRemote"),
]);
if (syncSuccess) {
await Future.wait([
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
_resumeBackup();
}),
_resumeBackup(),
]);
} else {
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
}
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
await _safeRun(uploadApi.refresh(), "refresh");
if (isAlbumLinkedSyncEnable) {
await _safeRun(backgroundManager.syncLinkedAlbum(), "syncLinkedAlbum");
@@ -172,20 +162,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
}
}
Future<void> _resumeBackup() async {
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
if (isEnableBackup) {
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser != null) {
await _safeRun(
_ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id),
"handleBackupResume",
);
}
}
}
// Helper method to check if operations should continue
bool _shouldContinueOperation() {
return [AppLifeCycleEnum.resumed, AppLifeCycleEnum.active].contains(state) &&
@@ -1,17 +1,14 @@
import 'dart:io';
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/platform/upload_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:path/path.dart';
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
((ref) => ShareIntentUploadStateNotifier(
@@ -25,10 +22,12 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
final AppRouter router;
final UploadService _uploadService;
final ShareIntentService _shareIntentService;
late final StreamSubscription<UploadApiTaskStatus> _taskStatusStream;
late final StreamSubscription<UploadApiTaskProgress> _taskProgressStream;
ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) {
_uploadService.taskStatusStream.listen(_updateUploadStatus);
_uploadService.taskProgressStream.listen(_taskProgressCallback);
_taskStatusStream = _uploadService.taskStatusStream.listen(_updateUploadStatus);
_taskProgressStream = _uploadService.taskProgressStream.listen(_taskProgressCallback);
}
void init() {
@@ -36,6 +35,13 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
_shareIntentService.init();
}
@override
void dispose() {
unawaited(_taskStatusStream.cancel());
unawaited(_taskProgressStream.cancel());
super.dispose();
}
void onSharedMedia(List<ShareIntentAttachment> attachments) {
router.removeWhere((route) => route.name == "ShareIntentRoute");
clearAttachments();
@@ -65,82 +71,35 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
state = [];
}
void _updateUploadStatus(TaskStatusUpdate task) async {
if (task.status == TaskStatus.canceled) {
return;
}
final taskId = task.task.taskId;
void _updateUploadStatus(UploadApiTaskStatus task) {
final uploadStatus = switch (task.status) {
TaskStatus.complete => UploadStatus.complete,
TaskStatus.failed => UploadStatus.failed,
TaskStatus.canceled => UploadStatus.canceled,
TaskStatus.enqueued => UploadStatus.enqueued,
TaskStatus.running => UploadStatus.running,
TaskStatus.paused => UploadStatus.paused,
TaskStatus.notFound => UploadStatus.notFound,
TaskStatus.waitingToRetry => UploadStatus.waitingToRetry,
UploadApiStatus.uploadComplete => UploadStatus.complete,
UploadApiStatus.uploadFailed || UploadApiStatus.downloadFailed => UploadStatus.failed,
UploadApiStatus.uploadQueued => UploadStatus.enqueued,
_ => UploadStatus.preparing,
};
final taskId = task.id.toInt();
state = [
for (final attachment in state)
if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment,
if (attachment.id == taskId) attachment.copyWith(status: uploadStatus) else attachment,
];
}
void _taskProgressCallback(TaskProgressUpdate update) {
void _taskProgressCallback(UploadApiTaskProgress update) {
// Ignore if the task is canceled or completed
if (update.progress == downloadFailed || update.progress == downloadCompleted) {
return;
}
final taskId = update.task.taskId;
final taskId = update.id.toInt();
state = [
for (final attachment in state)
if (attachment.id == taskId.toInt()) attachment.copyWith(uploadProgress: update.progress) else attachment,
if (attachment.id == taskId) attachment.copyWith(uploadProgress: update.progress) else attachment,
];
}
Future<void> upload(File file) async {
final task = await _buildUploadTask(hash(file.path).toString(), file);
await _uploadService.enqueueTasks([task]);
}
Future<UploadTask> _buildUploadTask(String id, File file, {Map<String, String>? fields}) async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final url = Uri.parse('$serverEndpoint/assets').toString();
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
final (baseDirectory, directory, filename) = await Task.split(filePath: file.path);
final stats = await file.stat();
final fileCreatedAt = stats.changed;
final fileModifiedAt = stats.modified;
final fieldsMap = {
'filename': filename,
'deviceAssetId': id,
'deviceId': deviceId,
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
'isFavorite': 'false',
'duration': '0',
if (fields != null) ...fields,
};
return UploadTask(
taskId: id,
httpRequestMethod: 'POST',
url: url,
headers: headers,
filename: filename,
fields: fieldsMap,
baseDirectory: baseDirectory,
directory: directory,
fileField: 'assetData',
group: kManualUploadGroup,
updates: Updates.statusAndProgress,
);
Future<void> upload(List<ShareIntentAttachment> files) {
return uploadApi.enqueueFiles(files.map((e) => e.path).toList(growable: false));
}
}
@@ -1,14 +1,12 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
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/platform/upload_api.g.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';
@@ -196,6 +194,9 @@ final driftBackupProvider = StateNotifierProvider<DriftBackupNotifier, DriftBack
});
class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
late final StreamSubscription<UploadApiTaskStatus> _taskStatusStream;
late final StreamSubscription<UploadApiTaskProgress> _taskProgressStream;
DriftBackupNotifier(this._uploadService)
: super(
const DriftBackupState(
@@ -211,17 +212,20 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
error: BackupError.none,
),
) {
{
_uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
_uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
}
_taskStatusStream = _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
_taskProgressStream = _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
}
final UploadService _uploadService;
StreamSubscription<TaskStatusUpdate>? _statusSubscription;
StreamSubscription<TaskProgressUpdate>? _progressSubscription;
final _logger = Logger("DriftBackupNotifier");
@override
void dispose() {
unawaited(_taskStatusStream.cancel());
unawaited(_taskProgressStream.cancel());
super.dispose();
}
/// Remove upload item from state
void _removeUploadItem(String taskId) {
if (state.uploadItems.containsKey(taskId)) {
@@ -231,16 +235,12 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
final taskId = update.task.taskId;
void _handleTaskStatusUpdate(UploadApiTaskStatus update) {
final taskId = update.id;
switch (update.status) {
case TaskStatus.complete:
if (update.task.group == kBackupGroup) {
if (update.responseStatusCode == 201) {
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
}
}
case UploadApiStatus.uploadComplete:
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
// Remove the completed task from the upload items
if (state.uploadItems.containsKey(taskId)) {
@@ -249,40 +249,45 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
});
}
case TaskStatus.failed:
// Ignore retry errors to avoid confusing users
if (update.exception?.description == 'Delayed or retried enqueue failed') {
_removeUploadItem(taskId);
return;
}
case UploadApiStatus.uploadFailed:
final currentItem = state.uploadItems[taskId];
if (currentItem == null) {
return;
}
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();
// 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 UploadApiStatus.uploadPending:
state = state.copyWith(
uploadItems: {
...state.uploadItems,
taskId: currentItem.copyWith(isFailed: true, error: error),
taskId: DriftUploadStatus(
taskId: update.id.toString(),
filename: update.filename,
fileSize: 0,
networkSpeedAsString: "",
progress: 0.0,
),
},
);
_logger.fine("Upload failed for taskId: $taskId, exception: ${update.exception}");
break;
case TaskStatus.canceled:
_removeUploadItem(update.task.taskId);
break;
default:
@@ -290,42 +295,21 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
}
void _handleTaskProgressUpdate(TaskProgressUpdate update) {
final taskId = update.task.taskId;
final filename = update.task.displayName;
void _handleTaskProgressUpdate(UploadApiTaskProgress update) {
final taskId = update.id;
final progress = update.progress;
final currentItem = state.uploadItems[taskId];
if (currentItem != null) {
if (progress == kUploadStatusCanceled) {
_removeUploadItem(update.task.taskId);
return;
}
state = state.copyWith(
uploadItems: {
...state.uploadItems,
taskId: update.hasExpectedFileSize
? currentItem.copyWith(
progress: progress,
fileSize: update.expectedFileSize,
networkSpeedAsString: update.networkSpeedAsString,
)
: currentItem.copyWith(progress: progress),
},
);
if (currentItem == null) {
return;
}
state = state.copyWith(
uploadItems: {
...state.uploadItems,
taskId: DriftUploadStatus(
taskId: taskId,
filename: filename,
taskId: currentItem.copyWith(
progress: progress,
fileSize: update.expectedFileSize,
networkSpeedAsString: update.networkSpeedAsString,
fileSize: update.totalBytes,
networkSpeedAsString: update.speed?.toStringAsFixed(2) ?? "",
),
},
);
@@ -350,52 +334,18 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
state = state.copyWith(isSyncing: isSyncing);
}
Future<void> startBackup(String userId) {
Future<void> startBackup() {
state = state.copyWith(error: BackupError.none);
return _uploadService.startBackup(userId, _updateEnqueueCount);
}
void _updateEnqueueCount(EnqueueStatus status) {
state = state.copyWith(enqueueCount: status.enqueueCount, enqueueTotalCount: status.totalCount);
return _uploadService.startBackup();
}
Future<void> cancel() async {
dPrint(() => "Canceling backup tasks...");
state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none);
final activeTaskCount = await _uploadService.cancelBackup();
if (activeTaskCount > 0) {
dPrint(() => "$activeTaskCount tasks left, continuing to cancel...");
await cancel();
} else {
dPrint(() => "All tasks canceled successfully.");
// Clear all upload items when cancellation is complete
state = state.copyWith(isCanceling: false, uploadItems: {});
}
}
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");
if (tasks.isEmpty) {
// Start a new backup queue
_logger.info("Start a new backup queue");
return startBackup(userId);
}
_logger.info("Tasks to resume: ${tasks.length}");
return _uploadService.resumeBackup();
}
@override
void dispose() {
_statusSubscription?.cancel();
_progressSubscription?.cancel();
super.dispose();
await _uploadService.cancelBackup();
dPrint(() => "All tasks canceled successfully.");
state = state.copyWith(isCanceling: false, uploadItems: {});
}
}
@@ -5,6 +5,7 @@ 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';
import 'package:immich_mobile/platform/upload_api.g.dart';
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
@@ -17,3 +18,5 @@ final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
final thumbnailApi = ThumbnailApi();
final uploadApi = UploadApi();