feat: share intent upload

This commit is contained in:
Alex
2026-01-12 14:57:57 -06:00
parent b7c4e12a27
commit 91a201da0c
4 changed files with 153 additions and 124 deletions

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

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

@@ -1,18 +1,12 @@
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:logging/logging.dart';
import 'package:path/path.dart';
import 'package:path/path.dart' as p;
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
((ref) => ShareIntentUploadStateNotifier(
@@ -28,10 +22,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
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._uploadService, 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 _uploadService.uploadFilesWithHttp(
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

@@ -379,6 +379,107 @@ class UploadService {
return _uploadRepository.start();
}
/// Upload multiple files using foreground HTTP with concurrent workers
/// This is used for share intent uploads
Future<void> uploadFilesWithHttp(
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;
}
const concurrentUploads = 3;
final httpClients = List.generate(concurrentUploads, (_) => Client());
final effectiveCancelToken = cancelToken ?? CancellationToken();
try {
int currentIndex = 0;
Future<void> worker(Client httpClient) async {
while (true) {
if (effectiveCancelToken.isCancelled) break;
final index = currentIndex;
if (index >= files.length) break;
currentIndex++;
final file = files[index];
final fileId = p.hash(file.path).toString();
final result = await _uploadSingleFileWithHttp(
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!);
}
}
}
final workerFutures = <Future<void>>[];
for (int i = 0; i < concurrentUploads; i++) {
workerFutures.add(worker(httpClients[i]));
}
await Future.wait(workerFutures);
} finally {
for (final client in httpClients) {
client.close();
}
}
}
/// Upload a single file using foreground HTTP upload
Future<UploadResult> _uploadSingleFileWithHttp(
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());
}
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
switch (update.status) {
case TaskStatus.complete: