mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
chore: dart http foreground upload (#24883)
* feat: bring back manual backup * expose iCloud retrieval progress * wip * unify http upload method, check for connectivity on iOS * handle LivePhotos progress * feat: speed calculation * wip * better upload detail page * handle error * handle error * pr feedback * feat: share intent upload * feat: manual upload * feat: manual upload progress * chore: styling * refactor * refactor * remove unused logs * fix: background android backup * feat: add error section * remove complete section * remove empty state and prevent slot jumps * more refactor * fix: background test * chore: add metadata to foreground upload * fix: email and name get reset in auth provider * pr feedback * remove version check for metadata field in upload payload * chore: fix unit test --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
parent
843d563178
commit
e4443fa43e
31 changed files with 1855 additions and 848 deletions
|
|
@ -937,6 +937,7 @@
|
||||||
"download_waiting_to_retry": "Waiting to retry",
|
"download_waiting_to_retry": "Waiting to retry",
|
||||||
"downloading": "Downloading",
|
"downloading": "Downloading",
|
||||||
"downloading_asset_filename": "Downloading asset {filename}",
|
"downloading_asset_filename": "Downloading asset {filename}",
|
||||||
|
"downloading_from_icloud": "Downloading from iCloud",
|
||||||
"downloading_media": "Downloading media",
|
"downloading_media": "Downloading media",
|
||||||
"drop_files_to_upload": "Drop files anywhere to upload",
|
"drop_files_to_upload": "Drop files anywhere to upload",
|
||||||
"duplicates": "Duplicates",
|
"duplicates": "Duplicates",
|
||||||
|
|
@ -1122,6 +1123,7 @@
|
||||||
"unable_to_update_workflow": "Unable to update workflow",
|
"unable_to_update_workflow": "Unable to update workflow",
|
||||||
"unable_to_upload_file": "Unable to upload file"
|
"unable_to_upload_file": "Unable to upload file"
|
||||||
},
|
},
|
||||||
|
"errors_text": "Errors",
|
||||||
"exclusion_pattern": "Exclusion pattern",
|
"exclusion_pattern": "Exclusion pattern",
|
||||||
"exif": "Exif",
|
"exif": "Exif",
|
||||||
"exif_bottom_sheet_description": "Add Description...",
|
"exif_bottom_sheet_description": "Add Description...",
|
||||||
|
|
@ -2236,7 +2238,6 @@
|
||||||
"updated_at": "Updated",
|
"updated_at": "Updated",
|
||||||
"updated_password": "Updated password",
|
"updated_password": "Updated password",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"upload_action_prompt": "{count} queued for upload",
|
|
||||||
"upload_concurrency": "Upload concurrency",
|
"upload_concurrency": "Upload concurrency",
|
||||||
"upload_details": "Upload Details",
|
"upload_details": "Upload Details",
|
||||||
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
|
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ import UIKit
|
||||||
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||||
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
|
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
|
||||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
|
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
|
||||||
|
ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl())
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func cancelPlugins(with engine: FlutterEngine) {
|
public static func cancelPlugins(with engine: FlutterEngine) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,60 @@
|
||||||
|
import Network
|
||||||
|
|
||||||
class ConnectivityApiImpl: ConnectivityApi {
|
class ConnectivityApiImpl: ConnectivityApi {
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let queue = DispatchQueue(label: "ConnectivityMonitor")
|
||||||
|
private var currentPath: NWPath?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
self?.currentPath = path
|
||||||
|
}
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
// Get initial state synchronously
|
||||||
|
currentPath = monitor.currentPath
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
monitor.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
func getCapabilities() throws -> [NetworkCapability] {
|
func getCapabilities() throws -> [NetworkCapability] {
|
||||||
[]
|
guard let path = currentPath else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
guard path.status == .satisfied else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var capabilities: [NetworkCapability] = []
|
||||||
|
|
||||||
|
if path.usesInterfaceType(.wifi) {
|
||||||
|
capabilities.append(.wifi)
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.usesInterfaceType(.cellular) {
|
||||||
|
capabilities.append(.cellular)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for VPN - iOS reports VPN as .other interface type in many cases
|
||||||
|
// or through the path's expensive property when on cellular with VPN
|
||||||
|
if path.usesInterfaceType(.other) {
|
||||||
|
capabilities.append(.vpn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if connection is unmetered:
|
||||||
|
// - Must be on WiFi (not cellular)
|
||||||
|
// - Must not be expensive (rules out personal hotspot)
|
||||||
|
// - Must not be constrained (Low Data Mode)
|
||||||
|
// Note: VPN over cellular should still be considered metered
|
||||||
|
let isOnCellular = path.usesInterfaceType(.cellular)
|
||||||
|
let isOnWifi = path.usesInterfaceType(.wifi)
|
||||||
|
|
||||||
|
if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained {
|
||||||
|
capabilities.append(.unmetered)
|
||||||
|
}
|
||||||
|
|
||||||
|
return capabilities
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
||||||
|
|
@ -20,13 +19,13 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/services/auth.service.dart';
|
import 'package:immich_mobile/services/auth.service.dart';
|
||||||
import 'package:immich_mobile/services/localization.service.dart';
|
import 'package:immich_mobile/services/localization.service.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
|
|
@ -243,13 +242,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
return _ref?.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
|
return _ref?.read(driftBackupProvider.notifier).startBackupWithURLSession(currentUser.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? [];
|
|
||||||
return _ref
|
return _ref
|
||||||
?.read(uploadServiceProvider)
|
?.read(foregroundUploadServiceProvider)
|
||||||
.startBackupWithHttpClient(currentUser.id, networkCapabilities.isUnmetered, _cancellationToken);
|
.uploadCandidates(currentUser.id, _cancellationToken, useSequentialUpload: true);
|
||||||
},
|
},
|
||||||
(error, stack) {
|
(error, stack) {
|
||||||
dPrint(() => "Error in backup zone $error, $stack");
|
dPrint(() => "Error in backup zone $error, $stack");
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ import 'package:logging/logging.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
class StorageRepository {
|
class StorageRepository {
|
||||||
const StorageRepository();
|
final log = Logger('StorageRepository');
|
||||||
|
|
||||||
|
StorageRepository();
|
||||||
|
|
||||||
Future<File?> getFileForAsset(String assetId) async {
|
Future<File?> getFileForAsset(String assetId) async {
|
||||||
File? file;
|
File? file;
|
||||||
|
|
@ -82,6 +84,51 @@ class StorageRepository {
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> isAssetAvailableLocally(String assetId) async {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File?> loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File?> loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
|
||||||
|
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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import 'package:path/path.dart';
|
||||||
|
|
||||||
enum ShareIntentAttachmentType { image, video }
|
enum ShareIntentAttachmentType { image, video }
|
||||||
|
|
||||||
enum UploadStatus { enqueued, running, complete, notFound, failed, canceled, waitingToRetry, paused }
|
enum UploadStatus { enqueued, running, complete, failed }
|
||||||
|
|
||||||
class ShareIntentAttachment {
|
class ShareIntentAttachment {
|
||||||
final String path;
|
final String path;
|
||||||
|
|
|
||||||
|
|
@ -93,11 +93,11 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||||
Logger("DriftBackupPage").warning("Remote sync did not complete successfully, skipping backup");
|
Logger("DriftBackupPage").warning("Remote sync did not complete successfully, skipping backup");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await backupNotifier.startBackup(currentUser.id);
|
await backupNotifier.startForegroundBackup(currentUser.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stopBackup() async {
|
Future<void> stopBackup() async {
|
||||||
await backupNotifier.cancel();
|
await backupNotifier.stopForegroundBackup();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
|
||||||
|
|
@ -113,10 +113,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||||
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
|
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
|
||||||
if (isBackupEnabled) {
|
if (isBackupEnabled) {
|
||||||
unawaited(
|
unawaited(
|
||||||
backupNotifier.cancel().whenComplete(
|
backupNotifier.stopForegroundBackup().whenComplete(
|
||||||
() => backgroundSync.syncRemote().then((success) {
|
() => backgroundSync.syncRemote().then((success) {
|
||||||
if (success) {
|
if (success) {
|
||||||
return backupNotifier.startBackup(user.id);
|
return backupNotifier.startForegroundBackup(user.id);
|
||||||
} else {
|
} else {
|
||||||
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
|
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,10 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
||||||
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
||||||
final backgroundSync = ref.read(backgroundSyncProvider);
|
final backgroundSync = ref.read(backgroundSyncProvider);
|
||||||
unawaited(
|
unawaited(
|
||||||
backupNotifier.cancel().whenComplete(
|
backupNotifier.stopForegroundBackup().whenComplete(
|
||||||
() => backgroundSync.syncRemote().then((success) {
|
() => backgroundSync.syncRemote().then((success) {
|
||||||
if (success) {
|
if (success) {
|
||||||
return backupNotifier.startBackup(currentUser.id);
|
return backupNotifier.startForegroundBackup(currentUser.id);
|
||||||
} else {
|
} else {
|
||||||
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
|
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,70 @@ import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class DriftUploadDetailPage extends ConsumerWidget {
|
class DriftUploadDetailPage extends ConsumerStatefulWidget {
|
||||||
const DriftUploadDetailPage({super.key});
|
const DriftUploadDetailPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<DriftUploadDetailPage> createState() => _DriftUploadDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DriftUploadDetailPageState extends ConsumerState<DriftUploadDetailPage> {
|
||||||
|
final Set<String> _seenTaskIds = {};
|
||||||
|
final Set<String> _failedTaskIds = {};
|
||||||
|
|
||||||
|
final Map<String, int> _taskSlotAssignments = {};
|
||||||
|
static const int _maxSlots = 3;
|
||||||
|
|
||||||
|
/// Assigns uploading items to fixed slots to prevent jumping when items complete
|
||||||
|
List<DriftUploadStatus?> _assignItemsToSlots(List<DriftUploadStatus> uploadingItems) {
|
||||||
|
final slots = List<DriftUploadStatus?>.filled(_maxSlots, null);
|
||||||
|
final currentTaskIds = uploadingItems.map((e) => e.taskId).toSet();
|
||||||
|
|
||||||
|
_taskSlotAssignments.removeWhere((taskId, _) => !currentTaskIds.contains(taskId));
|
||||||
|
|
||||||
|
for (final item in uploadingItems) {
|
||||||
|
final existingSlot = _taskSlotAssignments[item.taskId];
|
||||||
|
if (existingSlot != null && existingSlot < _maxSlots) {
|
||||||
|
slots[existingSlot] = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final item in uploadingItems) {
|
||||||
|
if (_taskSlotAssignments.containsKey(item.taskId)) continue;
|
||||||
|
|
||||||
|
for (int i = 0; i < _maxSlots; i++) {
|
||||||
|
if (slots[i] == null) {
|
||||||
|
slots[i] = item;
|
||||||
|
_taskSlotAssignments[item.taskId] = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
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));
|
||||||
|
|
||||||
|
for (final item in uploadItems.values) {
|
||||||
|
if (item.isFailed == true) {
|
||||||
|
_failedTaskIds.add(item.taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final item in uploadItems.values) {
|
||||||
|
if (item.progress >= 1.0 && item.isFailed != true && !_failedTaskIds.contains(item.taskId)) {
|
||||||
|
if (!_seenTaskIds.contains(item.taskId)) {
|
||||||
|
_seenTaskIds.add(item.taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final uploadingItems = uploadItems.values.where((item) => item.progress < 1.0 && item.isFailed != true).toList();
|
||||||
|
final failedItems = uploadItems.values.where((item) => item.isFailed == true).toList();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -25,98 +83,326 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
scrolledUnderElevation: 1,
|
scrolledUnderElevation: 1,
|
||||||
),
|
),
|
||||||
body: uploadItems.isEmpty ? _buildEmptyState(context) : _buildUploadList(uploadItems),
|
body: _buildTwoSectionLayout(context, uploadingItems, failedItems, iCloudProgress),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context) {
|
Widget _buildTwoSectionLayout(
|
||||||
return Center(
|
BuildContext context,
|
||||||
child: Column(
|
List<DriftUploadStatus> uploadingItems,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
List<DriftUploadStatus> failedItems,
|
||||||
children: [
|
Map<String, double> iCloudProgress,
|
||||||
Icon(Icons.cloud_off_rounded, size: 80, color: context.colorScheme.onSurface.withValues(alpha: 0.3)),
|
) {
|
||||||
const SizedBox(height: 16),
|
return CustomScrollView(
|
||||||
Text(
|
slivers: [
|
||||||
"no_uploads_in_progress".t(context: context),
|
// iCloud Downloads Section
|
||||||
style: context.textTheme.titleMedium?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.6)),
|
if (iCloudProgress.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _buildSectionHeader(
|
||||||
|
context,
|
||||||
|
title: "Downloading from iCloud",
|
||||||
|
count: iCloudProgress.length,
|
||||||
|
color: context.colorScheme.tertiary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final entry = iCloudProgress.entries.elementAt(index);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: _buildICloudDownloadCard(context, entry.key, entry.value),
|
||||||
|
);
|
||||||
|
}, childCount: iCloudProgress.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Uploading Section
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _buildSectionHeader(
|
||||||
|
context,
|
||||||
|
title: "uploading".t(context: context),
|
||||||
|
count: uploadingItems.length,
|
||||||
|
color: context.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
// Use slot-based assignment to prevent items from jumping
|
||||||
|
final slots = _assignItemsToSlots(uploadingItems);
|
||||||
|
final item = slots[index];
|
||||||
|
if (item != null) {
|
||||||
|
return _buildCurrentUploadCard(context, item);
|
||||||
|
} else {
|
||||||
|
return _buildPlaceholderCard(context);
|
||||||
|
}
|
||||||
|
}, childCount: 3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Errors Section
|
||||||
|
if (failedItems.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _buildSectionHeader(
|
||||||
|
context,
|
||||||
|
title: "errors_text".t(context: context),
|
||||||
|
count: failedItems.length,
|
||||||
|
color: context.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final item = failedItems[index];
|
||||||
|
return Padding(padding: const EdgeInsets.only(bottom: 8), child: _buildErrorCard(context, item));
|
||||||
|
}, childCount: failedItems.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Bottom padding
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionHeader(BuildContext context, {required String title, int? count, required Color color}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600, color: color),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
count != null
|
||||||
|
? Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.15),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
count.toString(),
|
||||||
|
style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, color: color),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildUploadList(Map<String, DriftUploadStatus> uploadItems) {
|
Widget _buildICloudDownloadCard(BuildContext context, String assetId, double progress) {
|
||||||
return ListView.separated(
|
final double progressPercentage = (progress * 100).clamp(0, 100);
|
||||||
addAutomaticKeepAlives: true,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
itemCount: uploadItems.length,
|
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: 4),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = uploadItems.values.elementAt(index);
|
|
||||||
return _buildUploadCard(context, item);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildUploadCard(BuildContext context, DriftUploadStatus item) {
|
|
||||||
final isCompleted = item.progress >= 1.0;
|
|
||||||
final double progressPercentage = (item.progress * 100).clamp(0, 100);
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: item.isFailed != null ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainer,
|
color: context.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1),
|
side: BorderSide(color: context.colorScheme.tertiary.withValues(alpha: 0.3), width: 1),
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: Padding(
|
||||||
onTap: () => _showFileDetailDialog(context, item),
|
padding: const EdgeInsets.all(12),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
child: Row(
|
||||||
child: Padding(
|
children: [
|
||||||
padding: const EdgeInsets.all(16),
|
Container(
|
||||||
child: Column(
|
width: 40,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
height: 40,
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
Row(
|
color: context.colorScheme.tertiary.withValues(alpha: 0.2),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.cloud_download_rounded, size: 24, color: context.colorScheme.tertiary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Text(
|
||||||
child: Column(
|
"downloading_from_icloud".t(context: context),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||||
spacing: 4,
|
maxLines: 1,
|
||||||
children: [
|
overflow: TextOverflow.ellipsis,
|
||||||
Text(
|
|
||||||
path.basename(item.filename),
|
|
||||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (item.error != null)
|
|
||||||
Text(
|
|
||||||
item.error!,
|
|
||||||
style: context.textTheme.bodySmall?.copyWith(
|
|
||||||
color: context.colorScheme.onErrorContainer.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
"backup_upload_details_page_more_details".t(context: context),
|
|
||||||
style: context.textTheme.bodySmall?.copyWith(
|
|
||||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
_buildProgressIndicator(
|
const SizedBox(height: 4),
|
||||||
context,
|
Text(
|
||||||
item.progress,
|
assetId,
|
||||||
progressPercentage,
|
style: context.textTheme.bodySmall?.copyWith(
|
||||||
isCompleted,
|
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
item.networkSpeedAsString,
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: progress,
|
||||||
|
backgroundColor: context.colorScheme.tertiary.withValues(alpha: 0.2),
|
||||||
|
valueColor: AlwaysStoppedAnimation(context.colorScheme.tertiary),
|
||||||
|
minHeight: 4,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: 48,
|
||||||
|
child: Text(
|
||||||
|
"${progressPercentage.toStringAsFixed(0)}%",
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: context.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: context.colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCurrentUploadCard(BuildContext context, DriftUploadStatus item) {
|
||||||
|
final double progressPercentage = (item.progress * 100).clamp(0, 100);
|
||||||
|
final isFailed = item.isFailed == true;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: isFailed
|
||||||
|
? context.colorScheme.errorContainer
|
||||||
|
: context.colorScheme.primaryContainer.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
side: BorderSide(
|
||||||
|
color: isFailed
|
||||||
|
? context.colorScheme.error.withValues(alpha: 0.3)
|
||||||
|
: context.colorScheme.primary.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _showFileDetailDialog(context, item),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 64,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_CurrentUploadThumbnail(taskId: item.taskId),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
path.basename(item.filename),
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
isFailed
|
||||||
|
? item.error ?? "unable_to_upload_file".t(context: context)
|
||||||
|
: "${formatHumanReadableBytes(item.fileSize, 1)} • ${item.networkSpeedAsString}",
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(
|
||||||
|
color: isFailed
|
||||||
|
? context.colorScheme.error
|
||||||
|
: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (!isFailed) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: item.progress,
|
||||||
|
backgroundColor: context.colorScheme.primary.withValues(alpha: 0.2),
|
||||||
|
valueColor: AlwaysStoppedAnimation(context.colorScheme.primary),
|
||||||
|
minHeight: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: 48,
|
||||||
|
child: isFailed
|
||||||
|
? Icon(Icons.error_rounded, color: context.colorScheme.error, size: 28)
|
||||||
|
: Text(
|
||||||
|
"${progressPercentage.toStringAsFixed(0)}%",
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: context.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: context.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorCard(BuildContext context, DriftUploadStatus item) {
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: context.colorScheme.errorContainer,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
side: BorderSide(color: context.colorScheme.error.withValues(alpha: 0.3), width: 1),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _showFileDetailDialog(context, item),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_CurrentUploadThumbnail(taskId: item.taskId),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
path.basename(item.filename),
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
item.error ?? "unable_to_upload_file".t(context: context),
|
||||||
|
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.error),
|
||||||
|
maxLines: 4,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Icon(Icons.error_rounded, color: context.colorScheme.error, size: 28),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -124,49 +410,84 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildProgressIndicator(
|
Widget _buildPlaceholderCard(BuildContext context) {
|
||||||
BuildContext context,
|
return Card(
|
||||||
double progress,
|
elevation: 0,
|
||||||
double percentage,
|
color: context.colorScheme.surfaceContainerLow.withValues(alpha: 0.5),
|
||||||
bool isCompleted,
|
shape: RoundedRectangleBorder(
|
||||||
String networkSpeedAsString,
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
) {
|
side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1, style: BorderStyle.solid),
|
||||||
return Column(
|
),
|
||||||
children: [
|
child: Padding(
|
||||||
Stack(
|
padding: const EdgeInsets.all(12),
|
||||||
alignment: AlignmentDirectional.center,
|
child: SizedBox(
|
||||||
children: [
|
height: 64,
|
||||||
SizedBox(
|
child: Row(
|
||||||
width: 36,
|
children: [
|
||||||
height: 36,
|
SizedBox(
|
||||||
child: TweenAnimationBuilder(
|
width: 48,
|
||||||
tween: Tween<double>(begin: 0.0, end: progress),
|
height: 48,
|
||||||
duration: const Duration(milliseconds: 300),
|
child: Container(
|
||||||
builder: (context, value, _) => CircularProgressIndicator(
|
decoration: BoxDecoration(
|
||||||
backgroundColor: context.colorScheme.outline.withValues(alpha: 0.2),
|
color: context.colorScheme.outline.withValues(alpha: 0.1),
|
||||||
strokeWidth: 3,
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
value: value,
|
),
|
||||||
color: isCompleted ? context.colorScheme.primary : context.colorScheme.secondary,
|
child: Icon(
|
||||||
|
Icons.hourglass_empty_rounded,
|
||||||
|
size: 24,
|
||||||
|
color: context.colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
if (isCompleted)
|
Expanded(
|
||||||
Icon(Icons.check_circle_rounded, size: 28, color: context.colorScheme.primary)
|
child: Column(
|
||||||
else
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text(
|
children: [
|
||||||
percentage.toStringAsFixed(0),
|
Container(
|
||||||
style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, fontSize: 10),
|
height: 14,
|
||||||
|
width: 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colorScheme.outline.withValues(alpha: 0.1),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
|
height: 10,
|
||||||
|
width: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colorScheme.outline.withValues(alpha: 0.08),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colorScheme.outline.withValues(alpha: 0.1),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(width: 12),
|
||||||
),
|
SizedBox(
|
||||||
Text(
|
width: 48,
|
||||||
networkSpeedAsString,
|
child: Text(
|
||||||
style: context.textTheme.labelSmall?.copyWith(
|
"0%",
|
||||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
textAlign: TextAlign.right,
|
||||||
fontSize: 10,
|
style: context.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: context.colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,9 +499,44 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CurrentUploadThumbnail extends ConsumerWidget {
|
||||||
|
final String taskId;
|
||||||
|
const _CurrentUploadThumbnail({required this.taskId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return FutureBuilder<LocalAsset?>(
|
||||||
|
future: _getAsset(ref),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colorScheme.primary.withValues(alpha: 0.2),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: snapshot.data != null
|
||||||
|
? Thumbnail.fromAsset(asset: snapshot.data!, size: const Size(48, 48), fit: BoxFit.cover)
|
||||||
|
: Icon(Icons.image, size: 24, color: context.colorScheme.primary),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LocalAsset?> _getAsset(WidgetRef ref) async {
|
||||||
|
try {
|
||||||
|
return await ref.read(localAssetRepository).getById(taskId);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class FileDetailDialog extends ConsumerWidget {
|
class FileDetailDialog extends ConsumerWidget {
|
||||||
final DriftUploadStatus uploadStatus;
|
final DriftUploadStatus uploadStatus;
|
||||||
|
|
||||||
const FileDetailDialog({super.key, required this.uploadStatus});
|
const FileDetailDialog({super.key, required this.uploadStatus});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -212,14 +568,12 @@ class FileDetailDialog extends ConsumerWidget {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator()));
|
return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator()));
|
||||||
}
|
}
|
||||||
|
|
||||||
final asset = snapshot.data;
|
final asset = snapshot.data;
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Thumbnail at the top center
|
|
||||||
Center(
|
Center(
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
|
@ -237,7 +591,7 @@ class FileDetailDialog extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (asset != null) ...[
|
if (asset != null)
|
||||||
_buildInfoSection(context, [
|
_buildInfoSection(context, [
|
||||||
_buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)),
|
_buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)),
|
||||||
_buildInfoRow(context, "local_id".t(context: context), asset.id),
|
_buildInfoRow(context, "local_id".t(context: context), asset.id),
|
||||||
|
|
@ -254,7 +608,6 @@ class FileDetailDialog extends ConsumerWidget {
|
||||||
if (asset.checksum != null)
|
if (asset.checksum != null)
|
||||||
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
|
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
|
||||||
]),
|
]),
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -282,7 +635,7 @@ class FileDetailDialog extends ConsumerWidget {
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
border: Border.all(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1),
|
border: Border.all(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1),
|
||||||
),
|
),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [...children]),
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,12 +656,7 @@ class FileDetailDialog extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(value, style: context.textTheme.labelMedium, maxLines: 3, overflow: TextOverflow.ellipsis),
|
||||||
value,
|
|
||||||
style: context.textTheme.labelMedium?.copyWith(),
|
|
||||||
maxLines: 3,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -317,8 +665,7 @@ class FileDetailDialog extends ConsumerWidget {
|
||||||
|
|
||||||
Future<LocalAsset?> _getAssetDetails(WidgetRef ref, String localAssetId) async {
|
Future<LocalAsset?> _getAssetDetails(WidgetRef ref, String localAssetId) async {
|
||||||
try {
|
try {
|
||||||
final repository = ref.read(localAssetRepository);
|
return await ref.read(localAssetRepository).getById(localAssetId);
|
||||||
return await repository.getById(localAssetId);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||||
if (isEnableBackup) {
|
if (isEnableBackup) {
|
||||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||||
if (currentUser != null) {
|
if (currentUser != null) {
|
||||||
unawaited(notifier.handleBackupResume(currentUser.id));
|
unawaited(notifier.startForegroundBackup(currentUser.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
|
@ -12,7 +11,7 @@ import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/url_helper.dart';
|
import 'package:immich_mobile/utils/url_helper.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class ShareIntentPage extends HookConsumerWidget {
|
class ShareIntentPage extends ConsumerWidget {
|
||||||
const ShareIntentPage({super.key, required this.attachments});
|
const ShareIntentPage({super.key, required this.attachments});
|
||||||
|
|
||||||
final List<ShareIntentAttachment> attachments;
|
final List<ShareIntentAttachment> attachments;
|
||||||
|
|
@ -21,12 +20,13 @@ class ShareIntentPage extends HookConsumerWidget {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final currentEndpoint = getServerUrl() ?? '--';
|
final currentEndpoint = getServerUrl() ?? '--';
|
||||||
final candidates = ref.watch(shareIntentUploadProvider);
|
final candidates = ref.watch(shareIntentUploadProvider);
|
||||||
final isUploaded = useState(false);
|
|
||||||
useOnAppLifecycleStateChange((previous, current) {
|
final isUploading = candidates.any((candidate) => candidate.status == UploadStatus.running);
|
||||||
if (current == AppLifecycleState.resumed) {
|
final isUploaded =
|
||||||
isUploaded.value = false;
|
candidates.isNotEmpty &&
|
||||||
}
|
candidates.every(
|
||||||
});
|
(candidate) => candidate.status == UploadStatus.complete || candidate.status == UploadStatus.failed,
|
||||||
|
);
|
||||||
|
|
||||||
void removeAttachment(ShareIntentAttachment attachment) {
|
void removeAttachment(ShareIntentAttachment attachment) {
|
||||||
ref.read(shareIntentUploadProvider.notifier).removeAttachment(attachment);
|
ref.read(shareIntentUploadProvider.notifier).removeAttachment(attachment);
|
||||||
|
|
@ -37,11 +37,8 @@ class ShareIntentPage extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
void upload() async {
|
void upload() async {
|
||||||
for (final attachment in candidates) {
|
final files = candidates.map((candidate) => candidate.file).toList();
|
||||||
await ref.read(shareIntentUploadProvider.notifier).upload(attachment.file);
|
await ref.read(shareIntentUploadProvider.notifier).uploadAll(files);
|
||||||
}
|
|
||||||
|
|
||||||
isUploaded.value = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isSelected(ShareIntentAttachment attachment) {
|
bool isSelected(ShareIntentAttachment attachment) {
|
||||||
|
|
@ -84,7 +81,7 @@ class ShareIntentPage extends HookConsumerWidget {
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16),
|
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16),
|
||||||
child: LargeLeadingTile(
|
child: LargeLeadingTile(
|
||||||
onTap: () => toggleSelection(attachment),
|
onTap: () => toggleSelection(attachment),
|
||||||
disabled: isUploaded.value,
|
disabled: isUploading || isUploaded,
|
||||||
selected: isSelected(attachment),
|
selected: isSelected(attachment),
|
||||||
leading: Stack(
|
leading: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -131,8 +128,8 @@ class ShareIntentPage extends HookConsumerWidget {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 48,
|
height: 48,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: isUploaded.value ? null : upload,
|
onPressed: (isUploading || isUploaded) ? null : upload,
|
||||||
child: isUploaded.value ? UploadingText(candidates: candidates) : const Text('upload').tr(),
|
child: (isUploading || isUploaded) ? UploadingText(candidates: candidates) : const Text('upload').tr(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -204,14 +201,7 @@ class UploadStatusIcon extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
UploadStatus.complete => Icon(Icons.check_circle_rounded, color: Colors.green, semanticLabel: 'completed'.tr()),
|
UploadStatus.complete => Icon(Icons.check_circle_rounded, color: Colors.green, semanticLabel: 'completed'.tr()),
|
||||||
UploadStatus.notFound ||
|
|
||||||
UploadStatus.failed => Icon(Icons.error_rounded, color: Colors.red, semanticLabel: 'failed'.tr()),
|
UploadStatus.failed => Icon(Icons.error_rounded, color: Colors.red, semanticLabel: 'failed'.tr()),
|
||||||
UploadStatus.canceled => Icon(Icons.cancel_rounded, color: Colors.red, semanticLabel: 'canceled'.tr()),
|
|
||||||
UploadStatus.waitingToRetry || UploadStatus.paused => Icon(
|
|
||||||
Icons.pause_circle_rounded,
|
|
||||||
color: context.primaryColor,
|
|
||||||
semanticLabel: 'paused'.tr(),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return statusIcon;
|
return statusIcon;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:cancellation_token_http/http.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
@ -12,7 +13,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.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;
|
||||||
|
|
@ -78,7 +79,7 @@ class DriftEditImagePage extends ConsumerWidget {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ref.read(uploadServiceProvider).manualBackup([localAsset]);
|
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
durationInSecond: 6,
|
durationInSecond: 6,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
class UploadActionButton extends ConsumerWidget {
|
class UploadActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
|
@ -20,19 +25,38 @@ class UploadActionButton extends ConsumerWidget {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).upload(source);
|
final isTimeline = source == ActionSource.timeline;
|
||||||
|
List<LocalAsset>? assets;
|
||||||
|
|
||||||
final successMessage = 'upload_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
if (source == ActionSource.timeline) {
|
||||||
|
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
|
||||||
|
if (assets.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
} else {
|
||||||
|
unawaited(
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) => const _UploadProgressDialog(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (context.mounted) {
|
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
|
||||||
|
|
||||||
|
if (!isTimeline && context.mounted) {
|
||||||
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.mounted && !result.success) {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||||
gravity: ToastGravity.BOTTOM,
|
gravity: ToastGravity.BOTTOM,
|
||||||
toastType: result.success ? ToastType.success : ToastType.error,
|
toastType: ToastType.error,
|
||||||
);
|
);
|
||||||
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,3 +71,42 @@ class UploadActionButton extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _UploadProgressDialog extends ConsumerWidget {
|
||||||
|
const _UploadProgressDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final progressMap = ref.watch(assetUploadProgressProvider);
|
||||||
|
|
||||||
|
// Calculate overall progress from all assets
|
||||||
|
final values = progressMap.values.where((v) => v >= 0).toList();
|
||||||
|
final progress = values.isEmpty ? 0.0 : values.reduce((a, b) => a + b) / values.length;
|
||||||
|
final hasError = progressMap.values.any((v) => v < 0);
|
||||||
|
final percentage = (progress * 100).toInt();
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text('uploading'.t(context: context)),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (hasError)
|
||||||
|
const Icon(Icons.error_outline, color: Colors.red, size: 48)
|
||||||
|
else
|
||||||
|
CircularProgressIndicator(value: progress > 0 ? progress : null),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(hasError ? 'Error' : '$percentage%'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
ImmichTextButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(manualUploadCancelTokenProvider)?.cancel();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
labelText: 'cancel'.t(context: context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||||
try {
|
try {
|
||||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||||
final file = await const StorageRepository().getFileForAsset(id);
|
final file = await StorageRepository().getFileForAsset(id);
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
|
|
@ -57,17 +56,13 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final enqueueCount = ref.watch(driftBackupProvider.select((state) => state.enqueueCount));
|
|
||||||
|
|
||||||
final enqueueTotalCount = ref.watch(driftBackupProvider.select((state) => state.enqueueTotalCount));
|
|
||||||
|
|
||||||
final isCanceling = ref.watch(driftBackupProvider.select((state) => state.isCanceling));
|
|
||||||
|
|
||||||
final uploadTasks = ref.watch(driftBackupProvider.select((state) => state.uploadItems));
|
final uploadTasks = ref.watch(driftBackupProvider.select((state) => state.uploadItems));
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -115,7 +110,7 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
|
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
|
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
|
||||||
onTap: () => isCanceling ? null : _onToggle(!_isEnabled),
|
onTap: () => _onToggle(!_isEnabled),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -154,35 +149,10 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (enqueueCount != enqueueTotalCount)
|
|
||||||
Text(
|
|
||||||
"queue_status".t(
|
|
||||||
context: context,
|
|
||||||
args: {'count': enqueueCount.toString(), 'total': enqueueTotalCount.toString()},
|
|
||||||
),
|
|
||||||
style: context.textTheme.labelLarge?.copyWith(
|
|
||||||
color: context.colorScheme.onSurfaceSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isCanceling)
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text("canceling".t(), style: context.textTheme.labelLarge),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
backgroundColor: context.colorScheme.onSurface.withValues(alpha: 0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Switch.adaptive(value: _isEnabled, onChanged: (value) => isCanceling ? null : _onToggle(value)),
|
Switch.adaptive(value: _isEnabled, onChanged: (value) => _onToggle(value)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
|
|
||||||
|
|
@ -62,6 +63,10 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
||||||
_showSelectionContainer = true;
|
_showSelectionContainer = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final uploadProgress = asset is LocalAsset
|
||||||
|
? ref.watch(assetUploadProgressProvider.select((map) => map[asset.id]))
|
||||||
|
: null;
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -168,6 +173,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (uploadProgress != null) _UploadProgressOverlay(progress: uploadProgress),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -293,3 +299,46 @@ class _AssetTypeIcons extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _UploadProgressOverlay extends StatelessWidget {
|
||||||
|
final double progress;
|
||||||
|
|
||||||
|
const _UploadProgressOverlay({required this.progress});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isError = progress < 0;
|
||||||
|
final percentage = isError ? 0 : (progress * 100).toInt();
|
||||||
|
|
||||||
|
return Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
color: isError ? Colors.red.withValues(alpha: 0.6) : Colors.black54,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (isError)
|
||||||
|
const Icon(Icons.error_outline, color: Colors.white, size: 36)
|
||||||
|
else
|
||||||
|
SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: progress,
|
||||||
|
strokeWidth: 3,
|
||||||
|
backgroundColor: Colors.white24,
|
||||||
|
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
isError ? 'Error' : '$percentage%',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||||
if (currentUser != null) {
|
if (currentUser != null) {
|
||||||
await _safeRun(
|
await _safeRun(
|
||||||
_ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id),
|
_ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id),
|
||||||
"handleBackupResume",
|
"handleBackupResume",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -238,6 +238,8 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||||
if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) {
|
if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) {
|
||||||
_ref.read(backupProvider.notifier).cancelBackup();
|
_ref.read(backupProvider.notifier).cancelBackup();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
await _ref.read(driftBackupProvider.notifier).stopForegroundBackup();
|
||||||
}
|
}
|
||||||
|
|
||||||
_ref.read(websocketProvider.notifier).disconnect();
|
_ref.read(websocketProvider.notifier).disconnect();
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,28 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:background_downloader/background_downloader.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/models/upload/share_intent_attachment.model.dart';
|
||||||
import 'package:immich_mobile/routing/router.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/share_intent_service.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||||
((ref) => ShareIntentUploadStateNotifier(
|
((ref) => ShareIntentUploadStateNotifier(
|
||||||
ref.watch(appRouterProvider),
|
ref.watch(appRouterProvider),
|
||||||
ref.watch(uploadServiceProvider),
|
ref.read(foregroundUploadServiceProvider),
|
||||||
ref.watch(shareIntentServiceProvider),
|
ref.read(shareIntentServiceProvider),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
||||||
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
|
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
|
||||||
final AppRouter router;
|
final AppRouter router;
|
||||||
final UploadService _uploadService;
|
final ForegroundUploadService _foregroundUploadService;
|
||||||
final ShareIntentService _shareIntentService;
|
final ShareIntentService _shareIntentService;
|
||||||
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
||||||
|
|
||||||
ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) {
|
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
|
||||||
_uploadService.taskStatusStream.listen(_updateUploadStatus);
|
|
||||||
_uploadService.taskProgressStream.listen(_taskProgressCallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
void init() {
|
void init() {
|
||||||
_shareIntentService.onSharedMedia = onSharedMedia;
|
_shareIntentService.onSharedMedia = onSharedMedia;
|
||||||
|
|
@ -67,97 +58,44 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
||||||
state = [];
|
state = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateUploadStatus(TaskStatusUpdate task) async {
|
Future<void> uploadAll(List<File> files) async {
|
||||||
if (task.status == TaskStatus.canceled) {
|
for (final file in files) {
|
||||||
return;
|
final fileId = p.hash(file.path).toString();
|
||||||
|
_updateStatus(fileId, UploadStatus.running);
|
||||||
}
|
}
|
||||||
|
|
||||||
final taskId = task.task.taskId;
|
await _foregroundUploadService.uploadShareIntent(
|
||||||
final uploadStatus = switch (task.status) {
|
files,
|
||||||
TaskStatus.complete => UploadStatus.complete,
|
onProgress: (fileId, bytes, totalBytes) {
|
||||||
TaskStatus.failed => UploadStatus.failed,
|
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||||
TaskStatus.canceled => UploadStatus.canceled,
|
_updateProgress(fileId, progress);
|
||||||
TaskStatus.enqueued => UploadStatus.enqueued,
|
},
|
||||||
TaskStatus.running => UploadStatus.running,
|
onSuccess: (fileId) {
|
||||||
TaskStatus.paused => UploadStatus.paused,
|
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
||||||
TaskStatus.notFound => UploadStatus.notFound,
|
},
|
||||||
TaskStatus.waitingToRetry => UploadStatus.waitingToRetry,
|
onError: (fileId, errorMessage) {
|
||||||
};
|
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
|
||||||
|
_updateStatus(fileId, UploadStatus.failed);
|
||||||
state = [
|
},
|
||||||
for (final attachment in state)
|
|
||||||
if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (task.status == TaskStatus.failed) {
|
|
||||||
String? error;
|
|
||||||
final exception = task.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 ??= task.exception?.toString();
|
|
||||||
|
|
||||||
_logger.warning("Upload failed for asset: ${task.task.filename}, error: $error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _taskProgressCallback(TaskProgressUpdate update) {
|
|
||||||
// Ignore if the task is canceled or completed
|
|
||||||
if (update.progress == downloadFailed || update.progress == downloadCompleted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final taskId = update.task.taskId;
|
|
||||||
state = [
|
|
||||||
for (final attachment in state)
|
|
||||||
if (attachment.id == taskId.toInt()) 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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
|
||||||
|
final id = int.parse(fileId);
|
||||||
|
state = [
|
||||||
|
for (final attachment in state)
|
||||||
|
if (attachment.id == id)
|
||||||
|
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
|
||||||
|
else
|
||||||
|
attachment,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateProgress(String fileId, double progress) {
|
||||||
|
final id = int.parse(fileId);
|
||||||
|
state = [
|
||||||
|
for (final attachment in state)
|
||||||
|
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/auth.service.dart';
|
import 'package:immich_mobile/services/auth.service.dart';
|
||||||
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||||
import 'package:immich_mobile/services/widget.service.dart';
|
import 'package:immich_mobile/services/widget.service.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:immich_mobile/utils/hash.dart';
|
import 'package:immich_mobile/utils/hash.dart';
|
||||||
|
|
@ -34,6 +35,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||||
final AuthService _authService;
|
final AuthService _authService;
|
||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
final UserService _userService;
|
final UserService _userService;
|
||||||
|
|
||||||
final SecureStorageService _secureStorageService;
|
final SecureStorageService _secureStorageService;
|
||||||
final WidgetService _widgetService;
|
final WidgetService _widgetService;
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
|
|
@ -45,6 +47,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||||
this._authService,
|
this._authService,
|
||||||
this._apiService,
|
this._apiService,
|
||||||
this._userService,
|
this._userService,
|
||||||
|
|
||||||
this._secureStorageService,
|
this._secureStorageService,
|
||||||
this._widgetService,
|
this._widgetService,
|
||||||
this._ref,
|
this._ref,
|
||||||
|
|
@ -87,7 +90,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||||
await _widgetService.clearCredentials();
|
await _widgetService.clearCredentials();
|
||||||
|
|
||||||
await _authService.logout();
|
await _authService.logout();
|
||||||
await _ref.read(uploadServiceProvider).cancelBackup();
|
await _ref.read(backgroundUploadServiceProvider).cancel();
|
||||||
|
_ref.read(foregroundUploadServiceProvider).cancel();
|
||||||
} finally {
|
} finally {
|
||||||
await _cleanUp();
|
await _cleanUp();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import 'package:cancellation_token_http/http.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
/// Tracks per-asset upload progress.
|
||||||
|
/// Key: local asset ID, Value: upload progress 0.0 to 1.0, or -1.0 for error
|
||||||
|
class AssetUploadProgressNotifier extends Notifier<Map<String, double>> {
|
||||||
|
static const double errorValue = -1.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, double> build() => {};
|
||||||
|
|
||||||
|
void setProgress(String localAssetId, double progress) {
|
||||||
|
state = {...state, localAssetId: progress};
|
||||||
|
}
|
||||||
|
|
||||||
|
void setError(String localAssetId) {
|
||||||
|
state = {...state, localAssetId: errorValue};
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove(String localAssetId) {
|
||||||
|
state = Map.from(state)..remove(localAssetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
state = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final assetUploadProgressProvider = NotifierProvider<AssetUploadProgressNotifier, Map<String, double>>(
|
||||||
|
AssetUploadProgressNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
final manualUploadCancelTokenProvider = StateProvider<CancellationToken?>((ref) => null);
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
// 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: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';
|
||||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
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/foreground_upload.service.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
class EnqueueStatus {
|
class EnqueueStatus {
|
||||||
final int enqueueCount;
|
final int enqueueCount;
|
||||||
|
|
@ -106,26 +105,24 @@ class DriftBackupState {
|
||||||
final int remainderCount;
|
final int remainderCount;
|
||||||
final int processingCount;
|
final int processingCount;
|
||||||
|
|
||||||
final int enqueueCount;
|
|
||||||
final int enqueueTotalCount;
|
|
||||||
|
|
||||||
final bool isSyncing;
|
final bool isSyncing;
|
||||||
final bool isCanceling;
|
|
||||||
final BackupError error;
|
final BackupError error;
|
||||||
|
|
||||||
final Map<String, DriftUploadStatus> uploadItems;
|
final Map<String, DriftUploadStatus> uploadItems;
|
||||||
|
final CancellationToken? cancelToken;
|
||||||
|
|
||||||
|
final Map<String, double> iCloudDownloadProgress;
|
||||||
|
|
||||||
const DriftBackupState({
|
const DriftBackupState({
|
||||||
required this.totalCount,
|
required this.totalCount,
|
||||||
required this.backupCount,
|
required this.backupCount,
|
||||||
required this.remainderCount,
|
required this.remainderCount,
|
||||||
required this.processingCount,
|
required this.processingCount,
|
||||||
required this.enqueueCount,
|
|
||||||
required this.enqueueTotalCount,
|
|
||||||
required this.isCanceling,
|
|
||||||
required this.isSyncing,
|
required this.isSyncing,
|
||||||
required this.uploadItems,
|
|
||||||
this.error = BackupError.none,
|
this.error = BackupError.none,
|
||||||
|
required this.uploadItems,
|
||||||
|
this.cancelToken,
|
||||||
|
this.iCloudDownloadProgress = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
DriftBackupState copyWith({
|
DriftBackupState copyWith({
|
||||||
|
|
@ -133,30 +130,28 @@ class DriftBackupState {
|
||||||
int? backupCount,
|
int? backupCount,
|
||||||
int? remainderCount,
|
int? remainderCount,
|
||||||
int? processingCount,
|
int? processingCount,
|
||||||
int? enqueueCount,
|
|
||||||
int? enqueueTotalCount,
|
|
||||||
bool? isCanceling,
|
|
||||||
bool? isSyncing,
|
bool? isSyncing,
|
||||||
Map<String, DriftUploadStatus>? uploadItems,
|
|
||||||
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,
|
||||||
backupCount: backupCount ?? this.backupCount,
|
backupCount: backupCount ?? this.backupCount,
|
||||||
remainderCount: remainderCount ?? this.remainderCount,
|
remainderCount: remainderCount ?? this.remainderCount,
|
||||||
processingCount: processingCount ?? this.processingCount,
|
processingCount: processingCount ?? this.processingCount,
|
||||||
enqueueCount: enqueueCount ?? this.enqueueCount,
|
|
||||||
enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount,
|
|
||||||
isCanceling: isCanceling ?? this.isCanceling,
|
|
||||||
isSyncing: isSyncing ?? this.isSyncing,
|
isSyncing: isSyncing ?? this.isSyncing,
|
||||||
uploadItems: uploadItems ?? this.uploadItems,
|
|
||||||
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, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -168,12 +163,11 @@ class DriftBackupState {
|
||||||
other.backupCount == backupCount &&
|
other.backupCount == backupCount &&
|
||||||
other.remainderCount == remainderCount &&
|
other.remainderCount == remainderCount &&
|
||||||
other.processingCount == processingCount &&
|
other.processingCount == processingCount &&
|
||||||
other.enqueueCount == enqueueCount &&
|
|
||||||
other.enqueueTotalCount == enqueueTotalCount &&
|
|
||||||
other.isCanceling == isCanceling &&
|
|
||||||
other.isSyncing == isSyncing &&
|
other.isSyncing == isSyncing &&
|
||||||
|
other.error == error &&
|
||||||
|
mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) &&
|
||||||
mapEquals(other.uploadItems, uploadItems) &&
|
mapEquals(other.uploadItems, uploadItems) &&
|
||||||
other.error == error;
|
other.cancelToken == cancelToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -182,44 +176,40 @@ class DriftBackupState {
|
||||||
backupCount.hashCode ^
|
backupCount.hashCode ^
|
||||||
remainderCount.hashCode ^
|
remainderCount.hashCode ^
|
||||||
processingCount.hashCode ^
|
processingCount.hashCode ^
|
||||||
enqueueCount.hashCode ^
|
|
||||||
enqueueTotalCount.hashCode ^
|
|
||||||
isCanceling.hashCode ^
|
|
||||||
isSyncing.hashCode ^
|
isSyncing.hashCode ^
|
||||||
|
error.hashCode ^
|
||||||
uploadItems.hashCode ^
|
uploadItems.hashCode ^
|
||||||
error.hashCode;
|
cancelToken.hashCode ^
|
||||||
|
iCloudDownloadProgress.hashCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final driftBackupProvider = StateNotifierProvider<DriftBackupNotifier, DriftBackupState>((ref) {
|
final driftBackupProvider = StateNotifierProvider<DriftBackupNotifier, DriftBackupState>((ref) {
|
||||||
return DriftBackupNotifier(ref.watch(uploadServiceProvider));
|
return DriftBackupNotifier(
|
||||||
|
ref.watch(foregroundUploadServiceProvider),
|
||||||
|
ref.watch(backgroundUploadServiceProvider),
|
||||||
|
UploadSpeedManager(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||||
DriftBackupNotifier(this._uploadService)
|
DriftBackupNotifier(this._foregroundUploadService, this._backgroundUploadService, this._uploadSpeedManager)
|
||||||
: super(
|
: super(
|
||||||
const DriftBackupState(
|
const DriftBackupState(
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
backupCount: 0,
|
backupCount: 0,
|
||||||
remainderCount: 0,
|
remainderCount: 0,
|
||||||
processingCount: 0,
|
processingCount: 0,
|
||||||
enqueueCount: 0,
|
|
||||||
enqueueTotalCount: 0,
|
|
||||||
isCanceling: false,
|
|
||||||
isSyncing: false,
|
isSyncing: false,
|
||||||
uploadItems: {},
|
uploadItems: {},
|
||||||
error: BackupError.none,
|
error: BackupError.none,
|
||||||
),
|
),
|
||||||
) {
|
);
|
||||||
{
|
|
||||||
_statusSubscription = _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
|
final ForegroundUploadService _foregroundUploadService;
|
||||||
_progressSubscription = _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
|
final BackgroundUploadService _backgroundUploadService;
|
||||||
}
|
final UploadSpeedManager _uploadSpeedManager;
|
||||||
}
|
|
||||||
|
|
||||||
final UploadService _uploadService;
|
|
||||||
StreamSubscription<TaskStatusUpdate>? _statusSubscription;
|
|
||||||
StreamSubscription<TaskProgressUpdate>? _progressSubscription;
|
|
||||||
final _logger = Logger("DriftBackupNotifier");
|
final _logger = Logger("DriftBackupNotifier");
|
||||||
|
|
||||||
/// Remove upload item from state
|
/// Remove upload item from state
|
||||||
|
|
@ -235,120 +225,12 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
|
|
||||||
if (!mounted) {
|
|
||||||
_logger.warning("Skip _handleTaskStatusUpdate: notifier disposed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final taskId = update.task.taskId;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the completed task from the upload items
|
|
||||||
if (state.uploadItems.containsKey(taskId)) {
|
|
||||||
Future.delayed(const Duration(milliseconds: 1000), () {
|
|
||||||
_removeUploadItem(taskId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
case TaskStatus.failed:
|
|
||||||
// Ignore retry errors to avoid confusing users
|
|
||||||
if (update.exception?.description == 'Delayed or retried enqueue failed') {
|
|
||||||
_removeUploadItem(taskId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
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:
|
|
||||||
_removeUploadItem(update.task.taskId);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleTaskProgressUpdate(TaskProgressUpdate update) {
|
|
||||||
if (!mounted) {
|
|
||||||
_logger.warning("Skip _handleTaskProgressUpdate: notifier disposed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final taskId = update.task.taskId;
|
|
||||||
final filename = update.task.displayName;
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = state.copyWith(
|
|
||||||
uploadItems: {
|
|
||||||
...state.uploadItems,
|
|
||||||
taskId: DriftUploadStatus(
|
|
||||||
taskId: taskId,
|
|
||||||
filename: filename,
|
|
||||||
progress: progress,
|
|
||||||
fileSize: update.expectedFileSize,
|
|
||||||
networkSpeedAsString: update.networkSpeedAsString,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> getBackupStatus(String userId) async {
|
Future<void> getBackupStatus(String userId) async {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
_logger.warning("Skip getBackupStatus (pre-call): notifier disposed");
|
_logger.warning("Skip getBackupStatus (pre-call): notifier disposed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final counts = await _uploadService.getBackupCounts(userId);
|
final counts = await _foregroundUploadService.getBackupCounts(userId);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
_logger.warning("Skip getBackupStatus (post-call): notifier disposed");
|
_logger.warning("Skip getBackupStatus (post-call): notifier disposed");
|
||||||
return;
|
return;
|
||||||
|
|
@ -374,47 +256,126 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||||
state = state.copyWith(isSyncing: isSyncing);
|
state = state.copyWith(isSyncing: isSyncing);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startBackup(String userId) {
|
Future<void> startForegroundBackup(String userId) async {
|
||||||
state = state.copyWith(error: BackupError.none);
|
state = state.copyWith(error: BackupError.none);
|
||||||
return _uploadService.startBackup(userId, _updateEnqueueCount);
|
|
||||||
|
final cancelToken = CancellationToken();
|
||||||
|
state = state.copyWith(cancelToken: cancelToken);
|
||||||
|
|
||||||
|
return _foregroundUploadService.uploadCandidates(
|
||||||
|
userId,
|
||||||
|
cancelToken,
|
||||||
|
callbacks: UploadCallbacks(
|
||||||
|
onProgress: _handleForegroundBackupProgress,
|
||||||
|
onSuccess: _handleForegroundBackupSuccess,
|
||||||
|
onError: _handleForegroundBackupError,
|
||||||
|
onICloudProgress: _handleICloudProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateEnqueueCount(EnqueueStatus status) {
|
Future<void> stopForegroundBackup() async {
|
||||||
state = state.copyWith(enqueueCount: status.enqueueCount, enqueueTotalCount: status.totalCount);
|
state.cancelToken?.cancel();
|
||||||
|
_uploadSpeedManager.clear();
|
||||||
|
state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancel() async {
|
void _handleICloudProgress(String localAssetId, double progress) {
|
||||||
if (!mounted) {
|
state = state.copyWith(iCloudDownloadProgress: {...state.iCloudDownloadProgress, localAssetId: progress});
|
||||||
_logger.warning("Skip cancel (pre-call): notifier disposed");
|
|
||||||
return;
|
if (progress >= 1.0) {
|
||||||
|
Future.delayed(const Duration(milliseconds: 250), () {
|
||||||
|
final updatedProgress = Map<String, double>.from(state.iCloudDownloadProgress);
|
||||||
|
updatedProgress.remove(localAssetId);
|
||||||
|
state = state.copyWith(iCloudDownloadProgress: updatedProgress);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
dPrint(() => "Canceling backup tasks...");
|
}
|
||||||
state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none);
|
|
||||||
|
|
||||||
final activeTaskCount = await _uploadService.cancelBackup();
|
void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) {
|
||||||
if (!mounted) {
|
if (state.cancelToken == null) {
|
||||||
_logger.warning("Skip cancel (post-call): notifier disposed");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTaskCount > 0) {
|
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||||
dPrint(() => "$activeTaskCount tasks left, continuing to cancel...");
|
final networkSpeedAsString = _uploadSpeedManager.updateProgress(localAssetId, bytes, totalBytes);
|
||||||
await cancel();
|
final currentItem = state.uploadItems[localAssetId];
|
||||||
|
if (currentItem != null) {
|
||||||
|
state = state.copyWith(
|
||||||
|
uploadItems: {
|
||||||
|
...state.uploadItems,
|
||||||
|
localAssetId: currentItem.copyWith(
|
||||||
|
filename: filename,
|
||||||
|
progress: progress,
|
||||||
|
fileSize: totalBytes,
|
||||||
|
networkSpeedAsString: networkSpeedAsString,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
dPrint(() => "All tasks canceled successfully.");
|
state = state.copyWith(
|
||||||
// Clear all upload items when cancellation is complete
|
uploadItems: {
|
||||||
state = state.copyWith(isCanceling: false, uploadItems: {});
|
...state.uploadItems,
|
||||||
|
localAssetId: DriftUploadStatus(
|
||||||
|
taskId: localAssetId,
|
||||||
|
filename: filename,
|
||||||
|
progress: progress,
|
||||||
|
fileSize: totalBytes,
|
||||||
|
networkSpeedAsString: networkSpeedAsString,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> handleBackupResume(String userId) async {
|
void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) {
|
||||||
|
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
|
||||||
|
_uploadSpeedManager.removeTask(localAssetId);
|
||||||
|
|
||||||
|
Future.delayed(const Duration(milliseconds: 1000), () {
|
||||||
|
_removeUploadItem(localAssetId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleForegroundBackupError(String localAssetId, String errorMessage) {
|
||||||
|
_logger.severe("Upload failed for $localAssetId: $errorMessage");
|
||||||
|
|
||||||
|
final currentItem = state.uploadItems[localAssetId];
|
||||||
|
if (currentItem != null) {
|
||||||
|
state = state.copyWith(
|
||||||
|
uploadItems: {
|
||||||
|
...state.uploadItems,
|
||||||
|
localAssetId: currentItem.copyWith(isFailed: true, error: errorMessage),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(
|
||||||
|
uploadItems: {
|
||||||
|
...state.uploadItems,
|
||||||
|
localAssetId: DriftUploadStatus(
|
||||||
|
taskId: localAssetId,
|
||||||
|
filename: 'Unknown',
|
||||||
|
progress: 0,
|
||||||
|
fileSize: 0,
|
||||||
|
networkSpeedAsString: '',
|
||||||
|
isFailed: true,
|
||||||
|
error: errorMessage,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_uploadSpeedManager.removeTask(localAssetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> startBackupWithURLSession(String userId) async {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
|
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_logger.info("Resuming backup tasks...");
|
_logger.info("Resuming backup tasks...");
|
||||||
state = state.copyWith(error: BackupError.none);
|
state = state.copyWith(error: BackupError.none);
|
||||||
final tasks = await _uploadService.getActiveTasks(kBackupGroup);
|
final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
|
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
|
||||||
return;
|
return;
|
||||||
|
|
@ -422,20 +383,12 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||||
_logger.info("Found ${tasks.length} tasks");
|
_logger.info("Found ${tasks.length} tasks");
|
||||||
|
|
||||||
if (tasks.isEmpty) {
|
if (tasks.isEmpty) {
|
||||||
// Start a new backup queue
|
_logger.info("Start backup with URLSession");
|
||||||
_logger.info("Start a new backup queue");
|
return _backgroundUploadService.uploadBackupCandidates(userId);
|
||||||
return startBackup(userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.info("Tasks to resume: ${tasks.length}");
|
_logger.info("Tasks to resume: ${tasks.length}");
|
||||||
return _uploadService.resumeBackup();
|
return _backgroundUploadService.resume();
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_statusSubscription?.cancel();
|
|
||||||
_progressSubscription?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -445,7 +398,7 @@ final driftBackupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ref.read(backupRepositoryProvider).getCandidates(user.id, onlyHashed: false);
|
return ref.read(foregroundUploadServiceProvider).getBackupCandidates(user.id, onlyHashed: false);
|
||||||
});
|
});
|
||||||
|
|
||||||
final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family<List<LocalAlbum>, String>((
|
final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family<List<LocalAlbum>, String>((
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:background_downloader/background_downloader.dart';
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
|
import 'package:cancellation_token_http/http.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
|
@ -13,10 +14,11 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||||
import 'package:immich_mobile/services/action.service.dart';
|
import 'package:immich_mobile/services/action.service.dart';
|
||||||
import 'package:immich_mobile/services/download.service.dart';
|
import 'package:immich_mobile/services/download.service.dart';
|
||||||
import 'package:immich_mobile/services/timeline.service.dart';
|
import 'package:immich_mobile/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
@ -40,7 +42,7 @@ class ActionResult {
|
||||||
class ActionNotifier extends Notifier<void> {
|
class ActionNotifier extends Notifier<void> {
|
||||||
final Logger _logger = Logger('ActionNotifier');
|
final Logger _logger = Logger('ActionNotifier');
|
||||||
late ActionService _service;
|
late ActionService _service;
|
||||||
late UploadService _uploadService;
|
late ForegroundUploadService _foregroundUploadService;
|
||||||
late DownloadService _downloadService;
|
late DownloadService _downloadService;
|
||||||
late AssetService _assetService;
|
late AssetService _assetService;
|
||||||
|
|
||||||
|
|
@ -48,7 +50,7 @@ class ActionNotifier extends Notifier<void> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void build() {
|
void build() {
|
||||||
_uploadService = ref.watch(uploadServiceProvider);
|
_foregroundUploadService = ref.watch(foregroundUploadServiceProvider);
|
||||||
_service = ref.watch(actionServiceProvider);
|
_service = ref.watch(actionServiceProvider);
|
||||||
_assetService = ref.watch(assetServiceProvider);
|
_assetService = ref.watch(assetServiceProvider);
|
||||||
_downloadService = ref.watch(downloadServiceProvider);
|
_downloadService = ref.watch(downloadServiceProvider);
|
||||||
|
|
@ -411,14 +413,44 @@ class ActionNotifier extends Notifier<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ActionResult> upload(ActionSource source) async {
|
Future<ActionResult> upload(ActionSource source, {List<LocalAsset>? assets}) async {
|
||||||
final assets = _getAssets(source).whereType<LocalAsset>().toList();
|
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
|
||||||
|
|
||||||
|
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
|
||||||
|
final cancelToken = CancellationToken();
|
||||||
|
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
|
||||||
|
|
||||||
|
// Initialize progress for all assets
|
||||||
|
for (final asset in assetsToUpload) {
|
||||||
|
progressNotifier.setProgress(asset.id, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _uploadService.manualBackup(assets);
|
await _foregroundUploadService.uploadManual(
|
||||||
return ActionResult(count: assets.length, success: true);
|
assetsToUpload,
|
||||||
|
cancelToken,
|
||||||
|
callbacks: UploadCallbacks(
|
||||||
|
onProgress: (localAssetId, filename, bytes, totalBytes) {
|
||||||
|
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||||
|
progressNotifier.setProgress(localAssetId, progress);
|
||||||
|
},
|
||||||
|
onSuccess: (localAssetId, remoteAssetId) {
|
||||||
|
progressNotifier.remove(localAssetId);
|
||||||
|
},
|
||||||
|
onError: (localAssetId, errorMessage) {
|
||||||
|
progressNotifier.setError(localAssetId);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return ActionResult(count: assetsToUpload.length, success: true);
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Failed manually upload assets', error, stack);
|
_logger.severe('Failed manually upload assets', error, stack);
|
||||||
return ActionResult(count: assets.length, success: false, error: error.toString());
|
return ActionResult(count: assetsToUpload.length, success: false, error: error.toString());
|
||||||
|
} finally {
|
||||||
|
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||||
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
|
progressNotifier.clear();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
|
|
||||||
final storageRepositoryProvider = Provider<StorageRepository>((ref) => const StorageRepository());
|
final storageRepositoryProvider = Provider<StorageRepository>((ref) => StorageRepository());
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ class UploadTaskWithFile {
|
||||||
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
|
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
|
||||||
|
|
||||||
class UploadRepository {
|
class UploadRepository {
|
||||||
|
final Logger logger = Logger('UploadRepository');
|
||||||
void Function(TaskStatusUpdate)? onUploadStatus;
|
void Function(TaskStatusUpdate)? onUploadStatus;
|
||||||
void Function(TaskProgressUpdate)? onTaskProgress;
|
void Function(TaskProgressUpdate)? onTaskProgress;
|
||||||
|
|
||||||
|
|
@ -92,52 +94,114 @@ class UploadRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> backupWithDartClient(Iterable<UploadTaskWithFile> tasks, CancellationToken cancelToken) async {
|
Future<UploadResult> uploadFile({
|
||||||
final httpClient = Client();
|
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 String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
|
|
||||||
Logger logger = Logger('UploadRepository');
|
try {
|
||||||
for (final candidate in tasks) {
|
final fileStream = file.openRead();
|
||||||
if (cancelToken.isCancelled) {
|
final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName);
|
||||||
logger.warning("Backup was cancelled by the user");
|
|
||||||
break;
|
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 responseBodyString = await response.stream.bytesToString();
|
||||||
|
|
||||||
|
if (![200, 201].contains(response.statusCode)) {
|
||||||
|
String? errorMessage;
|
||||||
|
|
||||||
|
if (response.statusCode == 413) {
|
||||||
|
errorMessage = 'Error(413) File is too large to upload';
|
||||||
|
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final error = jsonDecode(responseBodyString);
|
||||||
|
errorMessage = error['message'] ?? error['error'];
|
||||||
|
} catch (_) {
|
||||||
|
errorMessage = responseBodyString.isNotEmpty
|
||||||
|
? responseBodyString
|
||||||
|
: 'Upload failed with status ${response.statusCode}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final fileStream = candidate.file.openRead();
|
final responseBody = jsonDecode(responseBodyString);
|
||||||
final assetRawUploadData = MultipartFile(
|
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
|
||||||
"assetData",
|
} catch (e) {
|
||||||
fileStream,
|
return UploadResult.error(errorMessage: 'Failed to parse server response');
|
||||||
candidate.file.lengthSync(),
|
|
||||||
filename: candidate.task.filename,
|
|
||||||
);
|
|
||||||
|
|
||||||
final baseRequest = MultipartRequest('POST', Uri.parse('$savedEndpoint/assets'));
|
|
||||||
|
|
||||||
baseRequest.headers.addAll(candidate.task.headers);
|
|
||||||
baseRequest.fields.addAll(candidate.task.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;
|
|
||||||
|
|
||||||
logger.warning(
|
|
||||||
"Error(${error['statusCode']}) uploading ${candidate.task.filename} | Created on ${candidate.task.fields["fileCreatedAt"]} | ${error['error']}",
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} on CancelledException {
|
|
||||||
logger.warning("Backup was cancelled by the user");
|
|
||||||
break;
|
|
||||||
} catch (error, stackTrace) {
|
|
||||||
logger.warning("Error backup asset: ${error.toString()}: $stackTrace");
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
} 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:background_downloader/background_downloader.dart';
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
import 'package:cancellation_token_http/http.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
|
|
@ -15,12 +14,9 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/models/server_info/server_info.model.dart';
|
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
|
@ -29,43 +25,98 @@ 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;
|
||||||
|
|
||||||
final uploadServiceProvider = Provider((ref) {
|
final backgroundUploadServiceProvider = Provider((ref) {
|
||||||
final service = UploadService(
|
final service = BackgroundUploadService(
|
||||||
ref.watch(uploadRepositoryProvider),
|
ref.watch(uploadRepositoryProvider),
|
||||||
ref.watch(backupRepositoryProvider),
|
|
||||||
ref.watch(storageRepositoryProvider),
|
ref.watch(storageRepositoryProvider),
|
||||||
ref.watch(localAssetRepository),
|
ref.watch(localAssetRepository),
|
||||||
|
ref.watch(backupRepositoryProvider),
|
||||||
ref.watch(appSettingsServiceProvider),
|
ref.watch(appSettingsServiceProvider),
|
||||||
ref.watch(assetMediaRepositoryProvider),
|
ref.watch(assetMediaRepositoryProvider),
|
||||||
ref.watch(serverInfoProvider),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
ref.onDispose(service.dispose);
|
ref.onDispose(service.dispose);
|
||||||
return service;
|
return service;
|
||||||
});
|
});
|
||||||
|
|
||||||
class UploadService {
|
/// Metadata for upload tasks to track live photo handling
|
||||||
UploadService(
|
class UploadTaskMetadata {
|
||||||
|
final String localAssetId;
|
||||||
|
final bool isLivePhotos;
|
||||||
|
final String livePhotoVideoId;
|
||||||
|
|
||||||
|
const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId});
|
||||||
|
|
||||||
|
UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) {
|
||||||
|
return UploadTaskMetadata(
|
||||||
|
localAssetId: localAssetId ?? this.localAssetId,
|
||||||
|
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
|
||||||
|
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'localAssetId': localAssetId,
|
||||||
|
'isLivePhotos': isLivePhotos,
|
||||||
|
'livePhotoVideoId': livePhotoVideoId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory UploadTaskMetadata.fromMap(Map<String, dynamic> map) {
|
||||||
|
return UploadTaskMetadata(
|
||||||
|
localAssetId: map['localAssetId'] as String,
|
||||||
|
isLivePhotos: map['isLivePhotos'] as bool,
|
||||||
|
livePhotoVideoId: map['livePhotoVideoId'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory UploadTaskMetadata.fromJson(String source) =>
|
||||||
|
UploadTaskMetadata.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(covariant UploadTaskMetadata other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other.localAssetId == localAssetId &&
|
||||||
|
other.isLivePhotos == isLivePhotos &&
|
||||||
|
other.livePhotoVideoId == livePhotoVideoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for handling background uploads using iOS URLSession (background_downloader)
|
||||||
|
///
|
||||||
|
/// This service handles asynchronous background uploads that can continue
|
||||||
|
/// even when the app is suspended. Primarily used for iOS background backup.
|
||||||
|
class BackgroundUploadService {
|
||||||
|
BackgroundUploadService(
|
||||||
this._uploadRepository,
|
this._uploadRepository,
|
||||||
this._backupRepository,
|
|
||||||
this._storageRepository,
|
this._storageRepository,
|
||||||
this._localAssetRepository,
|
this._localAssetRepository,
|
||||||
|
this._backupRepository,
|
||||||
this._appSettingsService,
|
this._appSettingsService,
|
||||||
this._assetMediaRepository,
|
this._assetMediaRepository,
|
||||||
this._serverInfo,
|
|
||||||
) {
|
) {
|
||||||
_uploadRepository.onUploadStatus = _onUploadCallback;
|
_uploadRepository.onUploadStatus = _onUploadCallback;
|
||||||
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
|
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
final UploadRepository _uploadRepository;
|
final UploadRepository _uploadRepository;
|
||||||
final DriftBackupRepository _backupRepository;
|
|
||||||
final StorageRepository _storageRepository;
|
final StorageRepository _storageRepository;
|
||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
|
final DriftBackupRepository _backupRepository;
|
||||||
final AppSettingsService _appSettingsService;
|
final AppSettingsService _appSettingsService;
|
||||||
final AssetMediaRepository _assetMediaRepository;
|
final AssetMediaRepository _assetMediaRepository;
|
||||||
final ServerInfo _serverInfo;
|
final Logger _logger = Logger('BackgroundUploadService');
|
||||||
final Logger _logger = Logger('UploadService');
|
|
||||||
|
|
||||||
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
|
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
|
||||||
final StreamController<TaskProgressUpdate> _taskProgressController = StreamController<TaskProgressUpdate>.broadcast();
|
final StreamController<TaskProgressUpdate> _taskProgressController = StreamController<TaskProgressUpdate>.broadcast();
|
||||||
|
|
@ -93,116 +144,49 @@ class UploadService {
|
||||||
_taskProgressController.close();
|
_taskProgressController.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enqueue tasks to the background upload queue
|
||||||
Future<List<bool>> enqueueTasks(List<UploadTask> tasks) {
|
Future<List<bool>> enqueueTasks(List<UploadTask> tasks) {
|
||||||
return _uploadRepository.enqueueBackgroundAll(tasks);
|
return _uploadRepository.enqueueBackgroundAll(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a list of tasks that are ENQUEUED or RUNNING
|
||||||
Future<List<Task>> getActiveTasks(String group) {
|
Future<List<Task>> getActiveTasks(String group) {
|
||||||
return _uploadRepository.getActiveTasks(group);
|
return _uploadRepository.getActiveTasks(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<({int total, int remainder, int processing})> getBackupCounts(String userId) {
|
/// Start background upload using iOS URLSession
|
||||||
return _backupRepository.getAllCounts(userId);
|
///
|
||||||
}
|
/// Finds backup candidates, builds upload tasks, and enqueues them
|
||||||
|
/// for background processing.
|
||||||
Future<void> manualBackup(List<LocalAsset> localAssets) async {
|
Future<void> uploadBackupCandidates(String userId) async {
|
||||||
await _storageRepository.clearCache();
|
await _storageRepository.clearCache();
|
||||||
|
shouldAbortQueuingTasks = false;
|
||||||
|
|
||||||
|
final candidates = await _backupRepository.getCandidates(userId);
|
||||||
|
if (candidates.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = 100;
|
||||||
|
final batch = candidates.take(batchSize).toList();
|
||||||
List<UploadTask> tasks = [];
|
List<UploadTask> tasks = [];
|
||||||
for (final asset in localAssets) {
|
|
||||||
final task = await getUploadTask(
|
for (final asset in batch) {
|
||||||
asset,
|
final task = await getUploadTask(asset);
|
||||||
group: kManualUploadGroup,
|
|
||||||
priority: 1, // High priority after upload motion photo part
|
|
||||||
);
|
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
tasks.add(task);
|
tasks.add(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tasks.isNotEmpty) {
|
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
|
||||||
await enqueueTasks(tasks);
|
await enqueueTasks(tasks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find backup candidates
|
/// Cancel all ongoing background uploads and reset the upload queue
|
||||||
/// Build the upload tasks
|
|
||||||
/// Enqueue the tasks
|
|
||||||
Future<void> startBackup(String userId, void Function(EnqueueStatus status) onEnqueueTasks) async {
|
|
||||||
await _storageRepository.clearCache();
|
|
||||||
|
|
||||||
shouldAbortQueuingTasks = false;
|
|
||||||
|
|
||||||
final candidates = await _backupRepository.getCandidates(userId);
|
|
||||||
if (candidates.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchSize = 100;
|
|
||||||
int count = 0;
|
|
||||||
for (int i = 0; i < candidates.length; i += batchSize) {
|
|
||||||
if (shouldAbortQueuingTasks) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
final batch = candidates.skip(i).take(batchSize).toList();
|
|
||||||
List<UploadTask> tasks = [];
|
|
||||||
for (final asset in batch) {
|
|
||||||
final task = await getUploadTask(asset);
|
|
||||||
if (task != null) {
|
|
||||||
tasks.add(task);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
|
|
||||||
count += tasks.length;
|
|
||||||
await enqueueTasks(tasks);
|
|
||||||
|
|
||||||
onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> startBackupWithHttpClient(String userId, bool hasWifi, CancellationToken token) async {
|
|
||||||
await _storageRepository.clearCache();
|
|
||||||
|
|
||||||
shouldAbortQueuingTasks = false;
|
|
||||||
|
|
||||||
final candidates = await _backupRepository.getCandidates(userId);
|
|
||||||
if (candidates.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchSize = 100;
|
|
||||||
for (int i = 0; i < candidates.length; i += batchSize) {
|
|
||||||
if (shouldAbortQueuingTasks || token.isCancelled) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
final batch = candidates.skip(i).take(batchSize).toList();
|
|
||||||
List<UploadTaskWithFile> tasks = [];
|
|
||||||
for (final asset in batch) {
|
|
||||||
final requireWifi = _shouldRequireWiFi(asset);
|
|
||||||
if (requireWifi && !hasWifi) {
|
|
||||||
_logger.warning('Skipping upload for ${asset.id} because it requires WiFi');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final task = await _getUploadTaskWithFile(asset);
|
|
||||||
if (task != null) {
|
|
||||||
tasks.add(task);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
|
|
||||||
await _uploadRepository.backupWithDartClient(tasks, token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel all ongoing uploads and reset the upload queue
|
|
||||||
///
|
///
|
||||||
/// Return the number of left over tasks in the queue
|
/// Returns the number of tasks left in the queue
|
||||||
Future<int> cancelBackup() async {
|
Future<int> cancel() async {
|
||||||
shouldAbortQueuingTasks = true;
|
shouldAbortQueuingTasks = true;
|
||||||
|
|
||||||
await _storageRepository.clearCache();
|
await _storageRepository.clearCache();
|
||||||
|
|
@ -213,7 +197,8 @@ class UploadService {
|
||||||
return activeTasks.length;
|
return activeTasks.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resumeBackup() {
|
/// Resume background backup processing
|
||||||
|
Future<void> resume() {
|
||||||
return _uploadRepository.start();
|
return _uploadRepository.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,42 +256,6 @@ class UploadService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<UploadTaskWithFile?> _getUploadTaskWithFile(LocalAsset asset) async {
|
|
||||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
|
||||||
if (entity == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
|
||||||
if (file == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name;
|
|
||||||
|
|
||||||
String metadata = UploadTaskMetadata(
|
|
||||||
localAssetId: asset.id,
|
|
||||||
isLivePhotos: entity.isLivePhoto,
|
|
||||||
livePhotoVideoId: '',
|
|
||||||
).toJson();
|
|
||||||
|
|
||||||
return UploadTaskWithFile(
|
|
||||||
file: file,
|
|
||||||
task: await buildUploadTask(
|
|
||||||
file,
|
|
||||||
createdAt: asset.createdAt,
|
|
||||||
modifiedAt: asset.updatedAt,
|
|
||||||
originalFileName: originalFileName,
|
|
||||||
deviceAssetId: asset.id,
|
|
||||||
metadata: metadata,
|
|
||||||
group: "group",
|
|
||||||
priority: 0,
|
|
||||||
isFavorite: asset.isFavorite,
|
|
||||||
requiresWiFi: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
||||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||||
|
|
@ -443,8 +392,7 @@ class UploadService {
|
||||||
'isFavorite': isFavorite?.toString() ?? 'false',
|
'isFavorite': isFavorite?.toString() ?? 'false',
|
||||||
'duration': '0',
|
'duration': '0',
|
||||||
if (fields != null) ...fields,
|
if (fields != null) ...fields,
|
||||||
// Include cloudId and eTag in metadata if available and server version supports it
|
if (CurrentPlatform.isIOS && cloudId != null)
|
||||||
if (CurrentPlatform.isIOS && cloudId != null && _serverInfo.serverVersion.isAtLeast(major: 2, minor: 4))
|
|
||||||
'metadata': jsonEncode([
|
'metadata': jsonEncode([
|
||||||
RemoteAssetMetadataItem(
|
RemoteAssetMetadataItem(
|
||||||
key: RemoteAssetMetadataKey.mobileApp,
|
key: RemoteAssetMetadataKey.mobileApp,
|
||||||
|
|
@ -479,56 +427,3 @@ class UploadService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UploadTaskMetadata {
|
|
||||||
final String localAssetId;
|
|
||||||
final bool isLivePhotos;
|
|
||||||
final String livePhotoVideoId;
|
|
||||||
|
|
||||||
const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId});
|
|
||||||
|
|
||||||
UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) {
|
|
||||||
return UploadTaskMetadata(
|
|
||||||
localAssetId: localAssetId ?? this.localAssetId,
|
|
||||||
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
|
|
||||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
|
||||||
return <String, dynamic>{
|
|
||||||
'localAssetId': localAssetId,
|
|
||||||
'isLivePhotos': isLivePhotos,
|
|
||||||
'livePhotoVideoId': livePhotoVideoId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
factory UploadTaskMetadata.fromMap(Map<String, dynamic> map) {
|
|
||||||
return UploadTaskMetadata(
|
|
||||||
localAssetId: map['localAssetId'] as String,
|
|
||||||
isLivePhotos: map['isLivePhotos'] as bool,
|
|
||||||
livePhotoVideoId: map['livePhotoVideoId'] as String,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String toJson() => json.encode(toMap());
|
|
||||||
|
|
||||||
factory UploadTaskMetadata.fromJson(String source) =>
|
|
||||||
UploadTaskMetadata.fromMap(json.decode(source) as Map<String, dynamic>);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() =>
|
|
||||||
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(covariant UploadTaskMetadata other) {
|
|
||||||
if (identical(this, other)) return true;
|
|
||||||
|
|
||||||
return other.localAssetId == localAssetId &&
|
|
||||||
other.isLivePhotos == isLivePhotos &&
|
|
||||||
other.livePhotoVideoId == livePhotoVideoId;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
|
|
||||||
}
|
|
||||||
461
mobile/lib/services/foreground_upload.service.dart
Normal file
461
mobile/lib/services/foreground_upload.service.dart
Normal file
|
|
@ -0,0 +1,461 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:cancellation_token_http/http.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
|
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||||
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||||
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
|
|
||||||
|
/// Callbacks for upload progress and status updates
|
||||||
|
class UploadCallbacks {
|
||||||
|
final void Function(String id, String filename, int bytes, int totalBytes)? onProgress;
|
||||||
|
final void Function(String localId, String remoteId)? onSuccess;
|
||||||
|
final void Function(String id, String errorMessage)? onError;
|
||||||
|
final void Function(String id, double progress)? onICloudProgress;
|
||||||
|
|
||||||
|
const UploadCallbacks({this.onProgress, this.onSuccess, this.onError, this.onICloudProgress});
|
||||||
|
}
|
||||||
|
|
||||||
|
final foregroundUploadServiceProvider = Provider((ref) {
|
||||||
|
return ForegroundUploadService(
|
||||||
|
ref.watch(uploadRepositoryProvider),
|
||||||
|
ref.watch(storageRepositoryProvider),
|
||||||
|
ref.watch(backupRepositoryProvider),
|
||||||
|
ref.watch(connectivityApiProvider),
|
||||||
|
ref.watch(appSettingsServiceProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Service for handling foreground HTTP uploads
|
||||||
|
///
|
||||||
|
/// This service handles synchronous uploads using HTTP client with
|
||||||
|
/// concurrent worker pools. Used for manual backups, auto backups
|
||||||
|
/// (foreground mode), and share intent uploads.
|
||||||
|
class ForegroundUploadService {
|
||||||
|
ForegroundUploadService(
|
||||||
|
this._uploadRepository,
|
||||||
|
this._storageRepository,
|
||||||
|
this._backupRepository,
|
||||||
|
this._connectivityApi,
|
||||||
|
this._appSettingsService,
|
||||||
|
);
|
||||||
|
|
||||||
|
final UploadRepository _uploadRepository;
|
||||||
|
final StorageRepository _storageRepository;
|
||||||
|
final DriftBackupRepository _backupRepository;
|
||||||
|
final ConnectivityApi _connectivityApi;
|
||||||
|
final AppSettingsService _appSettingsService;
|
||||||
|
final Logger _logger = Logger('ForegroundUploadService');
|
||||||
|
|
||||||
|
bool shouldAbortUpload = false;
|
||||||
|
|
||||||
|
Future<({int total, int remainder, int processing})> getBackupCounts(String userId) {
|
||||||
|
return _backupRepository.getAllCounts(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<LocalAsset>> getBackupCandidates(String userId, {bool onlyHashed = true}) {
|
||||||
|
return _backupRepository.getCandidates(userId, onlyHashed: onlyHashed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bulk upload of backup candidates from selected albums
|
||||||
|
Future<void> uploadCandidates(
|
||||||
|
String userId,
|
||||||
|
CancellationToken cancelToken, {
|
||||||
|
UploadCallbacks callbacks = const UploadCallbacks(),
|
||||||
|
bool useSequentialUpload = false,
|
||||||
|
}) async {
|
||||||
|
final candidates = await _backupRepository.getCandidates(userId);
|
||||||
|
if (candidates.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final networkCapabilities = await _connectivityApi.getCapabilities();
|
||||||
|
final hasWifi = networkCapabilities.isUnmetered;
|
||||||
|
_logger.info('Network capabilities: $networkCapabilities, hasWifi/isUnmetered: $hasWifi');
|
||||||
|
|
||||||
|
if (useSequentialUpload) {
|
||||||
|
await _uploadSequentially(items: candidates, cancelToken: cancelToken, hasWifi: hasWifi, callbacks: callbacks);
|
||||||
|
} else {
|
||||||
|
await _executeWithWorkerPool<LocalAsset>(
|
||||||
|
items: candidates,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
shouldSkip: (asset) {
|
||||||
|
final requireWifi = _shouldRequireWiFi(asset);
|
||||||
|
return requireWifi && !hasWifi;
|
||||||
|
},
|
||||||
|
processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues
|
||||||
|
Future<void> _uploadSequentially({
|
||||||
|
required List<LocalAsset> items,
|
||||||
|
required CancellationToken cancelToken,
|
||||||
|
required bool hasWifi,
|
||||||
|
required UploadCallbacks callbacks,
|
||||||
|
}) async {
|
||||||
|
final httpClient = Client();
|
||||||
|
await _storageRepository.clearCache();
|
||||||
|
shouldAbortUpload = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (final asset in items) {
|
||||||
|
if (shouldAbortUpload || cancelToken.isCancelled) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final requireWifi = _shouldRequireWiFi(asset);
|
||||||
|
if (requireWifi && !hasWifi) {
|
||||||
|
_logger.warning('Skipping upload for ${asset.id} because it requires WiFi');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
httpClient.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manually upload picked local assets
|
||||||
|
Future<void> uploadManual(
|
||||||
|
List<LocalAsset> localAssets,
|
||||||
|
CancellationToken cancelToken, {
|
||||||
|
UploadCallbacks callbacks = const UploadCallbacks(),
|
||||||
|
}) async {
|
||||||
|
if (localAssets.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _executeWithWorkerPool<LocalAsset>(
|
||||||
|
items: localAssets,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload files from shared intent
|
||||||
|
Future<void> uploadShareIntent(
|
||||||
|
List<File> files, {
|
||||||
|
CancellationToken? cancelToken,
|
||||||
|
void Function(String fileId, int bytes, int totalBytes)? onProgress,
|
||||||
|
void Function(String fileId)? onSuccess,
|
||||||
|
void Function(String fileId, String errorMessage)? onError,
|
||||||
|
}) async {
|
||||||
|
if (files.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final effectiveCancelToken = cancelToken ?? CancellationToken();
|
||||||
|
|
||||||
|
await _executeWithWorkerPool<File>(
|
||||||
|
items: files,
|
||||||
|
cancelToken: effectiveCancelToken,
|
||||||
|
processItem: (file, httpClient) async {
|
||||||
|
final fileId = p.hash(file.path).toString();
|
||||||
|
|
||||||
|
final result = await _uploadSingleFile(
|
||||||
|
file,
|
||||||
|
deviceAssetId: fileId,
|
||||||
|
httpClient: httpClient,
|
||||||
|
cancelToken: effectiveCancelToken,
|
||||||
|
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
onSuccess?.call(fileId);
|
||||||
|
} else if (!result.isCancelled && result.errorMessage != null) {
|
||||||
|
onError?.call(fileId, result.errorMessage!);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
shouldAbortUpload = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic worker pool for concurrent uploads
|
||||||
|
///
|
||||||
|
/// [items] - List of items to process
|
||||||
|
/// [cancelToken] - Token to cancel the operation
|
||||||
|
/// [processItem] - Function to process each item with an HTTP client
|
||||||
|
/// [shouldSkip] - Optional function to skip items (e.g., WiFi requirement check)
|
||||||
|
/// [concurrentWorkers] - Number of concurrent workers (default: 3)
|
||||||
|
Future<void> _executeWithWorkerPool<T>({
|
||||||
|
required List<T> items,
|
||||||
|
required CancellationToken cancelToken,
|
||||||
|
required Future<void> Function(T item, Client httpClient) processItem,
|
||||||
|
bool Function(T item)? shouldSkip,
|
||||||
|
int concurrentWorkers = 3,
|
||||||
|
}) async {
|
||||||
|
final httpClients = List.generate(concurrentWorkers, (_) => Client());
|
||||||
|
|
||||||
|
await _storageRepository.clearCache();
|
||||||
|
shouldAbortUpload = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
int currentIndex = 0;
|
||||||
|
|
||||||
|
Future<void> worker(Client httpClient) async {
|
||||||
|
while (true) {
|
||||||
|
if (shouldAbortUpload || cancelToken.isCancelled) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final index = currentIndex;
|
||||||
|
if (index >= items.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentIndex++;
|
||||||
|
|
||||||
|
final item = items[index];
|
||||||
|
|
||||||
|
if (shouldSkip?.call(item) ?? false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processItem(item, httpClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final workerFutures = <Future<void>>[];
|
||||||
|
for (int i = 0; i < concurrentWorkers; i++) {
|
||||||
|
workerFutures.add(worker(httpClients[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Future.wait(workerFutures);
|
||||||
|
} finally {
|
||||||
|
for (final client in httpClients) {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadSingleAsset(
|
||||||
|
LocalAsset asset,
|
||||||
|
Client httpClient,
|
||||||
|
CancellationToken cancelToken, {
|
||||||
|
required UploadCallbacks callbacks,
|
||||||
|
}) 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 && CurrentPlatform.isIOS) {
|
||||||
|
_logger.info("Loading iCloud asset ${asset.id} - ${asset.name}");
|
||||||
|
|
||||||
|
// Create progress handler for iCloud download
|
||||||
|
PMProgressHandler? progressHandler;
|
||||||
|
StreamSubscription? progressSubscription;
|
||||||
|
|
||||||
|
progressHandler = PMProgressHandler();
|
||||||
|
progressSubscription = progressHandler.stream.listen((event) {
|
||||||
|
callbacks.onICloudProgress?.call(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(),
|
||||||
|
if (CurrentPlatform.isIOS && asset.cloudId != null)
|
||||||
|
'metadata': jsonEncode([
|
||||||
|
RemoteAssetMetadataItem(
|
||||||
|
key: RemoteAssetMetadataKey.mobileApp,
|
||||||
|
value: RemoteAssetMobileAppMetadata(
|
||||||
|
cloudId: asset.cloudId,
|
||||||
|
createdAt: asset.createdAt.toIso8601String(),
|
||||||
|
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||||
|
latitude: asset.latitude?.toString(),
|
||||||
|
longitude: asset.longitude?.toString(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload live photo video first if available
|
||||||
|
String? livePhotoVideoId;
|
||||||
|
if (entity.isLivePhoto && livePhotoFile != null) {
|
||||||
|
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
|
||||||
|
|
||||||
|
final livePhotoResult = await _uploadRepository.uploadFile(
|
||||||
|
file: livePhotoFile,
|
||||||
|
originalFileName: livePhotoTitle,
|
||||||
|
headers: headers,
|
||||||
|
fields: fields,
|
||||||
|
httpClient: httpClient,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
onProgress: (bytes, totalBytes) =>
|
||||||
|
callbacks.onProgress?.call(asset.localId!, livePhotoTitle, bytes, totalBytes),
|
||||||
|
logContext: 'livePhotoVideo[${asset.localId}]',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) {
|
||||||
|
livePhotoVideoId = livePhotoResult.remoteAssetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (livePhotoVideoId != null) {
|
||||||
|
fields['livePhotoVideoId'] = livePhotoVideoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _uploadRepository.uploadFile(
|
||||||
|
file: file,
|
||||||
|
originalFileName: originalFileName,
|
||||||
|
headers: headers,
|
||||||
|
fields: fields,
|
||||||
|
httpClient: httpClient,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
onProgress: (bytes, totalBytes) =>
|
||||||
|
callbacks.onProgress?.call(asset.localId!, originalFileName, bytes, totalBytes),
|
||||||
|
logContext: 'asset[${asset.localId}]',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isSuccess && result.remoteAssetId != null) {
|
||||||
|
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||||
|
} else if (result.isCancelled) {
|
||||||
|
_logger.warning(() => "Backup was cancelled by the user");
|
||||||
|
shouldAbortUpload = true;
|
||||||
|
} else if (result.errorMessage != null) {
|
||||||
|
_logger.severe(
|
||||||
|
() =>
|
||||||
|
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
|
||||||
|
);
|
||||||
|
|
||||||
|
callbacks.onError?.call(asset.localId!, result.errorMessage!);
|
||||||
|
|
||||||
|
if (result.errorMessage == "Quota has been exceeded!") {
|
||||||
|
shouldAbortUpload = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace);
|
||||||
|
callbacks.onError?.call(asset.localId!, error.toString());
|
||||||
|
} finally {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
try {
|
||||||
|
await file?.delete();
|
||||||
|
await livePhotoFile?.delete();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_logger.severe(() => "ERROR deleting file: ${error.toString()}", stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<UploadResult> _uploadSingleFile(
|
||||||
|
File file, {
|
||||||
|
required String deviceAssetId,
|
||||||
|
required Client httpClient,
|
||||||
|
required CancellationToken cancelToken,
|
||||||
|
void Function(int bytes, int totalBytes)? onProgress,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final stats = await file.stat();
|
||||||
|
final fileCreatedAt = stats.changed;
|
||||||
|
final fileModifiedAt = stats.modified;
|
||||||
|
final filename = p.basename(file.path);
|
||||||
|
|
||||||
|
final headers = ApiService.getRequestHeaders();
|
||||||
|
final deviceId = Store.get(StoreKey.deviceId);
|
||||||
|
|
||||||
|
final fields = {
|
||||||
|
'deviceAssetId': deviceAssetId,
|
||||||
|
'deviceId': deviceId,
|
||||||
|
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
|
||||||
|
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
|
||||||
|
'isFavorite': 'false',
|
||||||
|
'duration': '0',
|
||||||
|
};
|
||||||
|
|
||||||
|
return await _uploadRepository.uploadFile(
|
||||||
|
file: file,
|
||||||
|
originalFileName: filename,
|
||||||
|
headers: headers,
|
||||||
|
fields: fields,
|
||||||
|
httpClient: httpClient,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
onProgress: onProgress ?? (_, __) {},
|
||||||
|
logContext: 'shareIntent[$deviceAssetId]',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return UploadResult.error(errorMessage: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _shouldRequireWiFi(LocalAsset asset) {
|
||||||
|
bool requiresWiFi = true;
|
||||||
|
|
||||||
|
if (asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)) {
|
||||||
|
requiresWiFi = false;
|
||||||
|
} else if (!asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)) {
|
||||||
|
requiresWiFi = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return requiresWiFi;
|
||||||
|
}
|
||||||
|
}
|
||||||
182
mobile/lib/utils/upload_speed_calculator.dart
Normal file
182
mobile/lib/utils/upload_speed_calculator.dart
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
/// A class to calculate upload speed based on progress updates.
|
||||||
|
///
|
||||||
|
/// Tracks bytes transferred over time and calculates average speed
|
||||||
|
/// using a sliding window approach to smooth out fluctuations.
|
||||||
|
class UploadSpeedCalculator {
|
||||||
|
/// Creates an UploadSpeedCalculator with the given window size.
|
||||||
|
///
|
||||||
|
/// [windowSize] determines how many recent samples to use for
|
||||||
|
/// calculating the average speed. Default is 5 samples.
|
||||||
|
UploadSpeedCalculator({this.windowSize = 5});
|
||||||
|
|
||||||
|
/// The number of samples to keep in the sliding window.
|
||||||
|
final int windowSize;
|
||||||
|
|
||||||
|
/// List of recent speed samples (bytes per second).
|
||||||
|
final List<double> _speedSamples = [];
|
||||||
|
|
||||||
|
/// The timestamp of the last progress update.
|
||||||
|
DateTime? _lastUpdateTime;
|
||||||
|
|
||||||
|
/// The bytes transferred at the last progress update.
|
||||||
|
int _lastBytes = 0;
|
||||||
|
|
||||||
|
/// The total file size being uploaded.
|
||||||
|
int _totalBytes = 0;
|
||||||
|
|
||||||
|
/// Resets the calculator for a new upload.
|
||||||
|
void reset() {
|
||||||
|
_speedSamples.clear();
|
||||||
|
_lastUpdateTime = null;
|
||||||
|
_lastBytes = 0;
|
||||||
|
_totalBytes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the calculator with the current progress.
|
||||||
|
///
|
||||||
|
/// [currentBytes] is the number of bytes transferred so far.
|
||||||
|
/// [totalBytes] is the total size of the file being uploaded.
|
||||||
|
///
|
||||||
|
/// Returns the calculated speed in MB/s, or -1 if not enough data.
|
||||||
|
double update(int currentBytes, int totalBytes) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
_totalBytes = totalBytes;
|
||||||
|
|
||||||
|
if (_lastUpdateTime == null) {
|
||||||
|
_lastUpdateTime = now;
|
||||||
|
_lastBytes = currentBytes;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
final elapsed = now.difference(_lastUpdateTime!);
|
||||||
|
|
||||||
|
// Only calculate if at least 100ms has passed to avoid division by very small numbers
|
||||||
|
if (elapsed.inMilliseconds < 100) {
|
||||||
|
return _currentSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bytesTransferred = currentBytes - _lastBytes;
|
||||||
|
final elapsedSeconds = elapsed.inMilliseconds / 1000.0;
|
||||||
|
|
||||||
|
// Calculate bytes per second, then convert to MB/s
|
||||||
|
final bytesPerSecond = bytesTransferred / elapsedSeconds;
|
||||||
|
final mbPerSecond = bytesPerSecond / (1024 * 1024);
|
||||||
|
|
||||||
|
// Add to sliding window
|
||||||
|
_speedSamples.add(mbPerSecond);
|
||||||
|
if (_speedSamples.length > windowSize) {
|
||||||
|
_speedSamples.removeAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastUpdateTime = now;
|
||||||
|
_lastBytes = currentBytes;
|
||||||
|
|
||||||
|
return _currentSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current calculated speed in MB/s.
|
||||||
|
///
|
||||||
|
/// Returns -1 if no valid speed has been calculated yet.
|
||||||
|
double get _currentSpeed {
|
||||||
|
if (_speedSamples.isEmpty) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// Calculate average of all samples in the window
|
||||||
|
final sum = _speedSamples.fold(0.0, (prev, speed) => prev + speed);
|
||||||
|
return sum / _speedSamples.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current speed in MB/s, or -1 if not available.
|
||||||
|
double get speed => _currentSpeed;
|
||||||
|
|
||||||
|
/// Returns a human-readable string representation of the current speed.
|
||||||
|
///
|
||||||
|
/// Returns '-- MB/s' if N/A, otherwise in MB/s or kB/s format.
|
||||||
|
String get speedAsString {
|
||||||
|
final s = _currentSpeed;
|
||||||
|
return switch (s) {
|
||||||
|
<= 0 => '-- MB/s',
|
||||||
|
>= 1 => '${s.round()} MB/s',
|
||||||
|
_ => '${(s * 1000).round()} kB/s',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the estimated time remaining as a Duration.
|
||||||
|
///
|
||||||
|
/// Returns Duration with negative seconds if not calculable.
|
||||||
|
Duration get timeRemaining {
|
||||||
|
final s = _currentSpeed;
|
||||||
|
if (s <= 0 || _totalBytes <= 0 || _lastBytes >= _totalBytes) {
|
||||||
|
return const Duration(seconds: -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
final remainingBytes = _totalBytes - _lastBytes;
|
||||||
|
final bytesPerSecond = s * 1024 * 1024;
|
||||||
|
final secondsRemaining = remainingBytes / bytesPerSecond;
|
||||||
|
|
||||||
|
return Duration(seconds: secondsRemaining.round());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a human-readable string representation of time remaining.
|
||||||
|
///
|
||||||
|
/// Returns '--:--' if N/A, otherwise HH:MM:SS or MM:SS format.
|
||||||
|
String get timeRemainingAsString {
|
||||||
|
final remaining = timeRemaining;
|
||||||
|
return switch (remaining.inSeconds) {
|
||||||
|
<= 0 => '--:--',
|
||||||
|
< 3600 =>
|
||||||
|
'${remaining.inMinutes.toString().padLeft(2, "0")}'
|
||||||
|
':${remaining.inSeconds.remainder(60).toString().padLeft(2, "0")}',
|
||||||
|
_ =>
|
||||||
|
'${remaining.inHours}'
|
||||||
|
':${remaining.inMinutes.remainder(60).toString().padLeft(2, "0")}'
|
||||||
|
':${remaining.inSeconds.remainder(60).toString().padLeft(2, "0")}',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manager for tracking upload speeds for multiple concurrent uploads.
|
||||||
|
///
|
||||||
|
/// Each upload is identified by a unique task ID.
|
||||||
|
class UploadSpeedManager {
|
||||||
|
/// Map of task IDs to their speed calculators.
|
||||||
|
final Map<String, UploadSpeedCalculator> _calculators = {};
|
||||||
|
|
||||||
|
/// Gets or creates a speed calculator for the given task ID.
|
||||||
|
UploadSpeedCalculator getCalculator(String taskId) {
|
||||||
|
return _calculators.putIfAbsent(taskId, () => UploadSpeedCalculator());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates progress for a specific task and returns the speed string.
|
||||||
|
///
|
||||||
|
/// [taskId] is the unique identifier for the upload task.
|
||||||
|
/// [currentBytes] is the number of bytes transferred so far.
|
||||||
|
/// [totalBytes] is the total size of the file being uploaded.
|
||||||
|
///
|
||||||
|
/// Returns the human-readable speed string.
|
||||||
|
String updateProgress(String taskId, int currentBytes, int totalBytes) {
|
||||||
|
final calculator = getCalculator(taskId);
|
||||||
|
calculator.update(currentBytes, totalBytes);
|
||||||
|
return calculator.speedAsString;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the current speed string for a specific task.
|
||||||
|
String getSpeedAsString(String taskId) {
|
||||||
|
return _calculators[taskId]?.speedAsString ?? '-- MB/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the time remaining string for a specific task.
|
||||||
|
String getTimeRemainingAsString(String taskId) {
|
||||||
|
return _calculators[taskId]?.timeRemainingAsString ?? '--:--';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a task from tracking.
|
||||||
|
void removeTask(String taskId) {
|
||||||
|
_calculators.remove(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all tracked tasks.
|
||||||
|
void clear() {
|
||||||
|
_calculators.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import 'package:immich_mobile/domain/services/user.service.dart';
|
||||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
class MockStoreService extends Mock implements StoreService {}
|
class MockStoreService extends Mock implements StoreService {}
|
||||||
|
|
@ -16,5 +16,5 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||||
|
|
||||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||||
|
|
||||||
class MockUploadService extends Mock implements UploadService {}
|
class MockBackgroundUploadService extends Mock implements BackgroundUploadService {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ void main() {
|
||||||
late MockApiService apiService;
|
late MockApiService apiService;
|
||||||
late MockNetworkService networkService;
|
late MockNetworkService networkService;
|
||||||
late MockBackgroundSyncManager backgroundSyncManager;
|
late MockBackgroundSyncManager backgroundSyncManager;
|
||||||
late MockUploadService uploadService;
|
|
||||||
late MockAppSettingService appSettingsService;
|
late MockAppSettingService appSettingsService;
|
||||||
late Isar db;
|
late Isar db;
|
||||||
|
|
||||||
|
|
@ -31,7 +30,6 @@ void main() {
|
||||||
apiService = MockApiService();
|
apiService = MockApiService();
|
||||||
networkService = MockNetworkService();
|
networkService = MockNetworkService();
|
||||||
backgroundSyncManager = MockBackgroundSyncManager();
|
backgroundSyncManager = MockBackgroundSyncManager();
|
||||||
uploadService = MockUploadService();
|
|
||||||
appSettingsService = MockAppSettingService();
|
appSettingsService = MockAppSettingService();
|
||||||
|
|
||||||
sut = AuthService(
|
sut = AuthService(
|
||||||
|
|
@ -118,7 +116,6 @@ void main() {
|
||||||
when(() => authApiRepository.logout()).thenAnswer((_) async => {});
|
when(() => authApiRepository.logout()).thenAnswer((_) async => {});
|
||||||
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
|
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
|
||||||
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
|
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
|
||||||
when(() => uploadService.cancelBackup()).thenAnswer((_) => Future.value(1));
|
|
||||||
when(
|
when(
|
||||||
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
|
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
|
||||||
).thenAnswer((_) => Future.value(null));
|
).thenAnswer((_) => Future.value(null));
|
||||||
|
|
@ -133,7 +130,6 @@ void main() {
|
||||||
when(() => authApiRepository.logout()).thenThrow(Exception('Server error'));
|
when(() => authApiRepository.logout()).thenThrow(Exception('Server error'));
|
||||||
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
|
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
|
||||||
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
|
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
|
||||||
when(() => uploadService.cancelBackup()).thenAnswer((_) => Future.value(1));
|
|
||||||
when(
|
when(
|
||||||
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
|
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
|
||||||
).thenAnswer((_) => Future.value(null));
|
).thenAnswer((_) => Future.value(null));
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,8 @@ import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.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_info.model.dart';
|
|
||||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
import '../domain/service.mock.dart';
|
import '../domain/service.mock.dart';
|
||||||
|
|
@ -27,33 +22,12 @@ import '../infrastructure/repository.mock.dart';
|
||||||
import '../mocks/asset_entity.mock.dart';
|
import '../mocks/asset_entity.mock.dart';
|
||||||
import '../repository.mocks.dart';
|
import '../repository.mocks.dart';
|
||||||
|
|
||||||
// Test ServerInfo stub
|
|
||||||
const _serverInfo = ServerInfo(
|
|
||||||
serverVersion: ServerVersion(major: 2, minor: 4, patch: 0),
|
|
||||||
latestVersion: ServerVersion(major: 2, minor: 4, patch: 0),
|
|
||||||
serverFeatures: ServerFeatures(trash: true, map: true, oauthEnabled: false, passwordLogin: true, ocr: false),
|
|
||||||
serverConfig: ServerConfig(
|
|
||||||
trashDays: 30,
|
|
||||||
oauthButtonText: 'Login with OAuth',
|
|
||||||
externalDomain: '',
|
|
||||||
mapDarkStyleUrl: '',
|
|
||||||
mapLightStyleUrl: '',
|
|
||||||
),
|
|
||||||
serverDiskInfo: ServerDiskInfo(
|
|
||||||
diskAvailable: '100GB',
|
|
||||||
diskSize: '500GB',
|
|
||||||
diskUse: '400GB',
|
|
||||||
diskUsagePercentage: 80.0,
|
|
||||||
),
|
|
||||||
versionStatus: VersionStatus.upToDate,
|
|
||||||
);
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late UploadService sut;
|
late BackgroundUploadService sut;
|
||||||
late MockUploadRepository mockUploadRepository;
|
late MockUploadRepository mockUploadRepository;
|
||||||
late MockDriftBackupRepository mockBackupRepository;
|
|
||||||
late MockStorageRepository mockStorageRepository;
|
late MockStorageRepository mockStorageRepository;
|
||||||
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
||||||
|
late MockDriftBackupRepository mockBackupRepository;
|
||||||
late MockAppSettingsService mockAppSettingsService;
|
late MockAppSettingsService mockAppSettingsService;
|
||||||
late MockAssetMediaRepository mockAssetMediaRepository;
|
late MockAssetMediaRepository mockAssetMediaRepository;
|
||||||
late Drift db;
|
late Drift db;
|
||||||
|
|
@ -75,23 +49,22 @@ void main() {
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
mockUploadRepository = MockUploadRepository();
|
mockUploadRepository = MockUploadRepository();
|
||||||
mockBackupRepository = MockDriftBackupRepository();
|
|
||||||
mockStorageRepository = MockStorageRepository();
|
mockStorageRepository = MockStorageRepository();
|
||||||
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
||||||
|
mockBackupRepository = MockDriftBackupRepository();
|
||||||
mockAppSettingsService = MockAppSettingsService();
|
mockAppSettingsService = MockAppSettingsService();
|
||||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||||
|
|
||||||
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)).thenReturn(false);
|
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)).thenReturn(false);
|
||||||
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)).thenReturn(false);
|
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)).thenReturn(false);
|
||||||
|
|
||||||
sut = UploadService(
|
sut = BackgroundUploadService(
|
||||||
mockUploadRepository,
|
mockUploadRepository,
|
||||||
mockBackupRepository,
|
|
||||||
mockStorageRepository,
|
mockStorageRepository,
|
||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
|
mockBackupRepository,
|
||||||
mockAppSettingsService,
|
mockAppSettingsService,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
_serverInfo,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
mockUploadRepository.onUploadStatus = (_) {};
|
mockUploadRepository.onUploadStatus = (_) {};
|
||||||
|
|
@ -201,14 +174,13 @@ void main() {
|
||||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
|
||||||
final sutWithV24 = UploadService(
|
final sutWithV24 = BackgroundUploadService(
|
||||||
mockUploadRepository,
|
mockUploadRepository,
|
||||||
mockBackupRepository,
|
|
||||||
mockStorageRepository,
|
mockStorageRepository,
|
||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
|
mockBackupRepository,
|
||||||
mockAppSettingsService,
|
mockAppSettingsService,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
_serverInfo,
|
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
|
|
||||||
|
|
@ -247,61 +219,17 @@ void main() {
|
||||||
expect(metadata[0]['value']['longitude'], isNotNull);
|
expect(metadata[0]['value']['longitude'], isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should NOT include metadata on iOS when server version is below 2.4', () async {
|
|
||||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
||||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
|
||||||
|
|
||||||
final sutWithV23 = UploadService(
|
|
||||||
mockUploadRepository,
|
|
||||||
mockBackupRepository,
|
|
||||||
mockStorageRepository,
|
|
||||||
mockLocalAssetRepository,
|
|
||||||
mockAppSettingsService,
|
|
||||||
mockAssetMediaRepository,
|
|
||||||
_serverInfo.copyWith(
|
|
||||||
serverVersion: const ServerVersion(major: 2, minor: 3, patch: 0),
|
|
||||||
latestVersion: const ServerVersion(major: 2, minor: 3, patch: 0),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
addTearDown(() => sutWithV23.dispose());
|
|
||||||
|
|
||||||
final assetWithCloudId = LocalAsset(
|
|
||||||
id: 'test-asset-id',
|
|
||||||
name: 'test.jpg',
|
|
||||||
type: AssetType.image,
|
|
||||||
createdAt: DateTime(2025, 1, 1),
|
|
||||||
updatedAt: DateTime(2025, 1, 2),
|
|
||||||
cloudId: 'cloud-id-123',
|
|
||||||
latitude: 37.7749,
|
|
||||||
longitude: -122.4194,
|
|
||||||
);
|
|
||||||
|
|
||||||
final mockEntity = MockAssetEntity();
|
|
||||||
final mockFile = File('/path/to/test.jpg');
|
|
||||||
|
|
||||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
|
||||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
|
||||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
|
||||||
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
|
|
||||||
|
|
||||||
final task = await sutWithV23.getUploadTask(assetWithCloudId);
|
|
||||||
|
|
||||||
expect(task, isNotNull);
|
|
||||||
expect(task!.fields.containsKey('metadata'), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should NOT include metadata on Android regardless of server version', () async {
|
test('should NOT include metadata on Android regardless of server version', () async {
|
||||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
|
||||||
final sutAndroid = UploadService(
|
final sutAndroid = BackgroundUploadService(
|
||||||
mockUploadRepository,
|
mockUploadRepository,
|
||||||
mockBackupRepository,
|
|
||||||
mockStorageRepository,
|
mockStorageRepository,
|
||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
|
mockBackupRepository,
|
||||||
mockAppSettingsService,
|
mockAppSettingsService,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
_serverInfo,
|
|
||||||
);
|
);
|
||||||
addTearDown(() => sutAndroid.dispose());
|
addTearDown(() => sutAndroid.dispose());
|
||||||
|
|
||||||
|
|
@ -334,14 +262,13 @@ void main() {
|
||||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
|
||||||
final sutWithV24 = UploadService(
|
final sutWithV24 = BackgroundUploadService(
|
||||||
mockUploadRepository,
|
mockUploadRepository,
|
||||||
mockBackupRepository,
|
|
||||||
mockStorageRepository,
|
mockStorageRepository,
|
||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
|
mockBackupRepository,
|
||||||
mockAppSettingsService,
|
mockAppSettingsService,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
_serverInfo,
|
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
|
|
||||||
|
|
@ -374,14 +301,13 @@ void main() {
|
||||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
|
||||||
final sutWithV24 = UploadService(
|
final sutWithV24 = BackgroundUploadService(
|
||||||
mockUploadRepository,
|
mockUploadRepository,
|
||||||
mockBackupRepository,
|
|
||||||
mockStorageRepository,
|
mockStorageRepository,
|
||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
|
mockBackupRepository,
|
||||||
mockAppSettingsService,
|
mockAppSettingsService,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
_serverInfo,
|
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
|
|
||||||
Loading…
Reference in a new issue