mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-19 01:17:26 +02:00
feat: implement cloud upload with WebDAV and SFTP support
- Add CloudUploadService with WebDAV and SFTP upload methods - Add UploadQueueProvider for managing upload queue - Integrate upload trigger after download completes - Update CloudSettingsPage with actual connection test and queue UI - Add webdav_client ^1.2.2 and dartssh2 ^2.13.0 dependencies - Remove Google Drive option (not implemented) - Bump version to 3.4.0+72
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:webdav_client/webdav_client.dart' as webdav;
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
/// Result of a cloud upload operation
|
||||
class CloudUploadResult {
|
||||
final bool success;
|
||||
final String? error;
|
||||
final String? remotePath;
|
||||
|
||||
const CloudUploadResult({
|
||||
required this.success,
|
||||
this.error,
|
||||
this.remotePath,
|
||||
});
|
||||
|
||||
factory CloudUploadResult.success(String remotePath) => CloudUploadResult(
|
||||
success: true,
|
||||
remotePath: remotePath,
|
||||
);
|
||||
|
||||
factory CloudUploadResult.failure(String error) => CloudUploadResult(
|
||||
success: false,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parsed SFTP server URL
|
||||
class SftpServerInfo {
|
||||
final String host;
|
||||
final int port;
|
||||
|
||||
const SftpServerInfo({required this.host, required this.port});
|
||||
}
|
||||
|
||||
/// Service for uploading files to cloud storage (WebDAV, SFTP)
|
||||
class CloudUploadService {
|
||||
static CloudUploadService? _instance;
|
||||
static CloudUploadService get instance => _instance ??= CloudUploadService._();
|
||||
|
||||
CloudUploadService._();
|
||||
|
||||
final LogBuffer _log = LogBuffer();
|
||||
|
||||
webdav.Client? _webdavClient;
|
||||
String? _currentServerUrl;
|
||||
String? _currentUsername;
|
||||
|
||||
void _logInfo(String tag, String message) {
|
||||
_log.add(LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: 'INFO',
|
||||
tag: tag,
|
||||
message: message,
|
||||
));
|
||||
}
|
||||
|
||||
void _logError(String tag, String message, [String? error]) {
|
||||
_log.add(LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: 'ERROR',
|
||||
tag: tag,
|
||||
message: message,
|
||||
error: error,
|
||||
));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WebDAV Methods
|
||||
// ============================================================
|
||||
|
||||
/// Initialize WebDAV client with server credentials
|
||||
Future<void> initializeWebDAV({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
// Reuse existing client if credentials haven't changed
|
||||
if (_webdavClient != null &&
|
||||
_currentServerUrl == serverUrl &&
|
||||
_currentUsername == username) {
|
||||
return;
|
||||
}
|
||||
|
||||
_webdavClient = webdav.newClient(
|
||||
serverUrl,
|
||||
user: username,
|
||||
password: password,
|
||||
debug: false,
|
||||
);
|
||||
|
||||
_currentServerUrl = serverUrl;
|
||||
_currentUsername = username;
|
||||
|
||||
_logInfo('CloudUpload', 'WebDAV client initialized for $serverUrl');
|
||||
}
|
||||
|
||||
/// Test connection to WebDAV server
|
||||
Future<CloudUploadResult> testWebDAVConnection({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
final client = webdav.newClient(
|
||||
serverUrl,
|
||||
user: username,
|
||||
password: password,
|
||||
debug: false,
|
||||
);
|
||||
|
||||
// Try to ping/read root directory
|
||||
await client.ping();
|
||||
|
||||
_logInfo('CloudUpload', 'WebDAV connection test successful: $serverUrl');
|
||||
return CloudUploadResult.success('/');
|
||||
} catch (e) {
|
||||
_logError('CloudUpload', 'WebDAV connection test failed', e.toString());
|
||||
return CloudUploadResult.failure(_parseWebDAVError(e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a file to WebDAV server
|
||||
Future<CloudUploadResult> uploadFileWebDAV({
|
||||
required String localPath,
|
||||
required String remotePath,
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
void Function(int sent, int total)? onProgress,
|
||||
}) async {
|
||||
try {
|
||||
// Initialize client if needed
|
||||
await initializeWebDAV(
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
final client = _webdavClient!;
|
||||
final file = File(localPath);
|
||||
|
||||
if (!await file.exists()) {
|
||||
return CloudUploadResult.failure('File not found: $localPath');
|
||||
}
|
||||
|
||||
// Extract directory path and ensure it exists
|
||||
final remoteDir = remotePath.substring(0, remotePath.lastIndexOf('/'));
|
||||
if (remoteDir.isNotEmpty) {
|
||||
await _ensureWebDAVDirectoryExists(client, remoteDir);
|
||||
}
|
||||
|
||||
// Upload the file
|
||||
_logInfo('CloudUpload', 'WebDAV uploading: $localPath -> $remotePath');
|
||||
|
||||
await client.writeFromFile(
|
||||
localPath,
|
||||
remotePath,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
|
||||
_logInfo('CloudUpload', 'WebDAV upload complete: $remotePath');
|
||||
return CloudUploadResult.success(remotePath);
|
||||
} catch (e) {
|
||||
_logError('CloudUpload', 'WebDAV upload failed', e.toString());
|
||||
return CloudUploadResult.failure(_parseWebDAVError(e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure a directory exists on the WebDAV server, creating it if necessary
|
||||
Future<void> _ensureWebDAVDirectoryExists(webdav.Client client, String path) async {
|
||||
final parts = path.split('/').where((p) => p.isNotEmpty).toList();
|
||||
var currentPath = '';
|
||||
|
||||
for (final part in parts) {
|
||||
currentPath += '/$part';
|
||||
try {
|
||||
await client.mkdir(currentPath);
|
||||
} catch (e) {
|
||||
// Directory might already exist, ignore error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse WebDAV error to user-friendly message
|
||||
String _parseWebDAVError(dynamic error) {
|
||||
final errorStr = error.toString().toLowerCase();
|
||||
|
||||
if (errorStr.contains('401') || errorStr.contains('unauthorized')) {
|
||||
return 'Authentication failed. Check username and password.';
|
||||
}
|
||||
if (errorStr.contains('403') || errorStr.contains('forbidden')) {
|
||||
return 'Access denied. Check permissions on the server.';
|
||||
}
|
||||
if (errorStr.contains('404') || errorStr.contains('not found')) {
|
||||
return 'Server path not found. Check the URL.';
|
||||
}
|
||||
if (errorStr.contains('connection refused') || errorStr.contains('socket')) {
|
||||
return 'Cannot connect to server. Check URL and network.';
|
||||
}
|
||||
if (errorStr.contains('certificate') || errorStr.contains('ssl') || errorStr.contains('tls')) {
|
||||
return 'SSL/TLS error. Server certificate may be invalid.';
|
||||
}
|
||||
if (errorStr.contains('timeout')) {
|
||||
return 'Connection timed out. Server may be unreachable.';
|
||||
}
|
||||
if (errorStr.contains('507') || errorStr.contains('insufficient storage')) {
|
||||
return 'Insufficient storage on server.';
|
||||
}
|
||||
|
||||
return 'Upload failed: ${error.toString()}';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SFTP Methods
|
||||
// ============================================================
|
||||
|
||||
/// Parse SFTP server URL to extract host and port
|
||||
/// Supports formats:
|
||||
/// - sftp://hostname:port
|
||||
/// - sftp://hostname
|
||||
/// - hostname:port
|
||||
/// - hostname
|
||||
SftpServerInfo _parseSftpUrl(String serverUrl) {
|
||||
var url = serverUrl.trim();
|
||||
|
||||
// Remove sftp:// prefix if present
|
||||
if (url.toLowerCase().startsWith('sftp://')) {
|
||||
url = url.substring(7);
|
||||
}
|
||||
|
||||
// Check for port
|
||||
final colonIndex = url.lastIndexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
final host = url.substring(0, colonIndex);
|
||||
final portStr = url.substring(colonIndex + 1);
|
||||
final port = int.tryParse(portStr) ?? 22;
|
||||
return SftpServerInfo(host: host, port: port);
|
||||
}
|
||||
|
||||
return SftpServerInfo(host: url, port: 22);
|
||||
}
|
||||
|
||||
/// Test connection to SFTP server
|
||||
Future<CloudUploadResult> testSFTPConnection({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
SSHClient? client;
|
||||
try {
|
||||
final serverInfo = _parseSftpUrl(serverUrl);
|
||||
|
||||
_logInfo('CloudUpload', 'SFTP connecting to ${serverInfo.host}:${serverInfo.port}');
|
||||
|
||||
// Connect to SSH server
|
||||
final socket = await SSHSocket.connect(
|
||||
serverInfo.host,
|
||||
serverInfo.port,
|
||||
timeout: const Duration(seconds: 10),
|
||||
);
|
||||
|
||||
client = SSHClient(
|
||||
socket,
|
||||
username: username,
|
||||
onPasswordRequest: () => password,
|
||||
);
|
||||
|
||||
// Wait for authentication
|
||||
await client.authenticated;
|
||||
|
||||
// Test SFTP subsystem
|
||||
final sftp = await client.sftp();
|
||||
await sftp.listdir('/');
|
||||
sftp.close();
|
||||
|
||||
_logInfo('CloudUpload', 'SFTP connection test successful: ${serverInfo.host}');
|
||||
return CloudUploadResult.success('/');
|
||||
} catch (e) {
|
||||
_logError('CloudUpload', 'SFTP connection test failed', e.toString());
|
||||
return CloudUploadResult.failure(_parseSFTPError(e));
|
||||
} finally {
|
||||
client?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a file to SFTP server
|
||||
Future<CloudUploadResult> uploadFileSFTP({
|
||||
required String localPath,
|
||||
required String remotePath,
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
void Function(int sent, int total)? onProgress,
|
||||
}) async {
|
||||
SSHClient? client;
|
||||
try {
|
||||
final file = File(localPath);
|
||||
if (!await file.exists()) {
|
||||
return CloudUploadResult.failure('File not found: $localPath');
|
||||
}
|
||||
|
||||
final fileSize = await file.length();
|
||||
final serverInfo = _parseSftpUrl(serverUrl);
|
||||
|
||||
_logInfo('CloudUpload', 'SFTP connecting to ${serverInfo.host}:${serverInfo.port}');
|
||||
|
||||
// Connect to SSH server
|
||||
final socket = await SSHSocket.connect(
|
||||
serverInfo.host,
|
||||
serverInfo.port,
|
||||
timeout: const Duration(seconds: 30),
|
||||
);
|
||||
|
||||
client = SSHClient(
|
||||
socket,
|
||||
username: username,
|
||||
onPasswordRequest: () => password,
|
||||
);
|
||||
|
||||
// Wait for authentication
|
||||
await client.authenticated;
|
||||
|
||||
// Open SFTP session
|
||||
final sftp = await client.sftp();
|
||||
|
||||
// Ensure remote directory exists
|
||||
final remoteDir = remotePath.substring(0, remotePath.lastIndexOf('/'));
|
||||
if (remoteDir.isNotEmpty) {
|
||||
await _ensureSFTPDirectoryExists(sftp, remoteDir);
|
||||
}
|
||||
|
||||
_logInfo('CloudUpload', 'SFTP uploading: $localPath -> $remotePath');
|
||||
|
||||
// Open remote file for writing
|
||||
final remoteFile = await sftp.open(
|
||||
remotePath,
|
||||
mode: SftpFileOpenMode.create |
|
||||
SftpFileOpenMode.write |
|
||||
SftpFileOpenMode.truncate,
|
||||
);
|
||||
|
||||
// Read local file and write to remote with progress
|
||||
final localFileStream = file.openRead();
|
||||
int bytesUploaded = 0;
|
||||
|
||||
await for (final chunk in localFileStream) {
|
||||
await remoteFile.write(Stream.value(Uint8List.fromList(chunk)));
|
||||
bytesUploaded += chunk.length;
|
||||
onProgress?.call(bytesUploaded, fileSize);
|
||||
}
|
||||
|
||||
await remoteFile.close();
|
||||
sftp.close();
|
||||
|
||||
_logInfo('CloudUpload', 'SFTP upload complete: $remotePath');
|
||||
return CloudUploadResult.success(remotePath);
|
||||
} catch (e) {
|
||||
_logError('CloudUpload', 'SFTP upload failed', e.toString());
|
||||
return CloudUploadResult.failure(_parseSFTPError(e));
|
||||
} finally {
|
||||
client?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure a directory exists on the SFTP server, creating it if necessary
|
||||
Future<void> _ensureSFTPDirectoryExists(SftpClient sftp, String path) async {
|
||||
final parts = path.split('/').where((p) => p.isNotEmpty).toList();
|
||||
var currentPath = '';
|
||||
|
||||
for (final part in parts) {
|
||||
currentPath += '/$part';
|
||||
try {
|
||||
await sftp.mkdir(currentPath);
|
||||
} catch (e) {
|
||||
// Directory might already exist, ignore error
|
||||
// SFTP throws exception if directory exists
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse SFTP error to user-friendly message
|
||||
String _parseSFTPError(dynamic error) {
|
||||
final errorStr = error.toString().toLowerCase();
|
||||
|
||||
if (errorStr.contains('authentication') ||
|
||||
errorStr.contains('permission denied') ||
|
||||
errorStr.contains('auth fail')) {
|
||||
return 'Authentication failed. Check username and password.';
|
||||
}
|
||||
if (errorStr.contains('connection refused')) {
|
||||
return 'Connection refused. Check server address and port.';
|
||||
}
|
||||
if (errorStr.contains('no route to host') ||
|
||||
errorStr.contains('network is unreachable')) {
|
||||
return 'Cannot reach server. Check network connection.';
|
||||
}
|
||||
if (errorStr.contains('connection timed out') ||
|
||||
errorStr.contains('timeout')) {
|
||||
return 'Connection timed out. Server may be unreachable.';
|
||||
}
|
||||
if (errorStr.contains('host key') ||
|
||||
errorStr.contains('fingerprint')) {
|
||||
return 'Host key verification failed.';
|
||||
}
|
||||
if (errorStr.contains('no such file') ||
|
||||
errorStr.contains('not found')) {
|
||||
return 'Remote path not found.';
|
||||
}
|
||||
if (errorStr.contains('permission') ||
|
||||
errorStr.contains('access denied')) {
|
||||
return 'Permission denied. Check folder permissions.';
|
||||
}
|
||||
if (errorStr.contains('disk full') ||
|
||||
errorStr.contains('no space')) {
|
||||
return 'Insufficient storage on server.';
|
||||
}
|
||||
if (errorStr.contains('socket') ||
|
||||
errorStr.contains('broken pipe')) {
|
||||
return 'Connection lost. Try again.';
|
||||
}
|
||||
|
||||
return 'SFTP error: ${error.toString()}';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Common Methods
|
||||
// ============================================================
|
||||
|
||||
/// Get the remote path for a downloaded file
|
||||
String getRemotePath({
|
||||
required String localFilePath,
|
||||
required String baseRemotePath,
|
||||
required String downloadDirectory,
|
||||
}) {
|
||||
// Extract relative path from download directory
|
||||
String relativePath;
|
||||
if (localFilePath.startsWith(downloadDirectory)) {
|
||||
relativePath = localFilePath.substring(downloadDirectory.length);
|
||||
if (relativePath.startsWith('/') || relativePath.startsWith('\\')) {
|
||||
relativePath = relativePath.substring(1);
|
||||
}
|
||||
} else {
|
||||
// Just use the filename
|
||||
relativePath = localFilePath.split(Platform.pathSeparator).last;
|
||||
}
|
||||
|
||||
// Normalize path separators
|
||||
relativePath = relativePath.replaceAll('\\', '/');
|
||||
|
||||
// Combine with base remote path
|
||||
var remotePath = baseRemotePath;
|
||||
if (!remotePath.endsWith('/')) {
|
||||
remotePath += '/';
|
||||
}
|
||||
remotePath += relativePath;
|
||||
|
||||
return remotePath;
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
_webdavClient = null;
|
||||
_currentServerUrl = null;
|
||||
_currentUsername = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user