mirror of
https://github.com/immich-app/immich.git
synced 2025-12-07 17:23:12 +03:00
Compare commits
3 Commits
feat/asset
...
bring-back
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d93a2cadf1 | ||
|
|
7f016bfcfa | ||
|
|
21ced44159 |
@@ -82,6 +82,60 @@ class StorageRepository {
|
|||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if an asset is available locally or needs to be downloaded from iCloud
|
||||||
|
Future<bool> isAssetAvailableLocally(String assetId) async {
|
||||||
|
final log = Logger('StorageRepository');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final entity = await AssetEntity.fromId(assetId);
|
||||||
|
if (entity == null) {
|
||||||
|
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await entity.isLocallyAvailable(isOrigin: true);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
log.warning("Error checking if asset is locally available $assetId", error, stackTrace);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load file from iCloud with progress handler (for iOS)
|
||||||
|
Future<File?> loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
|
||||||
|
final log = Logger('StorageRepository');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final entity = await AssetEntity.fromId(assetId);
|
||||||
|
if (entity == null) {
|
||||||
|
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await entity.loadFile(progressHandler: progressHandler);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
log.warning("Error loading file from cloud for asset $assetId", error, stackTrace);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load live photo motion file from iCloud with progress handler (for iOS)
|
||||||
|
Future<File?> loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
|
||||||
|
final log = Logger('StorageRepository');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final entity = await AssetEntity.fromId(assetId);
|
||||||
|
if (entity == null) {
|
||||||
|
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await entity.loadFile(withSubtype: true, progressHandler: progressHandler);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clearCache() async {
|
Future<void> clearCache() async {
|
||||||
final log = Logger('StorageRepository');
|
final log = Logger('StorageRepository');
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stopBackup() async {
|
Future<void> stopBackup() async {
|
||||||
await backupNotifier.cancel();
|
// await backupNotifier.cancel();
|
||||||
|
await backupNotifier.stopBackup();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final uploadItems = ref.watch(driftBackupProvider.select((state) => state.uploadItems));
|
final uploadItems = ref.watch(driftBackupProvider.select((state) => state.uploadItems));
|
||||||
|
final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -25,7 +26,9 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
scrolledUnderElevation: 1,
|
scrolledUnderElevation: 1,
|
||||||
),
|
),
|
||||||
body: uploadItems.isEmpty ? _buildEmptyState(context) : _buildUploadList(uploadItems),
|
body: uploadItems.isEmpty && iCloudProgress.isEmpty
|
||||||
|
? _buildEmptyState(context)
|
||||||
|
: _buildUploadList(uploadItems, iCloudProgress),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,19 +48,110 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildUploadList(Map<String, DriftUploadStatus> uploadItems) {
|
Widget _buildUploadList(Map<String, DriftUploadStatus> uploadItems, Map<String, double> iCloudProgress) {
|
||||||
|
final totalItems = uploadItems.length + iCloudProgress.length;
|
||||||
|
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
addAutomaticKeepAlives: true,
|
addAutomaticKeepAlives: true,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: uploadItems.length,
|
itemCount: totalItems,
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: 4),
|
separatorBuilder: (context, index) => const SizedBox(height: 4),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = uploadItems.values.elementAt(index);
|
// Show iCloud downloads first
|
||||||
|
if (index < iCloudProgress.length) {
|
||||||
|
final entry = iCloudProgress.entries.elementAt(index);
|
||||||
|
return _buildICloudDownloadCard(context, entry.key, entry.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then show upload items
|
||||||
|
final uploadIndex = index - iCloudProgress.length;
|
||||||
|
final item = uploadItems.values.elementAt(uploadIndex);
|
||||||
return _buildUploadCard(context, item);
|
return _buildUploadCard(context, item);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildICloudDownloadCard(BuildContext context, String assetId, double progress) {
|
||||||
|
final double progressPercentage = (progress * 100).clamp(0, 100);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: context.colorScheme.surfaceContainerHighest,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||||
|
side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.cloud_download_rounded, size: 20, color: context.colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 4,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Downloading from iCloud",
|
||||||
|
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
assetId,
|
||||||
|
style: context.textTheme.bodySmall?.copyWith(
|
||||||
|
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildICloudProgressIndicator(context, progress, progressPercentage),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildICloudProgressIndicator(BuildContext context, double progress, double percentage) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
alignment: AlignmentDirectional.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: TweenAnimationBuilder(
|
||||||
|
tween: Tween<double>(begin: 0.0, end: progress),
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
builder: (context, value, _) => CircularProgressIndicator(
|
||||||
|
backgroundColor: context.colorScheme.outline.withValues(alpha: 0.2),
|
||||||
|
strokeWidth: 3,
|
||||||
|
value: value,
|
||||||
|
color: context.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
percentage.toStringAsFixed(0),
|
||||||
|
style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, fontSize: 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text("iCloud", style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.primary, fontSize: 10)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildUploadCard(BuildContext context, DriftUploadStatus item) {
|
Widget _buildUploadCard(BuildContext context, DriftUploadStatus item) {
|
||||||
final isCompleted = item.progress >= 1.0;
|
final isCompleted = item.progress >= 1.0;
|
||||||
final double progressPercentage = (item.progress * 100).clamp(0, 100);
|
final double progressPercentage = (item.progress * 100).clamp(0, 100);
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
|||||||
|
|
||||||
final isSyncing = ref.watch(driftBackupProvider.select((state) => state.isSyncing));
|
final isSyncing = ref.watch(driftBackupProvider.select((state) => state.isSyncing));
|
||||||
|
|
||||||
final isProcessing = uploadTasks.isNotEmpty || isSyncing;
|
final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress));
|
||||||
|
|
||||||
|
final isProcessing = uploadTasks.isNotEmpty || isSyncing || iCloudProgress.isNotEmpty;
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _animationController,
|
animation: _animationController,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:background_downloader/background_downloader.dart';
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
|
import 'package:cancellation_token_http/http.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/constants/constants.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/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
@@ -13,7 +15,6 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/upload.service.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
class EnqueueStatus {
|
class EnqueueStatus {
|
||||||
final int enqueueCount;
|
final int enqueueCount;
|
||||||
@@ -114,6 +115,10 @@ class DriftBackupState {
|
|||||||
final BackupError error;
|
final BackupError error;
|
||||||
|
|
||||||
final Map<String, DriftUploadStatus> uploadItems;
|
final Map<String, DriftUploadStatus> uploadItems;
|
||||||
|
final CancellationToken? cancelToken;
|
||||||
|
|
||||||
|
/// iCloud download progress for assets (assetId -> progress 0.0-1.0)
|
||||||
|
final Map<String, double> iCloudDownloadProgress;
|
||||||
|
|
||||||
const DriftBackupState({
|
const DriftBackupState({
|
||||||
required this.totalCount,
|
required this.totalCount,
|
||||||
@@ -122,10 +127,12 @@ class DriftBackupState {
|
|||||||
required this.processingCount,
|
required this.processingCount,
|
||||||
required this.enqueueCount,
|
required this.enqueueCount,
|
||||||
required this.enqueueTotalCount,
|
required this.enqueueTotalCount,
|
||||||
required this.isCanceling,
|
|
||||||
required this.isSyncing,
|
required this.isSyncing,
|
||||||
required this.uploadItems,
|
required this.isCanceling,
|
||||||
this.error = BackupError.none,
|
this.error = BackupError.none,
|
||||||
|
required this.uploadItems,
|
||||||
|
this.cancelToken,
|
||||||
|
this.iCloudDownloadProgress = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
DriftBackupState copyWith({
|
DriftBackupState copyWith({
|
||||||
@@ -135,10 +142,12 @@ class DriftBackupState {
|
|||||||
int? processingCount,
|
int? processingCount,
|
||||||
int? enqueueCount,
|
int? enqueueCount,
|
||||||
int? enqueueTotalCount,
|
int? enqueueTotalCount,
|
||||||
bool? isCanceling,
|
|
||||||
bool? isSyncing,
|
bool? isSyncing,
|
||||||
Map<String, DriftUploadStatus>? uploadItems,
|
bool? isCanceling,
|
||||||
BackupError? error,
|
BackupError? error,
|
||||||
|
Map<String, DriftUploadStatus>? uploadItems,
|
||||||
|
CancellationToken? cancelToken,
|
||||||
|
Map<String, double>? iCloudDownloadProgress,
|
||||||
}) {
|
}) {
|
||||||
return DriftBackupState(
|
return DriftBackupState(
|
||||||
totalCount: totalCount ?? this.totalCount,
|
totalCount: totalCount ?? this.totalCount,
|
||||||
@@ -147,16 +156,18 @@ class DriftBackupState {
|
|||||||
processingCount: processingCount ?? this.processingCount,
|
processingCount: processingCount ?? this.processingCount,
|
||||||
enqueueCount: enqueueCount ?? this.enqueueCount,
|
enqueueCount: enqueueCount ?? this.enqueueCount,
|
||||||
enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount,
|
enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount,
|
||||||
isCanceling: isCanceling ?? this.isCanceling,
|
|
||||||
isSyncing: isSyncing ?? this.isSyncing,
|
isSyncing: isSyncing ?? this.isSyncing,
|
||||||
uploadItems: uploadItems ?? this.uploadItems,
|
isCanceling: isCanceling ?? this.isCanceling,
|
||||||
error: error ?? this.error,
|
error: error ?? this.error,
|
||||||
|
uploadItems: uploadItems ?? this.uploadItems,
|
||||||
|
cancelToken: cancelToken ?? this.cancelToken,
|
||||||
|
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, isSyncing: $isSyncing, uploadItems: $uploadItems, error: $error)';
|
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isSyncing: $isSyncing, isCanceling: $isCanceling, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -170,10 +181,12 @@ class DriftBackupState {
|
|||||||
other.processingCount == processingCount &&
|
other.processingCount == processingCount &&
|
||||||
other.enqueueCount == enqueueCount &&
|
other.enqueueCount == enqueueCount &&
|
||||||
other.enqueueTotalCount == enqueueTotalCount &&
|
other.enqueueTotalCount == enqueueTotalCount &&
|
||||||
other.isCanceling == isCanceling &&
|
|
||||||
other.isSyncing == isSyncing &&
|
other.isSyncing == isSyncing &&
|
||||||
|
other.isCanceling == isCanceling &&
|
||||||
|
other.error == error &&
|
||||||
|
mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) &&
|
||||||
mapEquals(other.uploadItems, uploadItems) &&
|
mapEquals(other.uploadItems, uploadItems) &&
|
||||||
other.error == error;
|
other.cancelToken == cancelToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -184,10 +197,12 @@ class DriftBackupState {
|
|||||||
processingCount.hashCode ^
|
processingCount.hashCode ^
|
||||||
enqueueCount.hashCode ^
|
enqueueCount.hashCode ^
|
||||||
enqueueTotalCount.hashCode ^
|
enqueueTotalCount.hashCode ^
|
||||||
isCanceling.hashCode ^
|
|
||||||
isSyncing.hashCode ^
|
isSyncing.hashCode ^
|
||||||
|
isCanceling.hashCode ^
|
||||||
|
error.hashCode ^
|
||||||
uploadItems.hashCode ^
|
uploadItems.hashCode ^
|
||||||
error.hashCode;
|
cancelToken.hashCode ^
|
||||||
|
iCloudDownloadProgress.hashCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,13 +367,82 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
|||||||
|
|
||||||
Future<void> startBackup(String userId) {
|
Future<void> startBackup(String userId) {
|
||||||
state = state.copyWith(error: BackupError.none);
|
state = state.copyWith(error: BackupError.none);
|
||||||
return _uploadService.startBackup(userId, _updateEnqueueCount);
|
// return _uploadService.startBackup(userId, _updateEnqueueCount);
|
||||||
|
|
||||||
|
final cancelToken = CancellationToken();
|
||||||
|
state = state.copyWith(cancelToken: cancelToken);
|
||||||
|
|
||||||
|
return _uploadService.startForegroundUpload(
|
||||||
|
userId,
|
||||||
|
cancelToken,
|
||||||
|
_handleForegroundBackupProgress,
|
||||||
|
_handleForegroundBackupSuccess,
|
||||||
|
_handleForegroundBackupError,
|
||||||
|
onICloudProgress: _handleICloudProgress,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateEnqueueCount(EnqueueStatus status) {
|
Future<void> stopBackup() async {
|
||||||
state = state.copyWith(enqueueCount: status.enqueueCount, enqueueTotalCount: status.totalCount);
|
state.cancelToken?.cancel();
|
||||||
|
state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleICloudProgress(String localAssetId, double progress) {
|
||||||
|
state = state.copyWith(iCloudDownloadProgress: {...state.iCloudDownloadProgress, localAssetId: progress});
|
||||||
|
|
||||||
|
// Remove from progress map when download completes
|
||||||
|
if (progress >= 1.0) {
|
||||||
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
final updatedProgress = Map<String, double>.from(state.iCloudDownloadProgress);
|
||||||
|
updatedProgress.remove(localAssetId);
|
||||||
|
state = state.copyWith(iCloudDownloadProgress: updatedProgress);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleForegroundBackupProgress(String localAssetId, int bytes, int totalBytes) {
|
||||||
|
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||||
|
final currentItem = state.uploadItems[localAssetId];
|
||||||
|
if (currentItem != null) {
|
||||||
|
state = state.copyWith(
|
||||||
|
uploadItems: {
|
||||||
|
...state.uploadItems,
|
||||||
|
localAssetId: currentItem.copyWith(progress: progress, fileSize: totalBytes),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(
|
||||||
|
uploadItems: {
|
||||||
|
...state.uploadItems,
|
||||||
|
localAssetId: DriftUploadStatus(
|
||||||
|
taskId: localAssetId,
|
||||||
|
filename: localAssetId,
|
||||||
|
progress: progress,
|
||||||
|
fileSize: totalBytes,
|
||||||
|
networkSpeedAsString: '',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) {
|
||||||
|
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
|
||||||
|
|
||||||
|
Future.delayed(const Duration(milliseconds: 1000), () {
|
||||||
|
_removeUploadItem(localAssetId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleForegroundBackupError(String errorMessage) {
|
||||||
|
_logger.severe("Upload failed: $errorMessage");
|
||||||
|
// Here you can update the state to reflect the error if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// void _updateEnqueueCount(EnqueueStatus status) {
|
||||||
|
// state = state.copyWith(enqueueCount: status.enqueueCount, enqueueTotalCount: status.totalCount);
|
||||||
|
// }
|
||||||
|
|
||||||
Future<void> cancel() async {
|
Future<void> cancel() async {
|
||||||
dPrint(() => "Canceling backup tasks...");
|
dPrint(() => "Canceling backup tasks...");
|
||||||
state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none);
|
state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
@@ -140,4 +141,153 @@ class UploadRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upload a single asset with progress tracking
|
||||||
|
Future<UploadResult> uploadSingleAsset({
|
||||||
|
required File file,
|
||||||
|
required String originalFileName,
|
||||||
|
required Map<String, String> headers,
|
||||||
|
required Map<String, String> fields,
|
||||||
|
required Client httpClient,
|
||||||
|
required CancellationToken cancelToken,
|
||||||
|
required void Function(int bytes, int totalBytes) onProgress,
|
||||||
|
}) async {
|
||||||
|
return _uploadFile(
|
||||||
|
file: file,
|
||||||
|
originalFileName: originalFileName,
|
||||||
|
headers: headers,
|
||||||
|
fields: fields,
|
||||||
|
httpClient: httpClient,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
onProgress: onProgress,
|
||||||
|
logContext: 'asset',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload live photo video part and return the video asset ID
|
||||||
|
Future<String?> uploadLivePhotoVideo({
|
||||||
|
required File livePhotoFile,
|
||||||
|
required String originalFileName,
|
||||||
|
required Map<String, String> headers,
|
||||||
|
required Map<String, String> fields,
|
||||||
|
required Client httpClient,
|
||||||
|
required CancellationToken cancelToken,
|
||||||
|
required void Function(int bytes, int totalBytes) onProgress,
|
||||||
|
}) async {
|
||||||
|
final result = await _uploadFile(
|
||||||
|
file: livePhotoFile,
|
||||||
|
originalFileName: originalFileName,
|
||||||
|
headers: headers,
|
||||||
|
fields: fields,
|
||||||
|
httpClient: httpClient,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
onProgress: onProgress,
|
||||||
|
logContext: 'livePhoto video',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isSuccess && result.remoteAssetId != null) {
|
||||||
|
return result.remoteAssetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal method to upload a file to the server
|
||||||
|
Future<UploadResult> _uploadFile({
|
||||||
|
required File file,
|
||||||
|
required String originalFileName,
|
||||||
|
required Map<String, String> headers,
|
||||||
|
required Map<String, String> fields,
|
||||||
|
required Client httpClient,
|
||||||
|
required CancellationToken cancelToken,
|
||||||
|
required void Function(int bytes, int totalBytes) onProgress,
|
||||||
|
required String logContext,
|
||||||
|
}) async {
|
||||||
|
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
|
final Logger logger = Logger('UploadRepository');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final fileStream = file.openRead();
|
||||||
|
final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName);
|
||||||
|
|
||||||
|
final baseRequest = CustomMultipartRequest('POST', Uri.parse('$savedEndpoint/assets'), onProgress: onProgress);
|
||||||
|
|
||||||
|
baseRequest.headers.addAll(headers);
|
||||||
|
baseRequest.fields.addAll(fields);
|
||||||
|
baseRequest.files.add(assetRawUploadData);
|
||||||
|
|
||||||
|
final response = await httpClient.send(baseRequest, cancellationToken: cancelToken);
|
||||||
|
final responseBody = jsonDecode(await response.stream.bytesToString());
|
||||||
|
|
||||||
|
if (![200, 201].contains(response.statusCode)) {
|
||||||
|
final error = responseBody;
|
||||||
|
final errorMessage = error['message'] ?? error['error'];
|
||||||
|
|
||||||
|
logger.warning("Error(${error['statusCode']}) uploading $logContext | $originalFileName | ${error['error']}");
|
||||||
|
|
||||||
|
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
|
||||||
|
} on CancelledException {
|
||||||
|
logger.warning("Upload $logContext was cancelled");
|
||||||
|
return UploadResult.cancelled();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace");
|
||||||
|
return UploadResult.error(errorMessage: error.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of an upload operation
|
||||||
|
class UploadResult {
|
||||||
|
final bool isSuccess;
|
||||||
|
final bool isCancelled;
|
||||||
|
final String? remoteAssetId;
|
||||||
|
final String? errorMessage;
|
||||||
|
final int? statusCode;
|
||||||
|
|
||||||
|
const UploadResult({
|
||||||
|
required this.isSuccess,
|
||||||
|
required this.isCancelled,
|
||||||
|
this.remoteAssetId,
|
||||||
|
this.errorMessage,
|
||||||
|
this.statusCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UploadResult.success({required String remoteAssetId}) {
|
||||||
|
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory UploadResult.error({String? errorMessage, int? statusCode}) {
|
||||||
|
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory UploadResult.cancelled() {
|
||||||
|
return const UploadResult(isSuccess: false, isCancelled: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom MultipartRequest with progress tracking
|
||||||
|
class CustomMultipartRequest extends MultipartRequest {
|
||||||
|
CustomMultipartRequest(super.method, super.url, {required this.onProgress});
|
||||||
|
|
||||||
|
final void Function(int bytes, int totalBytes) onProgress;
|
||||||
|
|
||||||
|
@override
|
||||||
|
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 ByteStream(stream);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import 'package:immich_mobile/services/app_settings.service.dart';
|
|||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
|
|
||||||
final uploadServiceProvider = Provider((ref) {
|
final uploadServiceProvider = Provider((ref) {
|
||||||
final service = UploadService(
|
final service = UploadService(
|
||||||
@@ -193,6 +194,208 @@ class UploadService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> startForegroundUpload(
|
||||||
|
String userId,
|
||||||
|
CancellationToken cancelToken,
|
||||||
|
void Function(String localAssetId, int bytes, int totalBytes) onProgress,
|
||||||
|
void Function(String localAssetId, String remoteAssetId) onSuccess,
|
||||||
|
void Function(String errorMessage) onError, {
|
||||||
|
void Function(String localAssetId, double progress)? onICloudProgress,
|
||||||
|
}) async {
|
||||||
|
const concurrentUploads = 3;
|
||||||
|
final httpClients = List.generate(concurrentUploads, (_) => Client());
|
||||||
|
|
||||||
|
await _storageRepository.clearCache();
|
||||||
|
|
||||||
|
shouldAbortQueuingTasks = false;
|
||||||
|
|
||||||
|
final candidates = await _backupRepository.getCandidates(userId);
|
||||||
|
if (candidates.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
int clientIndex = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < candidates.length; i += concurrentUploads) {
|
||||||
|
if (shouldAbortQueuingTasks || cancelToken.isCancelled) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final batch = candidates.skip(i).take(concurrentUploads).toList();
|
||||||
|
final uploadFutures = <Future<void>>[];
|
||||||
|
|
||||||
|
for (final asset in batch) {
|
||||||
|
final httpClient = httpClients[clientIndex % concurrentUploads];
|
||||||
|
clientIndex++;
|
||||||
|
|
||||||
|
uploadFutures.add(
|
||||||
|
_uploadSingleAsset(
|
||||||
|
asset,
|
||||||
|
httpClient,
|
||||||
|
cancelToken,
|
||||||
|
(bytes, totalBytes) => onProgress(asset.localId!, bytes, totalBytes),
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
onICloudProgress: onICloudProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Future.wait(uploadFutures);
|
||||||
|
|
||||||
|
if (shouldAbortQueuingTasks) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (final client in httpClients) {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadSingleAsset(
|
||||||
|
LocalAsset asset,
|
||||||
|
Client httpClient,
|
||||||
|
CancellationToken cancelToken,
|
||||||
|
void Function(int bytes, int totalBytes) onProgress,
|
||||||
|
void Function(String localAssetId, String remoteAssetId) onSuccess,
|
||||||
|
void Function(String errorMessage) onError, {
|
||||||
|
void Function(String localAssetId, double progress)? onICloudProgress,
|
||||||
|
}) async {
|
||||||
|
File? file;
|
||||||
|
File? livePhotoFile;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||||
|
if (entity == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
|
||||||
|
|
||||||
|
if (!isAvailableLocally && Platform.isIOS) {
|
||||||
|
_logger.info("Loading iCloud asset ${asset.id} - ${asset.name}");
|
||||||
|
|
||||||
|
// Create progress handler for iCloud download
|
||||||
|
PMProgressHandler? progressHandler;
|
||||||
|
StreamSubscription? progressSubscription;
|
||||||
|
|
||||||
|
if (onICloudProgress != null) {
|
||||||
|
progressHandler = PMProgressHandler();
|
||||||
|
progressSubscription = progressHandler.stream.listen((event) {
|
||||||
|
onICloudProgress(asset.localId!, event.progress);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler);
|
||||||
|
if (entity.isLivePhoto) {
|
||||||
|
livePhotoFile = await _storageRepository.loadMotionFileFromCloud(
|
||||||
|
asset.id,
|
||||||
|
progressHandler: progressHandler,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await progressSubscription?.cancel();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get files locally
|
||||||
|
file = await _storageRepository.getFileForAsset(asset.id);
|
||||||
|
if (file == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For live photos, get the motion video file
|
||||||
|
if (entity.isLivePhoto) {
|
||||||
|
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
|
||||||
|
if (livePhotoFile == null) {
|
||||||
|
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file == null) {
|
||||||
|
_logger.warning("Failed to obtain file for asset ${asset.id} - ${asset.name}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name;
|
||||||
|
final deviceId = Store.get(StoreKey.deviceId);
|
||||||
|
|
||||||
|
final headers = ApiService.getRequestHeaders();
|
||||||
|
final fields = {
|
||||||
|
'deviceAssetId': asset.localId!,
|
||||||
|
'deviceId': deviceId,
|
||||||
|
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
|
||||||
|
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
|
||||||
|
'isFavorite': asset.isFavorite.toString(),
|
||||||
|
'duration': asset.duration.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload live photo video first if available
|
||||||
|
String? livePhotoVideoId;
|
||||||
|
if (entity.isLivePhoto && livePhotoFile != null) {
|
||||||
|
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
|
||||||
|
livePhotoVideoId = await _uploadRepository.uploadLivePhotoVideo(
|
||||||
|
livePhotoFile: livePhotoFile,
|
||||||
|
originalFileName: livePhotoTitle,
|
||||||
|
headers: headers,
|
||||||
|
fields: fields,
|
||||||
|
httpClient: httpClient,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
onProgress: onProgress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add livePhotoVideoId to fields if available
|
||||||
|
if (livePhotoVideoId != null) {
|
||||||
|
fields['livePhotoVideoId'] = livePhotoVideoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _uploadRepository.uploadSingleAsset(
|
||||||
|
file: file,
|
||||||
|
originalFileName: originalFileName,
|
||||||
|
headers: headers,
|
||||||
|
fields: fields,
|
||||||
|
httpClient: httpClient,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
onProgress: onProgress,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isSuccess && result.remoteAssetId != null) {
|
||||||
|
onSuccess(asset.localId!, result.remoteAssetId!);
|
||||||
|
} else if (result.isCancelled) {
|
||||||
|
dPrint(() => "Backup was cancelled by the user");
|
||||||
|
shouldAbortQueuingTasks = true;
|
||||||
|
} else if (result.errorMessage != null) {
|
||||||
|
dPrint(
|
||||||
|
() =>
|
||||||
|
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
|
||||||
|
);
|
||||||
|
|
||||||
|
onError(result.errorMessage!);
|
||||||
|
|
||||||
|
if (result.errorMessage == "Quota has been exceeded!") {
|
||||||
|
shouldAbortQueuingTasks = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
dPrint(() => "Error backup asset: ${error.toString()}: $stackTrace");
|
||||||
|
onError(error.toString());
|
||||||
|
} finally {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
try {
|
||||||
|
await file?.delete();
|
||||||
|
await livePhotoFile?.delete();
|
||||||
|
} catch (e) {
|
||||||
|
dPrint(() => "ERROR deleting file: ${e.toString()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cancel all ongoing uploads and reset the upload queue
|
/// Cancel all ongoing uploads and reset the upload queue
|
||||||
///
|
///
|
||||||
/// Return the number of left over tasks in the queue
|
/// Return the number of left over tasks in the queue
|
||||||
|
|||||||
Reference in New Issue
Block a user