320 lines
		
	
	
		
			9.2 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			320 lines
		
	
	
		
			9.2 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'dart:async';
 | 
						|
import 'dart:typed_data';
 | 
						|
import 'package:cross_file/cross_file.dart';
 | 
						|
import 'package:crypto/crypto.dart';
 | 
						|
import 'package:dio/dio.dart';
 | 
						|
import 'package:flutter/foundation.dart';
 | 
						|
import 'package:flutter_riverpod/flutter_riverpod.dart';
 | 
						|
import 'package:island/models/file.dart';
 | 
						|
import 'package:island/pods/network.dart';
 | 
						|
import 'package:mime/mime.dart';
 | 
						|
import 'package:native_exif/native_exif.dart';
 | 
						|
import 'package:path/path.dart' show extension;
 | 
						|
 | 
						|
class FileUploader {
 | 
						|
  final Dio _client;
 | 
						|
 | 
						|
  FileUploader(this._client);
 | 
						|
 | 
						|
  /// Calculates the MD5 hash of a file.
 | 
						|
  Future<String> _calculateFileHash(XFile file) async {
 | 
						|
    final bytes = await file.readAsBytes();
 | 
						|
    final digest = md5.convert(bytes);
 | 
						|
    return digest.toString();
 | 
						|
  }
 | 
						|
 | 
						|
  /// Creates an upload task for the given file.
 | 
						|
  Future<Map<String, dynamic>> createUploadTask({
 | 
						|
    required XFile 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 _client.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<void> uploadChunk({
 | 
						|
    required String taskId,
 | 
						|
    required int chunkIndex,
 | 
						|
    required Uint8List chunkData,
 | 
						|
  }) async {
 | 
						|
    final formData = FormData.fromMap({
 | 
						|
      'chunk': MultipartFile.fromBytes(
 | 
						|
        chunkData,
 | 
						|
        filename: 'chunk_$chunkIndex',
 | 
						|
      ),
 | 
						|
    });
 | 
						|
 | 
						|
    await _client.post(
 | 
						|
      '/drive/files/upload/chunk/$taskId/$chunkIndex',
 | 
						|
      data: formData,
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  /// Completes the upload and returns the CloudFile object.
 | 
						|
  Future<SnCloudFile> completeUpload(String taskId) async {
 | 
						|
    final response = await _client.post('/drive/files/upload/complete/$taskId');
 | 
						|
 | 
						|
    return SnCloudFile.fromJson(response.data);
 | 
						|
  }
 | 
						|
 | 
						|
  /// Uploads a file in chunks using the multi-part API.
 | 
						|
  Future<SnCloudFile> uploadFile({
 | 
						|
    required XFile 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 = <Uint8List>[];
 | 
						|
    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);
 | 
						|
  }
 | 
						|
 | 
						|
  static Completer<SnCloudFile?> createCloudFile({
 | 
						|
    required UniversalFile fileData,
 | 
						|
    required Dio client,
 | 
						|
    String? poolId,
 | 
						|
    FileUploadMode? mode,
 | 
						|
    Function(double progress, Duration estimate)? onProgress,
 | 
						|
  }) {
 | 
						|
    final completer = Completer<SnCloudFile?>();
 | 
						|
 | 
						|
    final effectiveMode =
 | 
						|
        mode ??
 | 
						|
        (fileData.type == UniversalFileType.file
 | 
						|
            ? FileUploadMode.generic
 | 
						|
            : FileUploadMode.mediaSafe);
 | 
						|
 | 
						|
    if (effectiveMode == FileUploadMode.mediaSafe &&
 | 
						|
        fileData.isOnDevice &&
 | 
						|
        fileData.type == UniversalFileType.image) {
 | 
						|
      final data = fileData.data;
 | 
						|
      if (data is XFile &&
 | 
						|
          !kIsWeb &&
 | 
						|
          (defaultTargetPlatform == TargetPlatform.iOS ||
 | 
						|
              defaultTargetPlatform == TargetPlatform.android)) {
 | 
						|
        Exif.fromPath(data.path)
 | 
						|
            .then((exif) async {
 | 
						|
              final gpsAttributes = {
 | 
						|
                'GPSLatitude': '',
 | 
						|
                'GPSLatitudeRef': '',
 | 
						|
                'GPSLongitude': '',
 | 
						|
                'GPSLongitudeRef': '',
 | 
						|
                'GPSAltitude': '',
 | 
						|
                'GPSAltitudeRef': '',
 | 
						|
                'GPSTimeStamp': '',
 | 
						|
                'GPSProcessingMethod': '',
 | 
						|
                'GPSDateStamp': '',
 | 
						|
              };
 | 
						|
              await exif.writeAttributes(gpsAttributes);
 | 
						|
            })
 | 
						|
            .then(
 | 
						|
              (_) => _processUpload(
 | 
						|
                fileData,
 | 
						|
                client,
 | 
						|
                poolId,
 | 
						|
                onProgress,
 | 
						|
                completer,
 | 
						|
              ),
 | 
						|
            )
 | 
						|
            .catchError((e) {
 | 
						|
              debugPrint('Error removing GPS EXIF data: $e');
 | 
						|
              return _processUpload(
 | 
						|
                fileData,
 | 
						|
                client,
 | 
						|
                poolId,
 | 
						|
                onProgress,
 | 
						|
                completer,
 | 
						|
              );
 | 
						|
            });
 | 
						|
 | 
						|
        return completer;
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    _processUpload(fileData, client, poolId, onProgress, completer);
 | 
						|
    return completer;
 | 
						|
  }
 | 
						|
 | 
						|
  // Helper method to process the upload
 | 
						|
  static Completer<SnCloudFile?> _processUpload(
 | 
						|
    UniversalFile fileData,
 | 
						|
    Dio client,
 | 
						|
    String? poolId,
 | 
						|
    Function(double progress, Duration estimate)? onProgress,
 | 
						|
    Completer<SnCloudFile?> completer,
 | 
						|
  ) {
 | 
						|
    String actualMimetype = getMimeType(fileData);
 | 
						|
    late XFile file;
 | 
						|
    String actualFilename = fileData.displayName ?? 'randomly_file';
 | 
						|
    Uint8List? byteData;
 | 
						|
 | 
						|
    // Handle the data based on what's in the UniversalFile
 | 
						|
    final data = fileData.data;
 | 
						|
 | 
						|
    if (data is XFile) {
 | 
						|
      file = data;
 | 
						|
      actualFilename = fileData.displayName ?? data.name;
 | 
						|
    } else if (data is List<int> || data is Uint8List) {
 | 
						|
      byteData = data is List<int> ? Uint8List.fromList(data) : data;
 | 
						|
      actualFilename = fileData.displayName ?? 'uploaded_file';
 | 
						|
      file = XFile.fromData(byteData!, mimeType: actualMimetype);
 | 
						|
    } else if (data is SnCloudFile) {
 | 
						|
      // If the file is already on the cloud, just return it
 | 
						|
      completer.complete(data);
 | 
						|
      return completer;
 | 
						|
    } else {
 | 
						|
      completer.completeError(
 | 
						|
        ArgumentError(
 | 
						|
          'Invalid fileData type. Expected data to be XFile, List<int>, Uint8List, or SnCloudFile.',
 | 
						|
        ),
 | 
						|
      );
 | 
						|
      return completer;
 | 
						|
    }
 | 
						|
 | 
						|
    final uploader = FileUploader(client);
 | 
						|
 | 
						|
    // Call progress start
 | 
						|
    onProgress?.call(0.0, Duration.zero);
 | 
						|
    uploader
 | 
						|
        .uploadFile(
 | 
						|
          file: file,
 | 
						|
          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;
 | 
						|
        });
 | 
						|
 | 
						|
    return completer;
 | 
						|
  }
 | 
						|
 | 
						|
  /// Gets the MIME type of a UniversalFile.
 | 
						|
  static String getMimeType(UniversalFile file, {bool useFallback = true}) {
 | 
						|
    final data = file.data;
 | 
						|
    if (data is XFile) {
 | 
						|
      final mime = data.mimeType;
 | 
						|
      if (mime != null && mime.isNotEmpty) return mime;
 | 
						|
      final filename = file.displayName ?? data.name;
 | 
						|
      if (filename.isNotEmpty) {
 | 
						|
        final detected = lookupMimeType(filename);
 | 
						|
        if (detected != null) return detected;
 | 
						|
      } else {
 | 
						|
        return switch (file.type) {
 | 
						|
          UniversalFileType.image => 'image/unknown',
 | 
						|
          UniversalFileType.audio => 'audio/unknown',
 | 
						|
          UniversalFileType.video => 'video/unknown',
 | 
						|
          _ => 'application/unknown',
 | 
						|
        };
 | 
						|
      }
 | 
						|
      if (useFallback) {
 | 
						|
        final ext = extension(data.path).substring(1);
 | 
						|
        if (ext.isNotEmpty) return 'application/$ext';
 | 
						|
        return 'application/unknown';
 | 
						|
      }
 | 
						|
      throw Exception('Cannot detect mime type for file: $filename');
 | 
						|
    } else if (data is List<int> || data is Uint8List) {
 | 
						|
      return 'application/octet-stream';
 | 
						|
    } else if (data is SnCloudFile) {
 | 
						|
      return data.mimeType ?? 'application/octet-stream';
 | 
						|
    } else {
 | 
						|
      throw ArgumentError('Invalid file data type');
 | 
						|
    }
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
enum FileUploadMode { generic, mediaSafe }
 | 
						|
 | 
						|
// Riverpod provider for the FileUploader service
 | 
						|
final fileUploaderProvider = Provider<FileUploader>((ref) {
 | 
						|
  final dio = ref.watch(apiClientProvider);
 | 
						|
  return FileUploader(dio);
 | 
						|
});
 |