diff --git a/lib/sftp_connection_list.dart b/lib/sftp_connection_list.dart index 0116fa4..ca4d4fb 100644 --- a/lib/sftp_connection_list.dart +++ b/lib/sftp_connection_list.dart @@ -1,10 +1,10 @@ import 'dart:convert'; -import 'package:dartssh2/dartssh2.dart'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:fluxcloud/connection.dart'; import 'package:fluxcloud/sftp_explorer.dart'; +import 'package:fluxcloud/sftp_worker.dart'; import 'package:fluxcloud/widgets/add_server_modal.dart'; class SftpConnectionList extends StatefulWidget { @@ -78,19 +78,9 @@ class _SftpConnectionListState extends State { borderRadius: BorderRadius.circular(10), child: InkWell( onTap: () async { - final conn = _connections[index]; - final client = SSHClient( - await SSHSocket.connect(conn.host!, conn.port!), - username: conn.username!, - onPasswordRequest: () => conn.password, - identities: [ - if (conn.privateKey != null) - ...SSHKeyPair.fromPem(conn.privateKey!) - ] - ); - final sftpClient = await client.sftp(); + final sftpWorker = await SftpWorker.spawn(_connections[index]); if (context.mounted) { - Navigator.push(context, MaterialPageRoute(builder: (context) => SftpExplorer(sftpClient: sftpClient,))); + Navigator.push(context, MaterialPageRoute(builder: (context) => SftpExplorer(sftpWorker: sftpWorker,))); } }, borderRadius: BorderRadius.circular(10), diff --git a/lib/sftp_explorer.dart b/lib/sftp_explorer.dart index 35c4385..91e5b84 100644 --- a/lib/sftp_explorer.dart +++ b/lib/sftp_explorer.dart @@ -1,11 +1,15 @@ +import 'dart:io'; + import 'package:dartssh2/dartssh2.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; +import 'package:fluxcloud/sftp_worker.dart'; class SftpExplorer extends StatefulWidget { - const SftpExplorer({super.key, required this.sftpClient, this.path = '/'}); + const SftpExplorer({super.key, required this.sftpWorker, this.path = '/'}); - final SftpClient sftpClient; + final SftpWorker sftpWorker; final String path; @override @@ -17,10 +21,6 @@ class _SftpExplorerState extends State { bool _isLoading = true; late List _dirContents; - SftpFileWriter? _loader; - String _loadingFileName = ''; - double _progress = 0; - @override void initState() { super.initState(); @@ -29,7 +29,14 @@ class _SftpExplorerState extends State { Future _listDir() async { setState(() => _isLoading = true); - _dirContents = await widget.sftpClient.listdir(widget.path); + try { + _dirContents = await widget.sftpWorker.listdir(widget.path); + } + catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(_buildErrorSnackBar(context, e.toString())); + } + } setState(() => _isLoading = false); } @@ -40,7 +47,6 @@ class _SftpExplorerState extends State { appBar: AppBar( title: Text('Explorer'), ), - bottomNavigationBar: _buildLoadingWidget(context), floatingActionButton: _buildFABs(context), body: _isLoading ? Center(child: CircularProgressIndicator()) : ListView.builder( itemCount: _dirContents.length, @@ -82,19 +88,19 @@ class _SftpExplorerState extends State { TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')), TextButton( onPressed: () async { - try { - await widget.sftpClient.rename('${widget.path}${dirEntry.filename}', '${widget.path}${newNameController.text}'); - _listDir(); - } - on SftpStatusError catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(_buildErrorSnackBar(context, e.message)); - } - } - if (context.mounted) { - Navigator.pop(context); - } - + // try { + // await widget.sftpWorker.rename('${widget.path}${dirEntry.filename}', '${widget.path}${newNameController.text}'); + // _listDir(); + // } + // on SftpStatusError catch (e) { + // if (context.mounted) { + // ScaffoldMessenger.of(context).showSnackBar(_buildErrorSnackBar(context, e.message)); + // } + // } + // if (context.mounted) { + // Navigator.pop(context); + // } + // }, child: Text('Rename') ), @@ -116,30 +122,30 @@ class _SftpExplorerState extends State { TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')), TextButton( onPressed: () async { - if (dirEntry.attr.isDirectory) { - Future removeRecursively (String path) async { - final dirContents = await widget.sftpClient.listdir(path); - for (SftpName entry in dirContents) { - final fullPath = '$path${entry.filename}'; - if (entry.attr.isDirectory) { - await removeRecursively('$fullPath/'); - await widget.sftpClient.rmdir('$fullPath/'); - } - else { - await widget.sftpClient.remove(fullPath); - } - } - await widget.sftpClient.rmdir(path); - } - await removeRecursively('${widget.path}${dirEntry.filename}/'); - } - else { - await widget.sftpClient.remove('${widget.path}${dirEntry.filename}'); - } - _listDir(); - if (context.mounted) { - Navigator.pop(context); - } + // if (dirEntry.attr.isDirectory) { + // Future removeRecursively (String path) async { + // final dirContents = await widget.sftpWorker.listdir(path); + // for (SftpName entry in dirContents) { + // final fullPath = '$path${entry.filename}'; + // if (entry.attr.isDirectory) { + // await removeRecursively('$fullPath/'); + // await widget.sftpWorker.rmdir('$fullPath/'); + // } + // else { + // await widget.sftpWorker.remove(fullPath); + // } + // } + // await widget.sftpWorker.rmdir(path); + // } + // await removeRecursively('${widget.path}${dirEntry.filename}/'); + // } + // else { + // await widget.sftpWorker.remove('${widget.path}${dirEntry.filename}'); + // } + // _listDir(); + // if (context.mounted) { + // Navigator.pop(context); + // } }, child: Text('Yes') ), @@ -155,7 +161,7 @@ class _SftpExplorerState extends State { if (dirEntry.attr.isDirectory) { Navigator.push(context, MaterialPageRoute( builder: (context) => SftpExplorer( - sftpClient: widget.sftpClient, + sftpWorker: widget.sftpWorker, path: '${widget.path}${dirEntry.filename}/', ) )); @@ -190,23 +196,23 @@ class _SftpExplorerState extends State { TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')), TextButton( onPressed: () async { - try { - await widget.sftpClient.mkdir('${widget.path}${nameController.text}'); - _listDir(); - } - on SftpStatusError catch (e) { - if (context.mounted) { - if (e.code == 4) { - ScaffoldMessenger.of(context).showSnackBar(_buildErrorSnackBar(context, 'Folder Already Exists')); - } - else { - ScaffoldMessenger.of(context).showSnackBar(_buildErrorSnackBar(context, 'Error: ${e.message}')); - } - } - } - if (context.mounted) { - Navigator.pop(context); - } + // try { + // await widget.sftpWorker.mkdir('${widget.path}${nameController.text}'); + // _listDir(); + // } + // on SftpStatusError catch (e) { + // if (context.mounted) { + // if (e.code == 4) { + // ScaffoldMessenger.of(context).showSnackBar(_buildErrorSnackBar(context, 'Folder Already Exists')); + // } + // else { + // ScaffoldMessenger.of(context).showSnackBar(_buildErrorSnackBar(context, 'Error: ${e.message}')); + // } + // } + // } + // if (context.mounted) { + // Navigator.pop(context); + // } }, child: Text('Ok') ), @@ -219,35 +225,17 @@ class _SftpExplorerState extends State { FloatingActionButton( heroTag: 'upload-file', onPressed: () async { - // TODO: upload hangingig on android - final List files = await openFiles(); - try { - for (XFile file in files) { - final remoteFile = await widget.sftpClient.open('${widget.path}${file.name}', mode: SftpFileOpenMode.create | SftpFileOpenMode.write | SftpFileOpenMode.exclusive); - final fileSize = await file.length(); - final uploader = remoteFile.write( - file.openRead().cast(), - onProgress: (progress) => setState(() => _progress = progress/fileSize) - ); - setState(() { - _loader = uploader; - _loadingFileName = file.name; - }); - await uploader.done; - } - setState(() => _loader = null); - _listDir(); + final List filePaths; + if (Platform.isAndroid | Platform.isIOS) { + final res = await FilePicker.platform.pickFiles(allowMultiple: true); + filePaths = (res?.paths ?? []).whereType().toList(); } - on SftpStatusError catch (e) { - if (context.mounted) { - if (e.code == 4) { - ScaffoldMessenger.of(context).showSnackBar(_buildErrorSnackBar(context, 'File Already Exists')); - } - else { - ScaffoldMessenger.of(context).showSnackBar(_buildErrorSnackBar(context, 'Error: ${e.message}')); - } - } + else { + final files = await openFiles(); + filePaths = files.map((file) => file.path).toList(); } + await widget.sftpWorker.uploadFiles(widget.path, filePaths); + _listDir(); }, child: Icon(Icons.upload), ), @@ -268,34 +256,4 @@ class _SftpExplorerState extends State { ) ); } - - Widget _buildLoadingWidget(BuildContext context) { - return _loader != null ? Container( - color: Theme.of(context).colorScheme.secondaryContainer, - child: Padding( - padding: const EdgeInsets.all(10), - child: Column( - spacing: 10, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - spacing: 10, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Uploading file: $_loadingFileName', style: TextStyle(fontSize: 16),), - TextButton( - onPressed: () { - _loader!.abort(); - widget.sftpClient.remove('${widget.path}$_loadingFileName'); - }, - child: Text('Cancel') - ), - ], - ), - LinearProgressIndicator(value: _progress,) - ], - ), - ), - ) : SizedBox(); - } } diff --git a/lib/sftp_worker.dart b/lib/sftp_worker.dart new file mode 100644 index 0000000..02bd17c --- /dev/null +++ b/lib/sftp_worker.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:path/path.dart'; + +import 'connection.dart'; + +sealed class SftpCommand {} +class ListDir extends SftpCommand { + final String path; + + ListDir(this.path); +} +class UploadFiles extends SftpCommand { + final String path; + final List fileNames; + + UploadFiles(this.path, this.fileNames); +} + +class SftpWorker { + + final ReceivePort _responses; + final SendPort _commands; + final Map> _activeRequests = {}; + int _idCounter = 0; + + SftpWorker._(this._responses, this._commands) { + _responses.listen(_sftpResponseHandler); + } + + static Future spawn(Connection connection) async { + final initPort = RawReceivePort(); + final workerReady = Completer<(ReceivePort, SendPort)>.sync(); + initPort.handler = (message) { + final commandPort = message as SendPort; + workerReady.complete(( + ReceivePort.fromRawReceivePort(initPort), + commandPort + )); + }; + + try { + Isolate.spawn(_startSftpIsolate, (initPort.sendPort, connection)); + } on Object { + initPort.close(); + rethrow; + } + + final (receivePort, sendPort) = await workerReady.future; + + return SftpWorker._(receivePort, sendPort); + } + + static void _startSftpIsolate((SendPort, Connection) args) async { + final sendPort = args.$1; + final receivePort = ReceivePort(); + + // TODO: error handling + final connection = args.$2; + final client = SSHClient( + await SSHSocket.connect(connection.host!, connection.port!), + username: connection.username!, + onPasswordRequest: () => connection.password, + identities: [ + if (connection.privateKey != null) + ...SSHKeyPair.fromPem(connection.privateKey!) + ] + ); + final sftpClient = await client.sftp(); + + sendPort.send(receivePort.sendPort); + + _sftpCmdHandler(sendPort, receivePort, sftpClient); + } + + + static void _sftpCmdHandler(SendPort sendPort, ReceivePort receivePort, SftpClient sftpClient) { + receivePort.listen((message) async { + final (int id, dynamic command) = message; + switch (command) { + case ListDir(:final path): + try { + final files = await sftpClient.listdir(path); + sendPort.send((id, files)); + } + on SftpStatusError catch (e) { + sendPort.send((id, RemoteError(e.message, ''))); + } + case UploadFiles(:final path, fileNames:final filePaths): + for (var filePath in filePaths) { + try { + final file = File(filePath); + final fileSize = await file.length(); + final remoteFile = await sftpClient.open( + '$path${basename(filePath)}', + mode: SftpFileOpenMode.create | SftpFileOpenMode.write | SftpFileOpenMode.exclusive + ); + await remoteFile.write( + file.openRead().cast(), + onProgress: (progress) { + print(progress/fileSize); + } + ); + } + on SftpStatusError catch (e) { + sendPort.send((id, RemoteError(e.message, ''))); + } + } + sendPort.send((id, 0)); + } + }); + } + + void _sftpResponseHandler(dynamic message) { + final (int id, Object response) = message; + final completer = _activeRequests.remove(id)!; + + if (response is RemoteError) { + completer.completeError(response); + } + else { + completer.complete(response); + } + } + + + Future> listdir(String path) async { + final completer = Completer.sync(); + final id = _idCounter++; + _activeRequests[id] = completer; + _commands.send((id, ListDir(path))); + return await completer.future as List; + } + + + Future uploadFiles(String path, List filePaths) async { + final completer = Completer.sync(); + final id = _idCounter++; + _activeRequests[id] = completer; + _commands.send((id, UploadFiles(path, filePaths))); + await completer.future; + } + + +} diff --git a/lib/widgets/loading_overlay.dart b/lib/widgets/loading_overlay.dart new file mode 100644 index 0000000..be57ac7 --- /dev/null +++ b/lib/widgets/loading_overlay.dart @@ -0,0 +1,83 @@ +import 'package:dartssh2/dartssh2.dart'; +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; + +class LoadingOverlay extends StatefulWidget{ + const LoadingOverlay({ + super.key, required this.sftpClient, required this.path, required this.files, + }); + + final SftpClient sftpClient; + final String path; + final List files; + + @override + State createState() => _LoadingOverlayState(); +} + +class _LoadingOverlayState extends State { + + Future uploadFiles() async { + for (final file in widget.files) { + final fileSize = await file.length(); + final remoteFile = await widget.sftpClient.open( + '${widget.path}${file.name}', + mode: SftpFileOpenMode.create | SftpFileOpenMode.write | SftpFileOpenMode.exclusive + ); + _loader = remoteFile.write( + file.openRead(), + onProgress: (progress) => setState(() => _progress = progress/fileSize) + ); + await _loader?.done; + } + } + + @override + void initState() { + super.initState(); + uploadFiles(); + _loadingFileName = widget.files[0].name; + } + + SftpFileWriter? _loader; + + late String _loadingFileName; + double _progress = 0; + + @override + build(BuildContext context) { + return Align( + alignment: Alignment.bottomCenter, + child: Container( + color: Theme.of(context).colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + spacing: 10, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + spacing: 10, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Uploading file: $_loadingFileName', style: TextStyle(fontSize: 16),), + TextButton( + onPressed: () { + if (_loader != null) { + _loader!.abort(); + widget.sftpClient.remove('${widget.path}$_loadingFileName'); + } + }, + child: Text('Cancel') + ), + ], + ), + LinearProgressIndicator(value: _progress,) + ], + ), + ), + ), + ); + } +} + diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5d35054..269271d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,11 +5,13 @@ import FlutterMacOS import Foundation +import file_picker import file_selector_macos import flutter_secure_storage_macos import path_provider_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 3a123a5..6f43778 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a + url: "https://pub.dev" + source: hosted + version: "10.2.0" file_selector: dependency: "direct main" description: @@ -166,6 +174,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e + url: "https://pub.dev" + source: hosted + version: "2.0.28" flutter_secure_storage: dependency: "direct main" description: @@ -305,7 +321,7 @@ packages: source: hosted version: "1.16.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" diff --git a/pubspec.yaml b/pubspec.yaml index f976e12..6a0b597 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,8 @@ dependencies: file_selector: ^1.0.3 flutter_secure_storage: ^9.2.4 dartssh2: ^2.13.0 + path: ^1.9.1 + file_picker: ^10.2.0 dev_dependencies: flutter_test: