mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-11 06:53:46 +02:00
663ee12bcc
- 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
470 lines
14 KiB
Dart
470 lines
14 KiB
Dart
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;
|
|
}
|
|
}
|