Compare commits

...

28 Commits

Author SHA1 Message Date
Alex
3cf240761a Merge branch 'main' of github.com:immich-app/immich into bring-back-manual-backup 2026-01-13 10:00:47 -06:00
Alex
252bd775c8 more refactor 2026-01-13 09:36:36 -06:00
Alex
e98332653a remove empty state and prevent slot jumps 2026-01-13 00:02:41 -06:00
Alex
d2aae73f12 remove complete section 2026-01-12 23:41:00 -06:00
Alex
c06188920a feat: add error section 2026-01-12 23:29:55 -06:00
Alex
c35a28c106 fix: background android backup 2026-01-12 23:04:21 -06:00
Alex Tran
e37814dfad remove unused logs 2026-01-12 22:17:22 -06:00
Alex Tran
9b30ce684f refactor 2026-01-12 21:40:49 -06:00
Alex Tran
fb66c5caa9 refactor 2026-01-12 20:26:55 -06:00
Alex
d35e73939f chore: styling 2026-01-12 16:32:28 -06:00
Alex
8b0249032c Merge branch 'main' of github.com:immich-app/immich into bring-back-manual-backup 2026-01-12 16:26:03 -06:00
Alex
eff9b46b23 feat: manual upload progress 2026-01-12 16:25:51 -06:00
Alex
9b84783b54 feat: manual upload 2026-01-12 15:06:35 -06:00
Alex
91a201da0c feat: share intent upload 2026-01-12 14:57:57 -06:00
Alex
b7c4e12a27 pr feedback 2026-01-12 13:44:13 -06:00
Alex
3cc51691cf handle error 2026-01-08 16:15:42 -06:00
Alex
343ae76274 handle error 2026-01-08 15:18:22 -06:00
Alex
6866a89278 better upload detail page 2026-01-08 14:47:22 -06:00
Alex
dd215ec758 wip 2026-01-08 11:21:47 -06:00
Alex
6ef7d2bb47 feat: speed calculation 2026-01-06 20:49:25 -06:00
Alex
4570f421cc handle LivePhotos progress 2026-01-06 20:37:20 -06:00
Alex
b3750cee32 unify http upload method, check for connectivity on iOS 2026-01-06 16:43:13 -06:00
Alex
a7cde63f1c Merge branch 'main' of github.com:immich-app/immich into bring-back-manual-backup 2025-12-28 17:28:41 -06:00
Alex
999779ecf5 wip 2025-12-27 16:50:25 -06:00
Alex
d10f1ae670 Merge branch 'main' of github.com:immich-app/immich into bring-back-manual-backup 2025-12-25 14:48:56 -06:00
Alex
d93a2cadf1 Merge branch 'main' of github.com:immich-app/immich into bring-back-manual-backup 2025-11-05 20:24:25 -06:00
Alex
7f016bfcfa expose iCloud retrieval progress 2025-10-23 11:04:56 -05:00
Alex Tran
21ced44159 feat: bring back manual backup 2025-10-23 09:31:07 -05:00
31 changed files with 1829 additions and 761 deletions

View File

@@ -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?",

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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");

View File

@@ -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');

View File

@@ -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;

View File

@@ -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(

View File

@@ -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');
}

View File

@@ -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');
}

View File

@@ -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;
}

View File

@@ -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));
}
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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),
),
],
);
}
}

View File

@@ -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;
}

View File

@@ -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)),
],
),
),

View File

@@ -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),
),
],
),
),
),
);
}
}

View File

@@ -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();

View File

@@ -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,
];
}
}

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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>((

View File

@@ -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();
});
}
}
}

View File

@@ -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());

View File

@@ -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);
}
}

View File

@@ -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;
}

View 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;
}
}

View 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();
}
}

View File

@@ -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 {}

View File

@@ -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));

View File

@@ -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,
);