From 9bdd08d8dd1bb6b488600a8bc762629ee7a03d70 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 21 Sep 2025 18:46:48 +0800 Subject: [PATCH] :sparkles: New protocol to upload file --- lib/services/file.dart | 103 ++++++++++++++++----- lib/services/file_uploader.dart | 155 ++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 lib/services/file_uploader.dart diff --git a/lib/services/file.dart b/lib/services/file.dart index 3e441a9c..4971a987 100644 --- a/lib/services/file.dart +++ b/lib/services/file.dart @@ -1,15 +1,15 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'dart:ui'; - import 'package:croppy/croppy.dart'; import 'package:cross_file/cross_file.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:island/models/file.dart'; +import 'package:island/services/file_uploader.dart'; import 'package:native_exif/native_exif.dart'; -import 'package:tus_client_dart/tus_client_dart.dart'; +import 'package:path_provider/path_provider.dart'; Future cropImage( BuildContext context, { @@ -44,6 +44,7 @@ Completer putMediaToCloud({ required UniversalFile fileData, required String atk, required String baseUrl, + String? poolId, String? filename, String? mimetype, Function(double progress, Duration estimate)? onProgress, @@ -85,6 +86,7 @@ Completer putMediaToCloud({ fileData, atk, baseUrl, + poolId, filename, mimetype, onProgress, @@ -98,6 +100,7 @@ Completer putMediaToCloud({ fileData, atk, baseUrl, + poolId, filename, mimetype, onProgress, @@ -114,6 +117,7 @@ Completer putMediaToCloud({ fileData, atk, baseUrl, + poolId, filename, mimetype, onProgress, @@ -127,6 +131,7 @@ Completer _processUpload( UniversalFile fileData, String atk, String baseUrl, + String? poolId, String? filename, String? mimetype, Function(double progress, Duration estimate)? onProgress, @@ -168,26 +173,80 @@ Completer _processUpload( return completer; } - final Map metadata = { - 'filename': actualFilename, - 'content-type': actualMimetype, - }; + // Create Dio instance + final dio = Dio( + BaseOptions( + baseUrl: baseUrl, + headers: { + 'Authorization': 'AtField $atk', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + ), + ); - final client = TusClient(file); - client - .upload( - uri: Uri.parse('$baseUrl/drive/tus'), - headers: {'Authorization': 'AtField $atk'}, - metadata: metadata, - onComplete: (lastResponse) { - final resp = jsonDecode(lastResponse!.headers['x-fileinfo']!); - completer.complete(SnCloudFile.fromJson(resp)); - }, - onProgress: (double progress, Duration estimate) { - onProgress?.call(progress, estimate); - }, - ) - .catchError(completer.completeError); + final uploader = FileUploader(dio); + + // Get File object + File fileObj; + if (file.path.isNotEmpty) { + fileObj = File(file.path); + // Call progress start + onProgress?.call(0.0, Duration.zero); + uploader + .uploadFile( + file: fileObj, + fileName: actualFilename, + contentType: actualMimetype, + poolId: poolId, + ) + .then((result) { + // Call progress end + onProgress?.call(1.0, Duration.zero); + completer.complete(result); + }) + .catchError((e) { + completer.completeError(e); + throw e; + }); + } else { + // Write to temp file + getTemporaryDirectory() + .then((tempDir) { + final tempFile = File('${tempDir.path}/temp_upload_$actualFilename'); + tempFile + .writeAsBytes(byteData!) + .then((_) { + fileObj = tempFile; + // Call progress start + onProgress?.call(0.0, Duration.zero); + uploader + .uploadFile( + file: fileObj, + fileName: actualFilename, + contentType: actualMimetype, + poolId: poolId, + ) + .then((result) { + // Call progress end + onProgress?.call(1.0, Duration.zero); + completer.complete(result); + }) + .catchError((e) { + completer.completeError(e); + throw e; + }); + }) + .catchError((e) { + completer.completeError(e); + throw e; + }); + }) + .catchError((e) { + completer.completeError(e); + throw e; + }); + } return completer; } diff --git a/lib/services/file_uploader.dart b/lib/services/file_uploader.dart new file mode 100644 index 00000000..4da0e168 --- /dev/null +++ b/lib/services/file_uploader.dart @@ -0,0 +1,155 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:island/models/file.dart'; +import 'package:island/pods/network.dart'; + +class FileUploader { + final Dio _dio; + + FileUploader(this._dio); + + /// Calculates the MD5 hash of a file. + Future _calculateFileHash(File file) async { + final bytes = await file.readAsBytes(); + final digest = md5.convert(bytes); + return digest.toString(); + } + + /// Creates an upload task for the given file. + Future> createUploadTask({ + required File file, + required String fileName, + required String contentType, + String? poolId, + String? bundleId, + String? encryptPassword, + String? expiredAt, + int? chunkSize, + }) async { + final hash = await _calculateFileHash(file); + final fileSize = await file.length(); + + final response = await _dio.post( + '/drive/files/upload/create', + data: { + 'hash': hash, + 'file_name': fileName, + 'file_size': fileSize, + 'content_type': contentType, + 'pool_id': poolId, + 'bundle_id': bundleId, + 'encrypt_password': encryptPassword, + 'expired_at': expiredAt, + 'chunk_size': chunkSize, + }, + ); + + return response.data; + } + + /// Uploads a single chunk of the file. + Future uploadChunk({ + required String taskId, + required int chunkIndex, + required Uint8List chunkData, + }) async { + final formData = FormData.fromMap({ + 'chunk': MultipartFile.fromBytes( + chunkData, + filename: 'chunk_$chunkIndex', + ), + }); + + await _dio.post( + '/drive/files/upload/chunk/$taskId/$chunkIndex', + data: formData, + ); + } + + /// Completes the upload and returns the CloudFile object. + Future completeUpload(String taskId) async { + final response = await _dio.post('/drive/files/upload/complete/$taskId'); + + return SnCloudFile.fromJson(response.data); + } + + /// Uploads a file in chunks using the multi-part API. + Future uploadFile({ + required File file, + required String fileName, + required String contentType, + String? poolId, + String? bundleId, + String? encryptPassword, + String? expiredAt, + int? customChunkSize, + }) async { + // Step 1: Create upload task + final createResponse = await createUploadTask( + file: file, + fileName: fileName, + contentType: contentType, + poolId: poolId, + bundleId: bundleId, + encryptPassword: encryptPassword, + expiredAt: expiredAt, + chunkSize: customChunkSize, + ); + + if (createResponse['file_exists'] == true) { + // File already exists, return the existing file + return SnCloudFile.fromJson(createResponse['file']); + } + + final taskId = createResponse['task_id'] as String; + final chunkSize = createResponse['chunk_size'] as int; + final chunksCount = createResponse['chunks_count'] as int; + + // Step 2: Upload chunks + final stream = file.openRead(); + final chunks = []; + int bytesRead = 0; + final buffer = BytesBuilder(); + + await for (final chunk in stream) { + buffer.add(chunk); + bytesRead += chunk.length; + + if (bytesRead >= chunkSize) { + chunks.add(buffer.takeBytes()); + bytesRead = 0; + } + } + + // Add remaining bytes as last chunk + if (buffer.length > 0) { + chunks.add(buffer.takeBytes()); + } + + // Ensure we have the correct number of chunks + if (chunks.length != chunksCount) { + throw Exception( + 'Chunk count mismatch: expected $chunksCount, got ${chunks.length}', + ); + } + + // Upload each chunk + for (int i = 0; i < chunks.length; i++) { + await uploadChunk(taskId: taskId, chunkIndex: i, chunkData: chunks[i]); + } + + // Step 3: Complete upload + return await completeUpload(taskId); + } +} + +// Riverpod provider for the FileUploader service +final fileUploaderProvider = Provider((ref) { + final dio = ref.watch(apiClientProvider); + return FileUploader(dio); +});