mirror of
https://github.com/immich-app/immich.git
synced 2026-01-13 13:36:35 -08:00
Compare commits
28 Commits
use-font-m
...
bring-back
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cf240761a | ||
|
|
252bd775c8 | ||
|
|
e98332653a | ||
|
|
d2aae73f12 | ||
|
|
c06188920a | ||
|
|
c35a28c106 | ||
|
|
e37814dfad | ||
|
|
9b30ce684f | ||
|
|
fb66c5caa9 | ||
|
|
d35e73939f | ||
|
|
8b0249032c | ||
|
|
eff9b46b23 | ||
|
|
9b84783b54 | ||
|
|
91a201da0c | ||
|
|
b7c4e12a27 | ||
|
|
3cc51691cf | ||
|
|
343ae76274 | ||
|
|
6866a89278 | ||
|
|
dd215ec758 | ||
|
|
6ef7d2bb47 | ||
|
|
4570f421cc | ||
|
|
b3750cee32 | ||
|
|
a7cde63f1c | ||
|
|
999779ecf5 | ||
|
|
d10f1ae670 | ||
|
|
d93a2cadf1 | ||
|
|
7f016bfcfa | ||
|
|
21ced44159 |
@@ -1122,6 +1122,7 @@
|
||||
"unable_to_update_workflow": "Unable to update workflow",
|
||||
"unable_to_upload_file": "Unable to upload file"
|
||||
},
|
||||
"errors_text": "Errors",
|
||||
"exclusion_pattern": "Exclusion pattern",
|
||||
"exif": "Exif",
|
||||
"exif_bottom_sheet_description": "Add Description...",
|
||||
@@ -2236,7 +2237,6 @@
|
||||
"updated_at": "Updated",
|
||||
"updated_password": "Updated password",
|
||||
"upload": "Upload",
|
||||
"upload_action_prompt": "{count} queued for upload",
|
||||
"upload_concurrency": "Upload concurrency",
|
||||
"upload_details": "Upload Details",
|
||||
"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)!)
|
||||
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
|
||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
|
||||
ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl())
|
||||
}
|
||||
|
||||
public static func cancelPlugins(with engine: FlutterEngine) {
|
||||
|
||||
@@ -1,6 +1,60 @@
|
||||
import Network
|
||||
|
||||
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] {
|
||||
[]
|
||||
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/domain/services/log.service.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/infrastructure/repositories/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/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/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/auth.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/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||
@@ -243,13 +242,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
|
||||
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
|
||||
?.read(uploadServiceProvider)
|
||||
.startBackupWithHttpClient(currentUser.id, networkCapabilities.isUnmetered, _cancellationToken);
|
||||
?.read(foregroundUploadServiceProvider)
|
||||
.uploadCandidates(currentUser.id, _cancellationToken, useSequentialUpload: true);
|
||||
},
|
||||
(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';
|
||||
|
||||
class StorageRepository {
|
||||
const StorageRepository();
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
StorageRepository();
|
||||
|
||||
Future<File?> getFileForAsset(String assetId) async {
|
||||
File? file;
|
||||
@@ -82,6 +84,51 @@ class StorageRepository {
|
||||
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 {
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:path/path.dart';
|
||||
|
||||
enum ShareIntentAttachmentType { image, video }
|
||||
|
||||
enum UploadStatus { enqueued, running, complete, notFound, failed, canceled, waitingToRetry, paused }
|
||||
enum UploadStatus { enqueued, running, complete, failed }
|
||||
|
||||
class ShareIntentAttachment {
|
||||
final String path;
|
||||
|
||||
@@ -93,11 +93,11 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
Logger("DriftBackupPage").warning("Remote sync did not complete successfully, skipping backup");
|
||||
return;
|
||||
}
|
||||
await backupNotifier.startBackup(currentUser.id);
|
||||
await backupNotifier.startForegroundBackup(currentUser.id);
|
||||
}
|
||||
|
||||
Future<void> stopBackup() async {
|
||||
await backupNotifier.cancel();
|
||||
await backupNotifier.stopForegroundBackup();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
|
||||
@@ -113,10 +113,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
|
||||
if (isBackupEnabled) {
|
||||
unawaited(
|
||||
backupNotifier.cancel().whenComplete(
|
||||
backupNotifier.stopForegroundBackup().whenComplete(
|
||||
() => backgroundSync.syncRemote().then((success) {
|
||||
if (success) {
|
||||
return backupNotifier.startBackup(user.id);
|
||||
return backupNotifier.startForegroundBackup(user.id);
|
||||
} else {
|
||||
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
|
||||
}
|
||||
|
||||
@@ -60,10 +60,10 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
||||
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
||||
final backgroundSync = ref.read(backgroundSyncProvider);
|
||||
unawaited(
|
||||
backupNotifier.cancel().whenComplete(
|
||||
backupNotifier.stopForegroundBackup().whenComplete(
|
||||
() => backgroundSync.syncRemote().then((success) {
|
||||
if (success) {
|
||||
return backupNotifier.startBackup(currentUser.id);
|
||||
return backupNotifier.startForegroundBackup(currentUser.id);
|
||||
} else {
|
||||
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;
|
||||
|
||||
@RoutePage()
|
||||
class DriftUploadDetailPage extends ConsumerWidget {
|
||||
class DriftUploadDetailPage extends ConsumerStatefulWidget {
|
||||
const DriftUploadDetailPage({super.key});
|
||||
|
||||
@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 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(
|
||||
appBar: AppBar(
|
||||
@@ -25,98 +83,326 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
),
|
||||
body: uploadItems.isEmpty ? _buildEmptyState(context) : _buildUploadList(uploadItems),
|
||||
body: _buildTwoSectionLayout(context, uploadingItems, failedItems, iCloudProgress),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.cloud_off_rounded, size: 80, color: context.colorScheme.onSurface.withValues(alpha: 0.3)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"no_uploads_in_progress".t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.6)),
|
||||
Widget _buildTwoSectionLayout(
|
||||
BuildContext context,
|
||||
List<DriftUploadStatus> uploadingItems,
|
||||
List<DriftUploadStatus> failedItems,
|
||||
Map<String, double> iCloudProgress,
|
||||
) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// iCloud Downloads Section
|
||||
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) {
|
||||
return ListView.separated(
|
||||
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);
|
||||
Widget _buildICloudDownloadCard(BuildContext context, String assetId, double progress) {
|
||||
final double progressPercentage = (progress * 100).clamp(0, 100);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: item.isFailed != null ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainer,
|
||||
color: context.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
side: BorderSide(color: context.colorScheme.tertiary.withValues(alpha: 0.3), width: 1),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _showFileDetailDialog(context, item),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
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: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 4,
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
"Downloading from iCloud...",
|
||||
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
_buildProgressIndicator(
|
||||
context,
|
||||
item.progress,
|
||||
progressPercentage,
|
||||
isCompleted,
|
||||
item.networkSpeedAsString,
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
assetId,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
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 ?? "Upload failed"
|
||||
: "${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 ?? "Upload failed",
|
||||
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(
|
||||
BuildContext context,
|
||||
double progress,
|
||||
double percentage,
|
||||
bool isCompleted,
|
||||
String networkSpeedAsString,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: TweenAnimationBuilder(
|
||||
tween: Tween<double>(begin: 0.0, end: progress),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
builder: (context, value, _) => CircularProgressIndicator(
|
||||
backgroundColor: context.colorScheme.outline.withValues(alpha: 0.2),
|
||||
strokeWidth: 3,
|
||||
value: value,
|
||||
color: isCompleted ? context.colorScheme.primary : context.colorScheme.secondary,
|
||||
Widget _buildPlaceholderCard(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: context.colorScheme.surfaceContainerLow.withValues(alpha: 0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1, style: BorderStyle.solid),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
height: 64,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.outline.withValues(alpha: 0.1),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.hourglass_empty_rounded,
|
||||
size: 24,
|
||||
color: context.colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isCompleted)
|
||||
Icon(Icons.check_circle_rounded, size: 28, color: context.colorScheme.primary)
|
||||
else
|
||||
Text(
|
||||
percentage.toStringAsFixed(0),
|
||||
style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, fontSize: 10),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
networkSpeedAsString,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: Text(
|
||||
"0%",
|
||||
textAlign: TextAlign.right,
|
||||
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 {
|
||||
final DriftUploadStatus uploadStatus;
|
||||
|
||||
const FileDetailDialog({super.key, required this.uploadStatus});
|
||||
|
||||
@override
|
||||
@@ -212,14 +568,12 @@ class FileDetailDialog extends ConsumerWidget {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
final asset = snapshot.data;
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Thumbnail at the top center
|
||||
Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
@@ -237,7 +591,7 @@ class FileDetailDialog extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (asset != null) ...[
|
||||
if (asset != null)
|
||||
_buildInfoSection(context, [
|
||||
_buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)),
|
||||
_buildInfoRow(context, "local_id".t(context: context), asset.id),
|
||||
@@ -254,7 +608,6 @@ class FileDetailDialog extends ConsumerWidget {
|
||||
if (asset.checksum != null)
|
||||
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
|
||||
]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -282,7 +635,7 @@ class FileDetailDialog extends ConsumerWidget {
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
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(
|
||||
child: Text(
|
||||
value,
|
||||
style: context.textTheme.labelMedium?.copyWith(),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: Text(value, style: context.textTheme.labelMedium, maxLines: 3, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -317,8 +665,7 @@ class FileDetailDialog extends ConsumerWidget {
|
||||
|
||||
Future<LocalAsset?> _getAssetDetails(WidgetRef ref, String localAssetId) async {
|
||||
try {
|
||||
final repository = ref.read(localAssetRepository);
|
||||
return await repository.getById(localAssetId);
|
||||
return await ref.read(localAssetRepository).getById(localAssetId);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
if (isEnableBackup) {
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
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:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.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';
|
||||
|
||||
@RoutePage()
|
||||
class ShareIntentPage extends HookConsumerWidget {
|
||||
class ShareIntentPage extends ConsumerWidget {
|
||||
const ShareIntentPage({super.key, required this.attachments});
|
||||
|
||||
final List<ShareIntentAttachment> attachments;
|
||||
@@ -21,12 +20,13 @@ class ShareIntentPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentEndpoint = getServerUrl() ?? '--';
|
||||
final candidates = ref.watch(shareIntentUploadProvider);
|
||||
final isUploaded = useState(false);
|
||||
useOnAppLifecycleStateChange((previous, current) {
|
||||
if (current == AppLifecycleState.resumed) {
|
||||
isUploaded.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
final isUploading = candidates.any((candidate) => candidate.status == UploadStatus.running);
|
||||
final isUploaded =
|
||||
candidates.isNotEmpty &&
|
||||
candidates.every(
|
||||
(candidate) => candidate.status == UploadStatus.complete || candidate.status == UploadStatus.failed,
|
||||
);
|
||||
|
||||
void removeAttachment(ShareIntentAttachment attachment) {
|
||||
ref.read(shareIntentUploadProvider.notifier).removeAttachment(attachment);
|
||||
@@ -37,11 +37,8 @@ class ShareIntentPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void upload() async {
|
||||
for (final attachment in candidates) {
|
||||
await ref.read(shareIntentUploadProvider.notifier).upload(attachment.file);
|
||||
}
|
||||
|
||||
isUploaded.value = true;
|
||||
final files = candidates.map((candidate) => candidate.file).toList();
|
||||
await ref.read(shareIntentUploadProvider.notifier).uploadAll(files);
|
||||
}
|
||||
|
||||
bool isSelected(ShareIntentAttachment attachment) {
|
||||
@@ -84,7 +81,7 @@ class ShareIntentPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16),
|
||||
child: LargeLeadingTile(
|
||||
onTap: () => toggleSelection(attachment),
|
||||
disabled: isUploaded.value,
|
||||
disabled: isUploading || isUploaded,
|
||||
selected: isSelected(attachment),
|
||||
leading: Stack(
|
||||
children: [
|
||||
@@ -131,8 +128,8 @@ class ShareIntentPage extends HookConsumerWidget {
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: isUploaded.value ? null : upload,
|
||||
child: isUploaded.value ? UploadingText(candidates: candidates) : const Text('upload').tr(),
|
||||
onPressed: (isUploading || isUploaded) ? null : upload,
|
||||
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.notFound ||
|
||||
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;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.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/repositories/file_media.repository.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:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -78,7 +79,7 @@ class DriftEditImagePage extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(uploadServiceProvider).manualBackup([localAsset]);
|
||||
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken());
|
||||
} catch (e) {
|
||||
ImmichToast.show(
|
||||
durationInSecond: 6,
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/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/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class UploadActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
@@ -20,19 +25,38 @@ class UploadActionButton extends ConsumerWidget {
|
||||
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(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
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 {
|
||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
@@ -57,17 +56,13 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
|
||||
@override
|
||||
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 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(
|
||||
animation: _animationController,
|
||||
@@ -115,7 +110,7 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
|
||||
onTap: () => isCanceling ? null : _onToggle(!_isEnabled),
|
||||
onTap: () => _onToggle(!_isEnabled),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
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/presentation/widgets/images/thumbnail.widget.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/timeline/multiselect.provider.dart';
|
||||
|
||||
@@ -45,6 +46,10 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
final bool storageIndicator =
|
||||
ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && showStorageIndicator;
|
||||
|
||||
final uploadProgress = asset is LocalAsset
|
||||
? ref.watch(assetUploadProgressProvider.select((map) => map[asset.id]))
|
||||
: null;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Container(color: lockSelection ? context.colorScheme.surfaceContainerHighest : assetContainerColor),
|
||||
@@ -104,6 +109,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
child: _TileOverlayIcon(Icons.favorite_rounded),
|
||||
),
|
||||
),
|
||||
if (uploadProgress != null) _UploadProgressOverlay(progress: uploadProgress),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -229,3 +235,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
if (currentUser != null) {
|
||||
await _safeRun(
|
||||
_ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id),
|
||||
_ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id),
|
||||
"handleBackupResume",
|
||||
);
|
||||
}
|
||||
@@ -237,6 +237,8 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) {
|
||||
_ref.read(backupProvider.notifier).cancelBackup();
|
||||
}
|
||||
} else {
|
||||
await _ref.read(driftBackupProvider.notifier).stopForegroundBackup();
|
||||
}
|
||||
|
||||
_ref.read(websocketProvider.notifier).disconnect();
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||
((ref) => ShareIntentUploadStateNotifier(
|
||||
ref.watch(appRouterProvider),
|
||||
ref.watch(uploadServiceProvider),
|
||||
ref.watch(foregroundUploadServiceProvider),
|
||||
ref.watch(shareIntentServiceProvider),
|
||||
)),
|
||||
);
|
||||
|
||||
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
|
||||
final AppRouter router;
|
||||
final UploadService _uploadService;
|
||||
final ForegroundUploadService _foregroundUploadService;
|
||||
final ShareIntentService _shareIntentService;
|
||||
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
||||
|
||||
ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) {
|
||||
_uploadService.taskStatusStream.listen(_updateUploadStatus);
|
||||
_uploadService.taskProgressStream.listen(_taskProgressCallback);
|
||||
}
|
||||
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
|
||||
|
||||
void init() {
|
||||
_shareIntentService.onSharedMedia = onSharedMedia;
|
||||
@@ -67,97 +58,44 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
||||
state = [];
|
||||
}
|
||||
|
||||
void _updateUploadStatus(TaskStatusUpdate task) async {
|
||||
if (task.status == TaskStatus.canceled) {
|
||||
return;
|
||||
Future<void> uploadAll(List<File> files) async {
|
||||
for (final file in files) {
|
||||
final fileId = p.hash(file.path).toString();
|
||||
_updateStatus(fileId, UploadStatus.running);
|
||||
}
|
||||
|
||||
final taskId = task.task.taskId;
|
||||
final uploadStatus = switch (task.status) {
|
||||
TaskStatus.complete => UploadStatus.complete,
|
||||
TaskStatus.failed => UploadStatus.failed,
|
||||
TaskStatus.canceled => UploadStatus.canceled,
|
||||
TaskStatus.enqueued => UploadStatus.enqueued,
|
||||
TaskStatus.running => UploadStatus.running,
|
||||
TaskStatus.paused => UploadStatus.paused,
|
||||
TaskStatus.notFound => UploadStatus.notFound,
|
||||
TaskStatus.waitingToRetry => UploadStatus.waitingToRetry,
|
||||
};
|
||||
|
||||
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,
|
||||
await _foregroundUploadService.uploadShareIntent(
|
||||
files,
|
||||
onProgress: (fileId, bytes, totalBytes) {
|
||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||
_updateProgress(fileId, progress);
|
||||
},
|
||||
onSuccess: (fileId) {
|
||||
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
||||
},
|
||||
onError: (fileId, errorMessage) {
|
||||
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
|
||||
_updateStatus(fileId, UploadStatus.failed);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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/services/api.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/upload.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/widget.service.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -24,7 +25,8 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||
ref.watch(authServiceProvider),
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(uploadServiceProvider),
|
||||
ref.watch(backgroundUploadServiceProvider),
|
||||
ref.watch(foregroundUploadServiceProvider),
|
||||
ref.watch(secureStorageServiceProvider),
|
||||
ref.watch(widgetServiceProvider),
|
||||
);
|
||||
@@ -34,7 +36,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
final AuthService _authService;
|
||||
final ApiService _apiService;
|
||||
final UserService _userService;
|
||||
final UploadService _uploadService;
|
||||
final BackgroundUploadService _backgroundUploadService;
|
||||
final ForegroundUploadService _foregroundUploadService;
|
||||
final SecureStorageService _secureStorageService;
|
||||
final WidgetService _widgetService;
|
||||
final _log = Logger("AuthenticationNotifier");
|
||||
@@ -45,7 +48,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
this._authService,
|
||||
this._apiService,
|
||||
this._userService,
|
||||
this._uploadService,
|
||||
this._backgroundUploadService,
|
||||
this._foregroundUploadService,
|
||||
this._secureStorageService,
|
||||
this._widgetService,
|
||||
) : super(
|
||||
@@ -87,7 +91,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
await _widgetService.clearCredentials();
|
||||
|
||||
await _authService.logout();
|
||||
await _uploadService.cancelBackup();
|
||||
await _backgroundUploadService.cancel();
|
||||
_foregroundUploadService.cancel();
|
||||
} finally {
|
||||
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 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
|
||||
class EnqueueStatus {
|
||||
final int enqueueCount;
|
||||
@@ -106,26 +105,24 @@ class DriftBackupState {
|
||||
final int remainderCount;
|
||||
final int processingCount;
|
||||
|
||||
final int enqueueCount;
|
||||
final int enqueueTotalCount;
|
||||
|
||||
final bool isSyncing;
|
||||
final bool isCanceling;
|
||||
final BackupError error;
|
||||
|
||||
final Map<String, DriftUploadStatus> uploadItems;
|
||||
final CancellationToken? cancelToken;
|
||||
|
||||
final Map<String, double> iCloudDownloadProgress;
|
||||
|
||||
const DriftBackupState({
|
||||
required this.totalCount,
|
||||
required this.backupCount,
|
||||
required this.remainderCount,
|
||||
required this.processingCount,
|
||||
required this.enqueueCount,
|
||||
required this.enqueueTotalCount,
|
||||
required this.isCanceling,
|
||||
required this.isSyncing,
|
||||
required this.uploadItems,
|
||||
this.error = BackupError.none,
|
||||
required this.uploadItems,
|
||||
this.cancelToken,
|
||||
this.iCloudDownloadProgress = const {},
|
||||
});
|
||||
|
||||
DriftBackupState copyWith({
|
||||
@@ -133,30 +130,28 @@ class DriftBackupState {
|
||||
int? backupCount,
|
||||
int? remainderCount,
|
||||
int? processingCount,
|
||||
int? enqueueCount,
|
||||
int? enqueueTotalCount,
|
||||
bool? isCanceling,
|
||||
bool? isSyncing,
|
||||
Map<String, DriftUploadStatus>? uploadItems,
|
||||
BackupError? error,
|
||||
Map<String, DriftUploadStatus>? uploadItems,
|
||||
CancellationToken? cancelToken,
|
||||
Map<String, double>? iCloudDownloadProgress,
|
||||
}) {
|
||||
return DriftBackupState(
|
||||
totalCount: totalCount ?? this.totalCount,
|
||||
backupCount: backupCount ?? this.backupCount,
|
||||
remainderCount: remainderCount ?? this.remainderCount,
|
||||
processingCount: processingCount ?? this.processingCount,
|
||||
enqueueCount: enqueueCount ?? this.enqueueCount,
|
||||
enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount,
|
||||
isCanceling: isCanceling ?? this.isCanceling,
|
||||
isSyncing: isSyncing ?? this.isSyncing,
|
||||
uploadItems: uploadItems ?? this.uploadItems,
|
||||
error: error ?? this.error,
|
||||
uploadItems: uploadItems ?? this.uploadItems,
|
||||
cancelToken: cancelToken ?? this.cancelToken,
|
||||
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
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
|
||||
@@ -168,12 +163,11 @@ class DriftBackupState {
|
||||
other.backupCount == backupCount &&
|
||||
other.remainderCount == remainderCount &&
|
||||
other.processingCount == processingCount &&
|
||||
other.enqueueCount == enqueueCount &&
|
||||
other.enqueueTotalCount == enqueueTotalCount &&
|
||||
other.isCanceling == isCanceling &&
|
||||
other.isSyncing == isSyncing &&
|
||||
other.error == error &&
|
||||
mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) &&
|
||||
mapEquals(other.uploadItems, uploadItems) &&
|
||||
other.error == error;
|
||||
other.cancelToken == cancelToken;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -182,45 +176,37 @@ class DriftBackupState {
|
||||
backupCount.hashCode ^
|
||||
remainderCount.hashCode ^
|
||||
processingCount.hashCode ^
|
||||
enqueueCount.hashCode ^
|
||||
enqueueTotalCount.hashCode ^
|
||||
isCanceling.hashCode ^
|
||||
isSyncing.hashCode ^
|
||||
error.hashCode ^
|
||||
uploadItems.hashCode ^
|
||||
error.hashCode;
|
||||
cancelToken.hashCode ^
|
||||
iCloudDownloadProgress.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
final driftBackupProvider = StateNotifierProvider<DriftBackupNotifier, DriftBackupState>((ref) {
|
||||
return DriftBackupNotifier(ref.watch(uploadServiceProvider));
|
||||
return DriftBackupNotifier(ref.watch(foregroundUploadServiceProvider), ref.watch(backgroundUploadServiceProvider));
|
||||
});
|
||||
|
||||
class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
DriftBackupNotifier(this._uploadService)
|
||||
DriftBackupNotifier(this._foregroundUploadService, this._backgroundUploadService)
|
||||
: super(
|
||||
const DriftBackupState(
|
||||
totalCount: 0,
|
||||
backupCount: 0,
|
||||
remainderCount: 0,
|
||||
processingCount: 0,
|
||||
enqueueCount: 0,
|
||||
enqueueTotalCount: 0,
|
||||
isCanceling: false,
|
||||
isSyncing: false,
|
||||
uploadItems: {},
|
||||
error: BackupError.none,
|
||||
),
|
||||
) {
|
||||
{
|
||||
_statusSubscription = _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
|
||||
_progressSubscription = _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
final ForegroundUploadService _foregroundUploadService;
|
||||
final BackgroundUploadService _backgroundUploadService;
|
||||
|
||||
final UploadService _uploadService;
|
||||
StreamSubscription<TaskStatusUpdate>? _statusSubscription;
|
||||
StreamSubscription<TaskProgressUpdate>? _progressSubscription;
|
||||
final _logger = Logger("DriftBackupNotifier");
|
||||
final _uploadSpeedManager = UploadSpeedManager();
|
||||
|
||||
/// Remove upload item from state
|
||||
void _removeUploadItem(String taskId) {
|
||||
@@ -235,120 +221,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 {
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip getBackupStatus (pre-call): notifier disposed");
|
||||
return;
|
||||
}
|
||||
final counts = await _uploadService.getBackupCounts(userId);
|
||||
final counts = await _foregroundUploadService.getBackupCounts(userId);
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip getBackupStatus (post-call): notifier disposed");
|
||||
return;
|
||||
@@ -374,47 +252,126 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
state = state.copyWith(isSyncing: isSyncing);
|
||||
}
|
||||
|
||||
Future<void> startBackup(String userId) {
|
||||
Future<void> startForegroundBackup(String userId) async {
|
||||
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) {
|
||||
state = state.copyWith(enqueueCount: status.enqueueCount, enqueueTotalCount: status.totalCount);
|
||||
Future<void> stopForegroundBackup() async {
|
||||
state.cancelToken?.cancel();
|
||||
_uploadSpeedManager.clear();
|
||||
state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {});
|
||||
}
|
||||
|
||||
Future<void> cancel() async {
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip cancel (pre-call): notifier disposed");
|
||||
return;
|
||||
void _handleICloudProgress(String localAssetId, double progress) {
|
||||
state = state.copyWith(iCloudDownloadProgress: {...state.iCloudDownloadProgress, localAssetId: progress});
|
||||
|
||||
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();
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip cancel (post-call): notifier disposed");
|
||||
void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) {
|
||||
if (state.cancelToken == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTaskCount > 0) {
|
||||
dPrint(() => "$activeTaskCount tasks left, continuing to cancel...");
|
||||
await cancel();
|
||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||
final networkSpeedAsString = _uploadSpeedManager.updateProgress(localAssetId, bytes, totalBytes);
|
||||
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 {
|
||||
dPrint(() => "All tasks canceled successfully.");
|
||||
// Clear all upload items when cancellation is complete
|
||||
state = state.copyWith(isCanceling: false, uploadItems: {});
|
||||
state = state.copyWith(
|
||||
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) {
|
||||
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
|
||||
return;
|
||||
}
|
||||
_logger.info("Resuming backup tasks...");
|
||||
state = state.copyWith(error: BackupError.none);
|
||||
final tasks = await _uploadService.getActiveTasks(kBackupGroup);
|
||||
final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup);
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
|
||||
return;
|
||||
@@ -422,20 +379,12 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
_logger.info("Found ${tasks.length} tasks");
|
||||
|
||||
if (tasks.isEmpty) {
|
||||
// Start a new backup queue
|
||||
_logger.info("Start a new backup queue");
|
||||
return startBackup(userId);
|
||||
_logger.info("Start backup with URLSession");
|
||||
return _backgroundUploadService.uploadBackupCandidates(userId);
|
||||
}
|
||||
|
||||
_logger.info("Tasks to resume: ${tasks.length}");
|
||||
return _uploadService.resumeBackup();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_statusSubscription?.cancel();
|
||||
_progressSubscription?.cancel();
|
||||
super.dispose();
|
||||
return _backgroundUploadService.resume();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,7 +394,7 @@ final driftBackupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>
|
||||
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>((
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/enums.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/user.provider.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/download.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:logging/logging.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
@@ -40,7 +42,7 @@ class ActionResult {
|
||||
class ActionNotifier extends Notifier<void> {
|
||||
final Logger _logger = Logger('ActionNotifier');
|
||||
late ActionService _service;
|
||||
late UploadService _uploadService;
|
||||
late ForegroundUploadService _foregroundUploadService;
|
||||
late DownloadService _downloadService;
|
||||
late AssetService _assetService;
|
||||
|
||||
@@ -48,7 +50,7 @@ class ActionNotifier extends Notifier<void> {
|
||||
|
||||
@override
|
||||
void build() {
|
||||
_uploadService = ref.watch(uploadServiceProvider);
|
||||
_foregroundUploadService = ref.watch(foregroundUploadServiceProvider);
|
||||
_service = ref.watch(actionServiceProvider);
|
||||
_assetService = ref.watch(assetServiceProvider);
|
||||
_downloadService = ref.watch(downloadServiceProvider);
|
||||
@@ -411,14 +413,44 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> upload(ActionSource source) async {
|
||||
final assets = _getAssets(source).whereType<LocalAsset>().toList();
|
||||
Future<ActionResult> upload(ActionSource source, {List<LocalAsset>? assets}) async {
|
||||
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 {
|
||||
await _uploadService.manualBackup(assets);
|
||||
return ActionResult(count: assets.length, success: true);
|
||||
await _foregroundUploadService.uploadManual(
|
||||
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) {
|
||||
_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: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:io';
|
||||
|
||||
@@ -20,6 +21,7 @@ class UploadTaskWithFile {
|
||||
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
|
||||
|
||||
class UploadRepository {
|
||||
final Logger logger = Logger('UploadRepository');
|
||||
void Function(TaskStatusUpdate)? onUploadStatus;
|
||||
void Function(TaskProgressUpdate)? onTaskProgress;
|
||||
|
||||
@@ -92,52 +94,114 @@ class UploadRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> backupWithDartClient(Iterable<UploadTaskWithFile> tasks, CancellationToken cancelToken) async {
|
||||
final httpClient = Client();
|
||||
Future<UploadResult> uploadFile({
|
||||
required File file,
|
||||
required String originalFileName,
|
||||
required Map<String, String> headers,
|
||||
required Map<String, String> fields,
|
||||
required Client httpClient,
|
||||
required CancellationToken cancelToken,
|
||||
required void Function(int bytes, int totalBytes) onProgress,
|
||||
required String logContext,
|
||||
}) async {
|
||||
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
|
||||
Logger logger = Logger('UploadRepository');
|
||||
for (final candidate in tasks) {
|
||||
if (cancelToken.isCancelled) {
|
||||
logger.warning("Backup was cancelled by the user");
|
||||
break;
|
||||
try {
|
||||
final fileStream = file.openRead();
|
||||
final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName);
|
||||
|
||||
final baseRequest = _CustomMultipartRequest('POST', Uri.parse('$savedEndpoint/assets'), onProgress: onProgress);
|
||||
|
||||
baseRequest.headers.addAll(headers);
|
||||
baseRequest.fields.addAll(fields);
|
||||
baseRequest.files.add(assetRawUploadData);
|
||||
|
||||
final response = await httpClient.send(baseRequest, cancellationToken: cancelToken);
|
||||
final 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 {
|
||||
final fileStream = candidate.file.openRead();
|
||||
final assetRawUploadData = MultipartFile(
|
||||
"assetData",
|
||||
fileStream,
|
||||
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;
|
||||
final responseBody = jsonDecode(responseBodyString);
|
||||
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
|
||||
} catch (e) {
|
||||
return UploadResult.error(errorMessage: 'Failed to parse server response');
|
||||
}
|
||||
} 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 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
@@ -15,7 +14,6 @@ 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/storage.repository.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/storage.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
@@ -26,12 +24,12 @@ import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final uploadServiceProvider = Provider((ref) {
|
||||
final service = UploadService(
|
||||
final backgroundUploadServiceProvider = Provider((ref) {
|
||||
final service = BackgroundUploadService(
|
||||
ref.watch(uploadRepositoryProvider),
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(storageRepositoryProvider),
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
);
|
||||
@@ -40,12 +38,70 @@ final uploadServiceProvider = Provider((ref) {
|
||||
return service;
|
||||
});
|
||||
|
||||
class UploadService {
|
||||
UploadService(
|
||||
/// Metadata for upload tasks to track live photo handling
|
||||
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._backupRepository,
|
||||
this._storageRepository,
|
||||
this._localAssetRepository,
|
||||
this._backupRepository,
|
||||
this._appSettingsService,
|
||||
this._assetMediaRepository,
|
||||
) {
|
||||
@@ -54,12 +110,12 @@ class UploadService {
|
||||
}
|
||||
|
||||
final UploadRepository _uploadRepository;
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final StorageRepository _storageRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final AppSettingsService _appSettingsService;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final Logger _logger = Logger('UploadService');
|
||||
final Logger _logger = Logger('BackgroundUploadService');
|
||||
|
||||
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
|
||||
final StreamController<TaskProgressUpdate> _taskProgressController = StreamController<TaskProgressUpdate>.broadcast();
|
||||
@@ -87,116 +143,49 @@ class UploadService {
|
||||
_taskProgressController.close();
|
||||
}
|
||||
|
||||
/// Enqueue tasks to the background upload queue
|
||||
Future<List<bool>> enqueueTasks(List<UploadTask> tasks) {
|
||||
return _uploadRepository.enqueueBackgroundAll(tasks);
|
||||
}
|
||||
|
||||
/// Get a list of tasks that are ENQUEUED or RUNNING
|
||||
Future<List<Task>> getActiveTasks(String group) {
|
||||
return _uploadRepository.getActiveTasks(group);
|
||||
}
|
||||
|
||||
Future<({int total, int remainder, int processing})> getBackupCounts(String userId) {
|
||||
return _backupRepository.getAllCounts(userId);
|
||||
}
|
||||
|
||||
Future<void> manualBackup(List<LocalAsset> localAssets) async {
|
||||
/// Start background upload using iOS URLSession
|
||||
///
|
||||
/// Finds backup candidates, builds upload tasks, and enqueues them
|
||||
/// for background processing.
|
||||
Future<void> uploadBackupCandidates(String userId) async {
|
||||
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 = [];
|
||||
for (final asset in localAssets) {
|
||||
final task = await getUploadTask(
|
||||
asset,
|
||||
group: kManualUploadGroup,
|
||||
priority: 1, // High priority after upload motion photo part
|
||||
);
|
||||
|
||||
for (final asset in batch) {
|
||||
final task = await getUploadTask(asset);
|
||||
if (task != null) {
|
||||
tasks.add(task);
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.isNotEmpty) {
|
||||
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
|
||||
await enqueueTasks(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find backup candidates
|
||||
/// 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
|
||||
/// Cancel all ongoing background uploads and reset the upload queue
|
||||
///
|
||||
/// Return the number of left over tasks in the queue
|
||||
Future<int> cancelBackup() async {
|
||||
/// Returns the number of tasks left in the queue
|
||||
Future<int> cancel() async {
|
||||
shouldAbortQueuingTasks = true;
|
||||
|
||||
await _storageRepository.clearCache();
|
||||
@@ -207,7 +196,8 @@ class UploadService {
|
||||
return activeTasks.length;
|
||||
}
|
||||
|
||||
Future<void> resumeBackup() {
|
||||
/// Resume background backup processing
|
||||
Future<void> resume() {
|
||||
return _uploadRepository.start();
|
||||
}
|
||||
|
||||
@@ -265,42 +255,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
|
||||
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
@@ -447,56 +401,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;
|
||||
}
|
||||
446
mobile/lib/services/foreground_upload.service.dart
Normal file
446
mobile/lib/services/foreground_upload.service.dart
Normal file
@@ -0,0 +1,446 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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(),
|
||||
};
|
||||
|
||||
// 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/platform/native_sync_api.g.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';
|
||||
|
||||
class MockStoreService extends Mock implements StoreService {}
|
||||
@@ -16,5 +16,5 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||
|
||||
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 MockNetworkService networkService;
|
||||
late MockBackgroundSyncManager backgroundSyncManager;
|
||||
late MockUploadService uploadService;
|
||||
late MockAppSettingService appSettingsService;
|
||||
late Isar db;
|
||||
|
||||
@@ -31,7 +30,6 @@ void main() {
|
||||
apiService = MockApiService();
|
||||
networkService = MockNetworkService();
|
||||
backgroundSyncManager = MockBackgroundSyncManager();
|
||||
uploadService = MockUploadService();
|
||||
appSettingsService = MockAppSettingService();
|
||||
|
||||
sut = AuthService(
|
||||
@@ -118,7 +116,6 @@ void main() {
|
||||
when(() => authApiRepository.logout()).thenAnswer((_) async => {});
|
||||
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
|
||||
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
|
||||
when(() => uploadService.cancelBackup()).thenAnswer((_) => Future.value(1));
|
||||
when(
|
||||
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
|
||||
).thenAnswer((_) => Future.value(null));
|
||||
@@ -133,7 +130,6 @@ void main() {
|
||||
when(() => authApiRepository.logout()).thenThrow(Exception('Server error'));
|
||||
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
|
||||
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
|
||||
when(() => uploadService.cancelBackup()).thenAnswer((_) => Future.value(1));
|
||||
when(
|
||||
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
|
||||
).thenAnswer((_) => Future.value(null));
|
||||
|
||||
@@ -10,7 +10,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.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 '../domain/service.mock.dart';
|
||||
@@ -20,11 +20,11 @@ import '../repository.mocks.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
|
||||
void main() {
|
||||
late UploadService sut;
|
||||
late BackgroundUploadService sut;
|
||||
late MockUploadRepository mockUploadRepository;
|
||||
late MockDriftBackupRepository mockBackupRepository;
|
||||
late MockStorageRepository mockStorageRepository;
|
||||
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late MockDriftBackupRepository mockBackupRepository;
|
||||
late MockAppSettingsService mockAppSettingsService;
|
||||
late MockAssetMediaRepository mockAssetMediaRepository;
|
||||
late Drift db;
|
||||
@@ -46,20 +46,20 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
mockUploadRepository = MockUploadRepository();
|
||||
mockBackupRepository = MockDriftBackupRepository();
|
||||
mockStorageRepository = MockStorageRepository();
|
||||
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
||||
mockBackupRepository = MockDriftBackupRepository();
|
||||
mockAppSettingsService = MockAppSettingsService();
|
||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||
|
||||
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)).thenReturn(false);
|
||||
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)).thenReturn(false);
|
||||
|
||||
sut = UploadService(
|
||||
sut = BackgroundUploadService(
|
||||
mockUploadRepository,
|
||||
mockBackupRepository,
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
Reference in New Issue
Block a user