Files
App/lib/services/file.dart
Texas0295 b80d91825a migrate file upload from tus to FileUploader API
The tus-based upload flow (/drive/tus) has been removed upstream in favor
of a new multipart upload protocol. This commit replaces all TusClient
usage with the new FileUploader service that follows the official
/drive/files/upload/{create,chunk,complete} endpoints.

Changes include:
- remove tus_client_dart dependency and related code
- add putFileToPool() backed by FileUploader.uploadFile()
- update uploadAttachment() to call the new putFileToPool
- preserve poolId support, filename, and mimetype handling
- ensure progress callbacks fire at start and completion

This aligns the client with the new upload protocol while keeping the
same Compose UI and settings logic introduced in earlier patches.

Signed-off-by: Texas0295 <kimura@texas0295.top>
2025-09-21 20:33:17 +08:00

302 lines
8.2 KiB
Dart

import 'dart:async';
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:path_provider/path_provider.dart';
Future<XFile?> cropImage(
BuildContext context, {
required XFile image,
List<CropAspectRatio?>? allowedAspectRatios,
bool replacePath = false,
}) async {
final result = await showMaterialImageCropper(
context,
imageProvider:
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)),
showLoadingIndicatorOnSubmit: true,
allowedAspectRatios: allowedAspectRatios,
);
if (result == null) return null; // Cancelled operation
final croppedFile = result.uiImage;
final croppedBytes = await croppedFile.toByteData(
format: ImageByteFormat.png,
);
if (croppedBytes == null) {
return image;
}
croppedFile.dispose();
return XFile.fromData(
croppedBytes.buffer.asUint8List(),
path: !replacePath ? image.path : null,
mimeType: image.mimeType,
);
}
Completer<SnCloudFile?> putFileToPool({
required UniversalFile fileData,
required String atk,
required String baseUrl,
required String poolId,
String? filename,
String? mimetype,
Function(double progress, Duration estimate)? onProgress,
}) {
final completer = Completer<SnCloudFile?>();
final data = fileData.data;
if (data is! XFile) {
completer.completeError(
ArgumentError('Unsupported fileData type for putFileToPool'),
);
return completer;
}
final actualFilename = filename ?? data.name;
final actualMimetype = mimetype ?? data.mimeType ?? 'application/octet-stream';
final dio = Dio(BaseOptions(
baseUrl: baseUrl,
headers: {
'Authorization': 'AtField $atk',
'Accept': 'application/json',
},
));
final uploader = FileUploader(dio);
final fileObj = File(data.path);
onProgress?.call(0.0, Duration.zero);
uploader.uploadFile(
file: fileObj,
fileName: actualFilename,
contentType: actualMimetype,
poolId: poolId,
).then((result) {
onProgress?.call(1.0, Duration.zero);
completer.complete(result);
}).catchError((e) {
completer.completeError(e);
});
return completer;
}
Completer<SnCloudFile?> putMediaToCloud({
required UniversalFile fileData,
required String atk,
required String baseUrl,
String? poolId,
String? filename,
String? mimetype,
Function(double progress, Duration estimate)? onProgress,
}) {
final completer = Completer<SnCloudFile?>();
// Process the image to remove GPS EXIF data if needed
if (fileData.isOnDevice && fileData.type == UniversalFileType.image) {
final data = fileData.data;
if (data is XFile && !kIsWeb && (Platform.isIOS || Platform.isAndroid)) {
// Use native_exif to selectively remove GPS data
Exif.fromPath(data.path)
.then((exif) {
// Remove GPS-related attributes
final gpsAttributes = [
'GPSLatitude',
'GPSLatitudeRef',
'GPSLongitude',
'GPSLongitudeRef',
'GPSAltitude',
'GPSAltitudeRef',
'GPSTimeStamp',
'GPSProcessingMethod',
'GPSDateStamp',
];
// Create a map of attributes to clear
final clearAttributes = <String, String>{};
for (final attr in gpsAttributes) {
clearAttributes[attr] = '';
}
// Write empty values to remove GPS data
return exif.writeAttributes(clearAttributes);
})
.then((_) {
// Continue with upload after GPS data is removed
_processUpload(
fileData,
atk,
baseUrl,
poolId,
filename,
mimetype,
onProgress,
completer,
);
})
.catchError((e) {
// If there's an error, continue with the original file
debugPrint('Error removing GPS EXIF data: $e');
_processUpload(
fileData,
atk,
baseUrl,
poolId,
filename,
mimetype,
onProgress,
completer,
);
});
return completer;
}
}
// If not an image or on web, continue with normal upload
_processUpload(
fileData,
atk,
baseUrl,
poolId,
filename,
mimetype,
onProgress,
completer,
);
return completer;
}
// Helper method to process the upload after any EXIF processing
Completer<SnCloudFile?> _processUpload(
UniversalFile fileData,
String atk,
String baseUrl,
String? poolId,
String? filename,
String? mimetype,
Function(double progress, Duration estimate)? onProgress,
Completer<SnCloudFile?> completer,
) {
late XFile file;
String actualFilename = filename ?? 'randomly_file';
String actualMimetype = mimetype ?? '';
Uint8List? byteData;
// Handle the data based on what's in the UniversalFile
final data = fileData.data;
if (data is XFile) {
file = data;
actualFilename = filename ?? data.name;
actualMimetype = mimetype ?? data.mimeType ?? '';
} else if (data is List<int> || data is Uint8List) {
byteData = data is List<int> ? Uint8List.fromList(data) : data;
actualFilename = filename ?? 'uploaded_file';
actualMimetype = mimetype ?? 'application/octet-stream';
if (mimetype == null) {
completer.completeError(
ArgumentError('Mimetype is required when providing raw bytes.'),
);
return completer;
}
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;
}
// Create Dio instance
final dio = Dio(
BaseOptions(
baseUrl: baseUrl,
headers: {
'Authorization': 'AtField $atk',
'Accept': 'application/json',
'Content-Type': 'application/json',
},
),
);
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;
}