diff --git a/lib/core/utils/ftpconnect/ftpconnect.dart b/lib/core/utils/ftpconnect/ftpconnect.dart new file mode 100644 index 000000000..8d4f8c3da --- /dev/null +++ b/lib/core/utils/ftpconnect/ftpconnect.dart @@ -0,0 +1,6 @@ +library ftpconnect; + +export 'src/ftp_exceptions.dart'; +export 'src/ftpconnect_base.dart'; +export 'src/ftp_entry.dart'; +export 'src/logger.dart'; diff --git a/lib/core/utils/ftpconnect/src/commands/directory.dart b/lib/core/utils/ftpconnect/src/commands/directory.dart new file mode 100644 index 000000000..5a3d17606 --- /dev/null +++ b/lib/core/utils/ftpconnect/src/commands/directory.dart @@ -0,0 +1,98 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../ftp_entry.dart'; +import '../ftp_exceptions.dart'; +import '../ftp_reply.dart'; +import '../ftp_socket.dart'; +import '../ftpconnect_base.dart'; +import '../utils.dart'; + +class FTPDirectory { + final FTPSocket _socket; + + FTPDirectory(this._socket); + + Future makeDirectory(String sName) async { + FTPReply sResponse = await (_socket.sendCommand('MKD $sName')); + + return sResponse.isSuccessCode(); + } + + Future deleteEmptyDirectory(String? sName) async { + FTPReply sResponse = await (_socket.sendCommand('rmd $sName')); + + return sResponse.isSuccessCode(); + } + + Future changeDirectory(String? sName) async { + FTPReply sResponse = await (_socket.sendCommand('CWD $sName')); + + return sResponse.isSuccessCode(); + } + + Future currentDirectory() async { + FTPReply sResponse = await _socket.sendCommand('PWD'); + if (!sResponse.isSuccessCode()) { + throw FTPConnectException( + 'Failed to get current working directory', sResponse.message); + } + + int iStart = sResponse.message.indexOf('"') + 1; + int iEnd = sResponse.message.lastIndexOf('"'); + + return sResponse.message.substring(iStart, iEnd); + } + + Future> directoryContent() async { + // Enter passive mode + FTPReply response = await _socket.openDataTransferChannel(); + + // Directoy content listing, the response will be handled by another socket + _socket.sendCommandWithoutWaitingResponse(_socket.listCommand.describeEnum); + + // Data transfer socket + int iPort = Utils.parsePort(response.message, _socket.supportIPV6); + Socket dataSocket = await Socket.connect(_socket.host, iPort, + timeout: Duration(seconds: _socket.timeout)); + //Test if second socket connection accepted or not + response = await _socket.readResponse(); + //some server return two lines 125 and 226 for transfer finished + bool isTransferCompleted = response.isSuccessCode(); + if (!isTransferCompleted && response.code != 125 && response.code != 150) { + throw FTPConnectException('Connection refused. ', response.message); + } + + List lstDirectoryListing = []; + await dataSocket.listen((Uint8List data) { + lstDirectoryListing.addAll(data); + }).asFuture(); + + await dataSocket.close(); + + if (!isTransferCompleted) { + response = await _socket.readResponse(); + if (!response.isSuccessCode()) { + throw FTPConnectException('Transfer Error.', response.message); + } + } + + // Convert MLSD response into FTPEntry + List lstFTPEntries = []; + String.fromCharCodes(lstDirectoryListing).split('\n').forEach((line) { + if (line.trim().isNotEmpty) { + lstFTPEntries.add( + FTPEntry.parse(line.replaceAll('\r', ""), _socket.listCommand), + ); + } + }); + + return lstFTPEntries; + } + + Future> directoryContentNames() async { + var list = await directoryContent(); + return list.map((f) => f.name).whereType().toList(); + } +} diff --git a/lib/core/utils/ftpconnect/src/commands/file.dart b/lib/core/utils/ftpconnect/src/commands/file.dart new file mode 100644 index 000000000..243e9a423 --- /dev/null +++ b/lib/core/utils/ftpconnect/src/commands/file.dart @@ -0,0 +1,190 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart'; + +import '../../ftpconnect.dart'; +import '../ftp_reply.dart'; +import '../ftp_socket.dart'; +import '../utils.dart'; + +typedef FileProgress = void Function( + double progressInPercent, int totalReceived, int fileSize); + +class FTPFile { + final FTPSocket _socket; + + FTPFile(this._socket); + + Future rename(String sOldName, String sNewName) async { + FTPReply sResponse = await (_socket.sendCommand('RNFR $sOldName')); + if (sResponse.code != 350) { + return false; + } + + sResponse = await (_socket.sendCommand('RNTO $sNewName')); + return sResponse.isSuccessCode(); + } + + Future delete(String? sFilename) async { + FTPReply sResponse = await (_socket.sendCommand('DELE $sFilename')); + + return sResponse.isSuccessCode(); + } + + Future exist(String sFilename) async { + return await size(sFilename) != -1; + } + + Future size(String? sFilename) async { + try { + FTPReply sResponse = await (_socket.sendCommand('SIZE $sFilename')); + if (!sResponse.isSuccessCode() && + _socket.transferType != TransferType.binary) { + //check if ascii mode get refused + //change to binary mode if ascii mode refused + final _socketTransferTypeBackup = _socket.transferType; + await _socket.setTransferType(TransferType.binary); + sResponse = await (_socket.sendCommand('SIZE $sFilename')); + //back to default mode + await _socket.setTransferType(_socketTransferTypeBackup); + } + return int.parse(sResponse.message.replaceAll('213 ', '')); + } catch (e) { + return -1; + } + } + + Future download( + String? sRemoteName, + File fLocalFile, { + FileProgress? onProgress, + }) async { + _socket.logger.log('Download $sRemoteName to ${fLocalFile.path}'); + //check for file existence and init totalData to receive + int fileSize = 0; + fileSize = await FTPFile(_socket).size(sRemoteName); + if (fileSize == -1) { + throw FTPConnectException('Remote File $sRemoteName does not exist!'); + } + + // Enter passive mode + FTPReply response = await _socket.openDataTransferChannel(); + + //the response will be the file, witch will be loaded with another socket + _socket.sendCommandWithoutWaitingResponse('RETR $sRemoteName'); + + // Data Transfer Socket + int lPort = Utils.parsePort(response.message, _socket.supportIPV6); + _socket.logger.log('Opening DataSocket to Port $lPort'); + final Socket dataSocket = await Socket.connect(_socket.host, lPort, + timeout: Duration(seconds: _socket.timeout)); + // Test if second socket connection accepted or not + response = await _socket.readResponse(); + //some server return two lines 125 and 226 for transfer finished + bool isTransferCompleted = response.isSuccessCode(); + if (!isTransferCompleted && response.code != 125 && response.code != 150) { + throw FTPConnectException('Connection refused. ', response.message); + } + + // Changed to listen mode instead so that it's possible to send information back on downloaded amount + _socket.logger.log('Start downloading...'); + var sink = fLocalFile.openWrite(mode: FileMode.writeOnly); + var received = 0; + await dataSocket.listen((data) { + sink.add(data); + if (onProgress != null) { + received += data.length; + var percent = ((received / fileSize) * 100).toStringAsFixed(2); + //in case that the file size is 0, then pass directly 100 + double percentVal = double.tryParse(percent) ?? 100; + if (percentVal.isInfinite || percentVal.isNaN) percentVal = 100; + onProgress(percentVal, received, fileSize); + } + }).asFuture(); + + await dataSocket.close(); + await sink.flush(); + await sink.close(); + + if (!isTransferCompleted) { + //Test if All data are well transferred + response = await _socket.readResponse(); + if (!response.isSuccessCode()) { + throw FTPConnectException('Transfer Error.', response.message); + } + } + + _socket.logger.log('File Downloaded!'); + return true; + } + + /// Upload File [fFile] to the current directory with [remoteName] (using filename if not set) + Future upload( + File fFile, { + String remoteName = '', + FileProgress? onProgress, + }) async { + _socket.logger.log('Upload File: ${fFile.path}'); + + // Enter passive mode + FTPReply response = await _socket.openDataTransferChannel(); + + // Store File + String sFilename = remoteName; + if (sFilename.isEmpty) { + sFilename = basename(fFile.path); + } + + // The response is the file to upload, witch will be managed by another socket + _socket.sendCommandWithoutWaitingResponse('STOR $sFilename'); + + // Data Transfer Socket + int iPort = Utils.parsePort(response.message, _socket.supportIPV6); + _socket.logger.log('Opening DataSocket to Port $iPort'); + final Socket dataSocket = await Socket.connect(_socket.host, iPort); + //Test if second socket connection accepted or not + response = await _socket.readResponse(); + //some server return two lines 125 and 226 for transfer finished + bool isTransferCompleted = response.isSuccessCode(); + if (!isTransferCompleted && response.code != 125 && response.code != 150) { + throw FTPConnectException('Connection refused. ', response.message); + } + + _socket.logger.log('Start uploading...'); + + var received = 0; + int fileSize = await fFile.length(); + + Stream> readStream = fFile.openRead().transform( + StreamTransformer.fromHandlers( + handleData: (data, sink) { + sink.add(data); + if (onProgress != null) { + received += data.length; + var percent = ((received / fileSize) * 100).toStringAsFixed(2); + //in case that the file size is 0, then pass directly 100 + double percentVal = double.tryParse(percent) ?? 100; + if (percentVal.isInfinite || percentVal.isNaN) percentVal = 100; + onProgress(percentVal, received, fileSize); + } + }, + ), + ); + + await dataSocket.addStream(readStream); + await dataSocket.flush(); + await dataSocket.close(); + + if (!isTransferCompleted) { + // Test if All data are well transferred + response = await _socket.readResponse(); + if (!response.isSuccessCode()) { + throw FTPConnectException('Transfer Error.', response.message); + } + } + + _socket.logger.log('File Uploaded!'); + return true; + } +} diff --git a/lib/core/utils/ftpconnect/src/ftp_entry.dart b/lib/core/utils/ftpconnect/src/ftp_entry.dart new file mode 100644 index 000000000..bdf1a3623 --- /dev/null +++ b/lib/core/utils/ftpconnect/src/ftp_entry.dart @@ -0,0 +1,265 @@ +import 'package:intl/intl.dart'; + +import 'ftp_exceptions.dart'; +import 'ftpconnect_base.dart'; + +class FTPEntry { + final String name; + final DateTime? modifyTime; + final String? permission; + final FTPEntryType type; + final int? size; + final String? unique; + final String? group; + final int? gid; + final String? mode; + final String? owner; + final int? uid; + final Map? additionalProperties; + + static final RegExp regexpLIST = RegExp(r"^([\-ld])" // Directory flag [1] + r"([\-rwxs]{9})\s+" // Permissions [2] + r"(\d+)\s+" // Number of items [3] + r"(\w+)\s+" // File owner [4] + r"(\w+)\s+" // File group [5] + r"(\d+)\s+" // File size in bytes [6] + r"(\w{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{1,2}|\d{4}))\s+" // date[7] + r"(.+)$" //file/dir name[8] + ); + + static final regexpLISTSiiServers = RegExp(r"^(.{8}\s+.{7})\s+" //date[1] + r"(.{0,5})\s+" //type file or dir [2] + r"(\d{0,24})\s+" //size [3] + r"(.+)$" //file/ dir name [4] + ); + + // Hide constructor + FTPEntry._( + this.name, + this.modifyTime, + this.permission, + this.type, + this.size, + this.unique, + this.group, + this.gid, + this.mode, + this.owner, + this.uid, + this.additionalProperties); + + factory FTPEntry.parse(String responseLine, ListCommand cmd) { + if (responseLine.trim().isEmpty) { + throw FTPConnectException("Can't parse a null or blank response line"); + } + if (cmd == ListCommand.LIST) { + return FTPEntry._parseListCommand(responseLine); + } else if (cmd == ListCommand.NLST) { + return FTPEntry._(responseLine, null, null, FTPEntryType.UNKNOWN, null, + null, null, null, null, null, null, null); + } else { + return FTPEntry._parseMLSDCommand(responseLine); + } + } + + factory FTPEntry._parseMLSDCommand(final String responseLine) { + String _name = ""; + DateTime? _modifyTime; + String? _permission; + FTPEntryType _type = FTPEntryType.UNKNOWN; + int _size = 0; + String? _unique; + String? _group; + int _gid = -1; + String? _mode; + String? _owner; + int _uid = -1; + Map _additional = {}; + + // Split and trim line + responseLine.trim().split(';').forEach((property) { + final prop = property + .split('=') + .map((part) => part.trim()) + .toList(growable: false); + + if (prop.length == 1) { + // Name + _name = prop[0]; + } else { + // Other attributes + switch (prop[0].toLowerCase()) { + case 'modify': + final String date = + prop[1].substring(0, 8) + 'T' + prop[1].substring(8); + _modifyTime = DateTime.tryParse(prop[1]) ?? DateTime.tryParse(date); + break; + case 'perm': + _permission = prop[1]; + break; + case 'size': + _size = int.parse(prop[1]); + break; + case 'type': + if (prop[1] == 'dir') { + _type = FTPEntryType.DIR; + } else if (prop[1] == 'file') { + _type = FTPEntryType.FILE; + } else { + _type = FTPEntryType.LINK; + } + break; + case 'unique': + _unique = prop[1]; + break; + case 'unix.group': + _group = prop[1]; + break; + case 'unix.gid': + _gid = int.parse(prop[1]); + break; + case 'unix.mode': + _mode = prop[1]; + break; + case 'unix.owner': + _owner = prop[1]; + break; + case 'unix.uid': + _uid = int.parse(prop[1]); + break; + default: + _additional.putIfAbsent(prop[0], () => prop[1]); + break; + } + } + }); + + return FTPEntry._(_name, _modifyTime, _permission, _type, _size, _unique, + _group, _gid, _mode, _owner, _uid, Map.unmodifiable(_additional)); + } + + ///reference http://cr.yp.to/ftp/list/binls.html + ///-rw-r--r-- 1 owner group 213 Aug 26 16:31 FileName.txt + ///d for Dir + ///- for file + /// + /// SII servers format: + /// 02-11-15 03:05PM 1410887680 directory + /// 02-11-15 03:05PM 1410887680 file.avi + factory FTPEntry._parseListCommand(final String responseLine) { + if (regexpLIST.hasMatch(responseLine)) { + return FTPEntry._parseLIST(responseLine); + } else if (regexpLISTSiiServers.hasMatch(responseLine)) { + return FTPEntry._parseLISTiis(responseLine); + } else { + throw FTPConnectException( + 'Invalid format <$responseLine> for LIST command response !'); + } + } + + factory FTPEntry._parseLIST(final String responseLine) { + String _name = ""; + DateTime? _modifyTime; + String? _persmission; + FTPEntryType _type = FTPEntryType.UNKNOWN; + int _size = 0; + String? _unique; + String? _group; + int _gid = -1; + String? _mode; + String? _owner; + int _uid = -1; + + Iterable matches = regexpLIST.allMatches(responseLine); + for (Match match in matches) { + if (match.group(1) == "-") { + _type = FTPEntryType.FILE; + } else if (match.group(1) == "d") { + _type = FTPEntryType.DIR; + } else { + _type = FTPEntryType.LINK; + } + + //permission + _persmission = match.group(2); + //nb files + //var nbFiles = match.group(3); + //owner + _owner = match.group(4); + //group + _group = match.group(5); + //size + _size = int.tryParse(match.group(6)!) ?? 0; + //date + String date = (match.group(7)!.split(" ")..removeWhere((i) => i.isEmpty)) + .join(" "); //keep only one space + //insert year + if (date.contains(':')) date = '$date ${DateTime.now().year}'; + var format = date.contains(':') ? 'MMM dd hh:mm yyyy' : 'MMM dd yyyy'; + _modifyTime = DateFormat(format, 'en_US').parse(date); + //file/dir name + _name = match.group(8)!; + } + return FTPEntry._(_name, _modifyTime, _persmission, _type, _size, _unique, + _group, _gid, _mode, _owner, _uid, {}); + } + + factory FTPEntry._parseLISTiis(final String responseLine) { + String _name = ""; + DateTime? _modifyTime; + String? _persmission; + FTPEntryType _type = FTPEntryType.UNKNOWN; + int _size = 0; + String? _unique; + String? _group; + int _gid = -1; + String? _mode; + String? _owner; + int _uid = -1; + Iterable matches = regexpLISTSiiServers.allMatches(responseLine); + for (Match match in matches) { + //date + String date = + match.group(1)!.split(" ").fold('', (previousValue, element) { + //keep only one space and add fullyear if only last 2 digits in year + if (element.isEmpty) return previousValue; + if (previousValue.isEmpty) { + return element.length <= 8 + ? element.substring(0, 6) + + DateTime.now().year.toString().substring(0, 2) + + element.substring(6, 8) + : element; + } + return '$previousValue $element'; + }); + _modifyTime = DateFormat('MM-dd-yyyy hh:mma').parse(date); + + //type + if (match.group(2)!.trim().isEmpty) { + _type = FTPEntryType.FILE; + } else if (match.group(2)!.toLowerCase().contains("dir")) { + _type = FTPEntryType.DIR; + } else { + _type = FTPEntryType.LINK; + } + //size + _size = int.tryParse(match.group(3)!) ?? 0; + + //file/dir name + _name = match.group(4)!; + } + return FTPEntry._(_name, _modifyTime, _persmission, _type, _size, _unique, + _group, _gid, _mode, _owner, _uid, {}); + } + + @override + String toString() => + 'name=$name;modify=$modifyTime;perm=$permission;type=${type.describeEnum.toLowerCase()};size=$size;unique=$unique;unix.group=$group;unix.mode=$mode;unix.owner=$owner;unix.uid=$uid;unix.gid=$gid'; +} + +enum FTPEntryType { FILE, DIR, LINK, UNKNOWN } + +extension FtpEntryTypeEnum on FTPEntryType { + String get describeEnum => + this.toString().substring(this.toString().indexOf('.') + 1); +} diff --git a/lib/core/utils/ftpconnect/src/ftp_exceptions.dart b/lib/core/utils/ftpconnect/src/ftp_exceptions.dart new file mode 100644 index 000000000..59368b2ba --- /dev/null +++ b/lib/core/utils/ftpconnect/src/ftp_exceptions.dart @@ -0,0 +1,11 @@ +class FTPConnectException implements Exception { + final String message; + final String? response; + + FTPConnectException(this.message, [this.response]); + + @override + String toString() { + return 'FTPConnectException: $message (Response: $response)'; + } +} diff --git a/lib/core/utils/ftpconnect/src/ftp_reply.dart b/lib/core/utils/ftpconnect/src/ftp_reply.dart new file mode 100644 index 000000000..c4538f176 --- /dev/null +++ b/lib/core/utils/ftpconnect/src/ftp_reply.dart @@ -0,0 +1,24 @@ +import 'dart:core'; + +class FTPReply { + final int _code; + final String _messages; + + FTPReply(this._code, this._messages); + + get code => this._code; + + get message => this._messages; + + bool isSuccessCode() { + int aux = this._code - 200; + return aux >= 0 && aux < 100; + } + + @override + String toString() { + StringBuffer buffer = new StringBuffer(); + buffer.write("FTPReply = [code= $_code, message= $_messages]"); + return buffer.toString(); + } +} diff --git a/lib/core/utils/ftpconnect/src/ftp_socket.dart b/lib/core/utils/ftpconnect/src/ftp_socket.dart new file mode 100644 index 000000000..cdb28c7be --- /dev/null +++ b/lib/core/utils/ftpconnect/src/ftp_socket.dart @@ -0,0 +1,221 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../ftpconnect.dart'; +import 'ftp_reply.dart'; + +class FTPSocket { + final String host; + final int port; + final Logger logger; + final int timeout; + final SecurityType securityType; + late RawSocket _socket; + TransferMode transferMode = TransferMode.passive; + TransferType _transferType = TransferType.auto; + ListCommand listCommand = ListCommand.MLSD; + bool supportIPV6 = false; + + FTPSocket(this.host, this.port, this.securityType, this.logger, this.timeout); + + /// Set current transfer type of socket + /// + /// Supported types are: [TransferType.auto], [TransferType.ascii], [TransferType.binary], + TransferType get transferType => _transferType; + + /// Read the FTP Server response from the Stream + /// + /// Blocks until data is received! + Future readResponse() async { + StringBuffer res = StringBuffer(); + await Future.doWhile(() async { + bool dataReceivedSuccessfully = false; + + //this is used to read all data for specific command line + while (_socket.available() > 0) { + res.write(String.fromCharCodes(_socket.read()!).trim()); + dataReceivedSuccessfully = true; + } + if (dataReceivedSuccessfully) return false; + + await Future.delayed(Duration(milliseconds: 300)); + return true; + }).timeout(Duration(seconds: timeout), onTimeout: () { + throw FTPConnectException('Timeout reached for Receiving response !'); + }); + + String r = res.toString(); + if (r.startsWith("\n")) r = r.replaceFirst("\n", ""); + + if (r.length < 3) throw FTPConnectException("Illegal Reply Exception", r); + + int? code; + List lines = r.split('\n'); + //get last code + String? line; + for (line in lines) { + if (line.length >= 3) code = int.tryParse(line.substring(0, 3)) ?? code; + } + //multiline response + if (line != null && line.length >= 4 && line[3] == '-') + return await readResponse(); + + if (code == null) throw FTPConnectException("Illegal Reply Exception", r); + + FTPReply reply = FTPReply(code, r); + logger.log('< ${reply.toString()}'); + return reply; + } + + /// Send a command [cmd] to the FTP Server + /// if [waitResponse] the function waits for the reply, other wise return '' + Future sendCommand(String cmd) { + logger.log('> $cmd'); + _socket.write(Utf8Codec().encode('$cmd\r\n')); + + return readResponse(); + } + + /// Send a command [cmd] to the FTP Server + /// if [waitResponse] the function waits for the reply, other wise return '' + void sendCommandWithoutWaitingResponse(String cmd) async { + logger.log('> $cmd'); + _socket.write(Utf8Codec().encode('$cmd\r\n')); + } + + /// Connect to the FTP Server and Login with [user] and [pass] + Future connect(String user, String pass, {String? account}) async { + logger.log('Connecting...'); + + final timeout = Duration(seconds: this.timeout); + + try { + // FTPS starts secure + if (securityType == SecurityType.FTPS) { + _socket = await RawSecureSocket.connect( + host, + port, + timeout: timeout, + onBadCertificate: (certificate) => true, + ); + } else { + _socket = await RawSocket.connect( + host, + port, + timeout: timeout, + ); + } + } catch (e) { + throw FTPConnectException( + 'Could not connect to $host ($port)', e.toString()); + } + + logger.log('Connection established, waiting for welcome message...'); + await readResponse(); + + // FTPES needs to be upgraded prior to getting a welcome + if (securityType == SecurityType.FTPES) { + FTPReply lResp = await sendCommand('AUTH TLS'); + if (!lResp.isSuccessCode()) { + lResp = await sendCommand('AUTH SSL'); + if (!lResp.isSuccessCode()) { + throw FTPConnectException( + 'FTPES cannot be applied: the server refused both AUTH TLS and AUTH SSL commands', + lResp.message); + } + } + + _socket = await RawSecureSocket.secure(_socket, + onBadCertificate: (certificate) => true); + } + + if ([SecurityType.FTPES, SecurityType.FTPS].contains(securityType)) { + await sendCommand('PBSZ 0'); + await sendCommand('PROT P'); + } + + // Send Username + FTPReply lResp = await sendCommand('USER $user'); + + //password required + if (lResp.code == 331) { + lResp = await sendCommand('PASS $pass'); + if (lResp.code == 332) { + if (account == null) throw FTPConnectException('Account required'); + lResp = await sendCommand('ACCT $account'); + if (!lResp.isSuccessCode()) { + throw FTPConnectException('Wrong Account', lResp.message); + } + } else if (!lResp.isSuccessCode()) { + throw FTPConnectException('Wrong Username/password', lResp.message); + } + //account required + } else if (lResp.code == 332) { + if (account == null) throw FTPConnectException('Account required'); + lResp = await sendCommand('ACCT $account'); + if (!lResp.isSuccessCode()) { + throw FTPConnectException('Wrong Account', lResp.message); + } + } else if (!lResp.isSuccessCode()) { + throw FTPConnectException('Wrong username $user', lResp.message); + } + + logger.log('Connected!'); + return true; + } + + Future openDataTransferChannel() async { + FTPReply res = FTPReply(200, ""); + if (transferMode == TransferMode.active) { + //todo later + } else { + res = await sendCommand(supportIPV6 ? 'EPSV' : 'PASV'); + if (!res.isSuccessCode()) { + throw FTPConnectException('Could not start Passive Mode', res.message); + } + } + + return res; + } + + /// Set the Transfer mode on [socket] to [mode] + Future setTransferType(TransferType pTransferType) async { + //if we already in the same transfer type we do nothing + if (_transferType == pTransferType) return; + switch (pTransferType) { + case TransferType.auto: + // Set to ASCII mode + await sendCommand('TYPE A'); + break; + case TransferType.ascii: + // Set to ASCII mode + await sendCommand('TYPE A'); + break; + case TransferType.binary: + // Set to BINARY mode + await sendCommand('TYPE I'); + break; + default: + break; + } + _transferType = pTransferType; + } + + // Disconnect from the FTP Server + Future disconnect() async { + logger.log('Disconnecting...'); + + try { + await sendCommand('QUIT'); + } catch (ignored) { + // Ignore + } finally { + await _socket.close(); + _socket.shutdown(SocketDirection.both); + } + + logger.log('Disconnected!'); + return true; + } +} diff --git a/lib/core/utils/ftpconnect/src/ftpconnect_base.dart b/lib/core/utils/ftpconnect/src/ftpconnect_base.dart new file mode 100644 index 000000000..f7e8f0085 --- /dev/null +++ b/lib/core/utils/ftpconnect/src/ftpconnect_base.dart @@ -0,0 +1,307 @@ +import 'dart:io'; + +import '../ftpconnect.dart'; +import 'package:path/path.dart'; + +import 'commands/directory.dart'; +import 'commands/file.dart'; +import 'ftp_reply.dart'; +import 'ftp_socket.dart'; +import 'utils.dart'; + +class FTPConnect { + final String _user; + final String _pass; + late FTPSocket _socket; + + /// Create a FTP Client instance + /// + /// [host]: Hostname or IP Address + /// [port]: Port number (Defaults to 21 for FTP and FTPES, 990 for FTPS) + /// [user]: Username (Defaults to anonymous) + /// [pass]: Password if not anonymous login + /// [debug]: Enable Debug Logging + /// [timeout]: Timeout in seconds to wait for responses + FTPConnect( + String host, { + int? port, + String user = 'anonymous', + String pass = '', + bool showLog = false, + SecurityType securityType = SecurityType.FTP, + Logger? logger, + int timeout = 30, + }) : _user = user, + _pass = pass { + port ??= securityType == SecurityType.FTPS ? 990 : 21; + _socket = FTPSocket( + host, + port, + securityType, + logger != null ? logger : Logger(isEnabled: showLog), + timeout, + ); + } + + set transferMode(TransferMode pTransferMode) { + _socket.transferMode = pTransferMode; + } + + set listCommand(ListCommand pListCommand) { + _socket.listCommand = pListCommand; + } + + set supportIPV6(bool pSupportIPV6) => _socket.supportIPV6 = pSupportIPV6; + + /// Set current transfer type of connection + /// + /// Supported types are: [TransferType.auto], [TransferType.ascii], [TransferType.binary], + Future setTransferType(TransferType pTransferType) async { + if (_socket.transferType == pTransferType) return; + await _socket.setTransferType(pTransferType); + } + + /// Connect to the FTP Server + /// return true if we are connected successfully + Future connect() => _socket.connect(_user, _pass); + + /// Disconnect from the FTP Server + /// return true if we are disconnected successfully + Future disconnect() => _socket.disconnect(); + + Future sendCustomCommand(String pCmd) => _socket.sendCommand(pCmd); + + /// Upload the File [fFile] to the current directory + Future uploadFile( + File fFile, { + String sRemoteName = '', + FileProgress? onProgress, + }) { + return FTPFile(_socket).upload( + fFile, + remoteName: sRemoteName, + onProgress: onProgress, + ); + } + + /// Download the Remote File [sRemoteName] to the local File [fFile] + Future downloadFile( + String? sRemoteName, + File fFile, { + FileProgress? onProgress, + }) { + return FTPFile(_socket) + .download(sRemoteName, fFile, onProgress: onProgress); + } + + /// Create a new Directory with the Name of [sDirectory] in the current directory. + /// + /// Returns `true` if the directory was created successfully + /// Returns `false` if the directory could not be created or already exists + Future makeDirectory(String sDirectory) { + return FTPDirectory(_socket).makeDirectory(sDirectory); + } + + /// Deletes the Directory with the Name of [sDirectory] in the current directory. + /// + /// Returns `true` if the directory was deleted successfully + /// Returns `false` if the directory could not be deleted or does not nexist + Future deleteEmptyDirectory(String? sDirectory) { + return FTPDirectory(_socket).deleteEmptyDirectory(sDirectory); + } + + /// Deletes the Directory with the Name of [sDirectory] in the current directory. + /// + /// Returns `true` if the directory was deleted successfully + /// Returns `false` if the directory could not be deleted or does not nexist + /// THIS USEFUL TO DELETE NON EMPTY DIRECTORY + Future deleteDirectory(String sDirectory) async { + String currentDir = await this.currentDirectory(); + if (!await this.changeDirectory(sDirectory)) { + throw FTPConnectException("Couldn't change directory to $sDirectory"); + } + List dirContent = await this.listDirectoryContent(); + await Future.forEach(dirContent, (FTPEntry entry) async { + if (entry.type == FTPEntryType.FILE) { + if (!await deleteFile(entry.name)) { + throw FTPConnectException("Couldn't delete file ${entry.name}"); + } + } else { + if (!await deleteDirectory(entry.name)) { + throw FTPConnectException("Couldn't delete folder ${entry.name}"); + } + } + }); + await this.changeDirectory(currentDir); + return await deleteEmptyDirectory(sDirectory); + } + + /// Change into the Directory with the Name of [sDirectory] within the current directory. + /// + /// Use `..` to navigate back + /// Returns `true` if the directory was changed successfully + /// Returns `false` if the directory could not be changed (does not exist, no permissions or another error) + Future changeDirectory(String? sDirectory) { + return FTPDirectory(_socket).changeDirectory(sDirectory); + } + + /// Returns the current directory + Future currentDirectory() { + return FTPDirectory(_socket).currentDirectory(); + } + + /// Returns the content of the current directory + /// [cmd] refer to the used command for the server, there is servers working + /// with MLSD and other with LIST + Future> listDirectoryContent() { + return FTPDirectory(_socket).directoryContent(); + } + + /// Returns the content names of the current directory + /// [cmd] refer to the used command for the server, there is servers working + /// with MLSD and other with LIST for detailed content + Future> listDirectoryContentOnlyNames() { + return FTPDirectory(_socket).directoryContentNames(); + } + + /// Rename a file (or directory) from [sOldName] to [sNewName] + Future rename(String sOldName, String sNewName) { + return FTPFile(_socket).rename(sOldName, sNewName); + } + + /// Delete the file [sFilename] from the server + Future deleteFile(String? sFilename) { + return FTPFile(_socket).delete(sFilename); + } + + /// check the existence of the file [sFilename] from the server + Future existFile(String sFilename) { + return FTPFile(_socket).exist(sFilename); + } + + /// returns the file [sFilename] size from server, + /// returns -1 if file does not exist + Future sizeFile(String sFilename) { + return FTPFile(_socket).size(sFilename); + } + + /// Upload the File [fileToUpload] to the current directory + /// if [pRemoteName] is not setted the remote file will take take the same local name + /// [pRetryCount] number of attempts + /// + /// this strategy can be used when we don't need to go step by step + /// (connect -> upload -> disconnect) or there is a need for a number of attemps + /// in case of a poor connexion for example + Future uploadFileWithRetry( + File fileToUpload, { + String pRemoteName = '', + int pRetryCount = 1, + FileProgress? onProgress, + }) { + Future uploadFileRetry() async { + bool res = await this.uploadFile( + fileToUpload, + sRemoteName: pRemoteName, + onProgress: onProgress, + ); + return res; + } + + return Utils.retryAction(() => uploadFileRetry(), pRetryCount); + } + + /// Download the Remote File [pRemoteName] to the local File [pLocalFile] + /// [pRetryCount] number of attempts + /// + /// this strategy can be used when we don't need to go step by step + /// (connect -> download -> disconnect) or there is a need for a number of attempts + /// in case of a poor connexion for example + Future downloadFileWithRetry( + String pRemoteName, + File pLocalFile, { + int pRetryCount = 1, + FileProgress? onProgress, + }) { + Future downloadFileRetry() async { + bool res = await this.downloadFile( + pRemoteName, + pLocalFile, + onProgress: onProgress, + ); + return res; + } + + return Utils.retryAction(() => downloadFileRetry(), pRetryCount); + } + + /// Download the Remote Directory [pRemoteDir] to the local File [pLocalDir] + /// [pRetryCount] number of attempts + Future downloadDirectory(String pRemoteDir, Directory pLocalDir, + {int pRetryCount = 1}) { + Future downloadDir(String? pRemoteDir, Directory pLocalDir) async { + await pLocalDir.create(recursive: true); + + //read remote directory content + if (!await this.changeDirectory(pRemoteDir)) { + throw FTPConnectException('Cannot download directory', + '$pRemoteDir not found or inaccessible !'); + } + List dirContent = await this.listDirectoryContent(); + await Future.forEach(dirContent, (FTPEntry entry) async { + if (entry.type == FTPEntryType.FILE) { + File localFile = File(join(pLocalDir.path, entry.name)); + await downloadFile(entry.name, localFile); + } else if (entry.type == FTPEntryType.DIR) { + //create a local directory + var localDir = await Directory(join(pLocalDir.path, entry.name)) + .create(recursive: true); + await downloadDir(entry.name, localDir); + //back to current folder + await this.changeDirectory('..'); + } + }); + return true; + } + + Future downloadDirRetry() async { + bool res = await downloadDir(pRemoteDir, pLocalDir); + return res; + } + + return Utils.retryAction(() => downloadDirRetry(), pRetryCount); + } + + /// check the existence of the Directory with the Name of [pDirectory]. + /// + /// Returns `true` if the directory was changed successfully + /// Returns `false` if the directory could not be changed (does not exist, no permissions or another error) + Future checkFolderExistence(String pDirectory) { + return this.changeDirectory(pDirectory); + } + + /// Create a new Directory with the Name of [pDirectory] in the current directory if it does not exist. + /// + /// Returns `true` if the directory exists or was created successfully + /// Returns `false` if the directory not found and could not be created + Future createFolderIfNotExist(String pDirectory) async { + if (!await checkFolderExistence(pDirectory)) { + return this.makeDirectory(pDirectory); + } + return true; + } +} + +///Note that [LIST] and [MLSD] return content detailed +///BUT [NLST] return only dir/file names inside the given directory +enum ListCommand { NLST, LIST, MLSD } + +enum TransferType { auto, ascii, binary } + +enum TransferMode { active, passive } + +enum SecurityType { FTP, FTPS, FTPES } + +extension CommandListTypeEnum on ListCommand { + String get describeEnum => + this.toString().substring(this.toString().indexOf('.') + 1); +} diff --git a/lib/core/utils/ftpconnect/src/logger.dart b/lib/core/utils/ftpconnect/src/logger.dart new file mode 100644 index 000000000..ce4a1ad6d --- /dev/null +++ b/lib/core/utils/ftpconnect/src/logger.dart @@ -0,0 +1,13 @@ +class Logger { + final bool isEnabled; + + Logger({this.isEnabled = false}); + + void log(String pMessage) { + if (isEnabled) _printLog(pMessage); + } + + void _printLog(String pMessage) { + print('[${DateTime.now().toString()}] $pMessage'); + } +} diff --git a/lib/core/utils/ftpconnect/src/utils.dart b/lib/core/utils/ftpconnect/src/utils.dart new file mode 100644 index 000000000..6fbd41baa --- /dev/null +++ b/lib/core/utils/ftpconnect/src/utils.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +class Utils { + Utils._(); + + static int parsePort(String response, bool isIPV6) { + return isIPV6 ? parsePortEPSV(response) : parsePortPASV(response); + } + + /// Parse the Passive Mode Port from the Servers [sResponse] + /// port format (|||xxxxx|) + static int parsePortEPSV(String sResponse) { + int iParOpen = sResponse.indexOf('('); + int iParClose = sResponse.indexOf(')'); + + if (iParClose > -1 && iParOpen > -1) { + sResponse = sResponse.substring(iParOpen + 4, iParClose - 1); + } + return int.parse(sResponse); + } + + /// Parse the Passive Mode Port from the Servers [sResponse] + /// format 227 Entering Passive Mode (192,168,8,36,8,75). + static int parsePortPASV(String sResponse) { + int iParOpen = sResponse.indexOf('('); + int iParClose = sResponse.indexOf(')'); + + String sParameters = sResponse.substring(iParOpen + 1, iParClose); + List lstParameters = sParameters.split(','); + + int iPort1 = int.parse(lstParameters[lstParameters.length - 2]); + int iPort2 = int.parse(lstParameters[lstParameters.length - 1]); + + return (iPort1 * 256) + iPort2; + } + + ///retry a function [retryCount] times, until exceed [retryCount] or execute the function successfully + ///Return true if the future executed successfully , false other wises + static Future retryAction(FutureOr action(), retryCount) async { + int lAttempts = 1; + bool result = true; + await Future.doWhile(() async { + try { + result = await action(); + //if there is no exception we exit the loop (return false to exit) + return false; + } catch (e) { + if (lAttempts++ >= retryCount) { + throw e; + } + } + //return true to loop again + return true; + }); + return result; + } +} diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index b4ab10ed8..013166031 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -546,7 +546,7 @@ class _AgentViewState extends State with WidgetsBindingObserver { // 语音波形图 - 只在按住时显示 if (isPushToTalkActive) ...[ const AgentSoundBarsWidget(), - const Spacer(), + //const Spacer(), ], // 状态文字和图标 @@ -555,7 +555,7 @@ class _AgentViewState extends State with WidgetsBindingObserver { mainAxisAlignment: isPushToTalkActive ? MainAxisAlignment - .end + .center : MainAxisAlignment .center, children: [ diff --git a/lib/modules/meeting/controllers/meeting_connect_controller.dart b/lib/modules/meeting/controllers/meeting_connect_controller.dart index f6d16e214..af101e1cb 100644 --- a/lib/modules/meeting/controllers/meeting_connect_controller.dart +++ b/lib/modules/meeting/controllers/meeting_connect_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:get/get.dart'; +import 'package:wifi_iot/wifi_iot.dart'; class MeetingConnectController extends GetxController { RxString stateStr = '搜索中...'.obs; @@ -20,6 +21,10 @@ class MeetingConnectController extends GetxController { String _wifiSSID = ''; String _wifiPassword = ''; + String _ftpAddress = ''; + String _ftpPort = ''; + String _ftpAccount = ''; + String _ftpPassword = ''; @override void onInit() { @@ -81,6 +86,7 @@ class MeetingConnectController extends GetxController { } Future _disconnect() async { + await WiFiForIoTPlugin.disconnect(); await _device?.disconnect(); } @@ -139,27 +145,61 @@ class MeetingConnectController extends GetxController { break; case 0x87: // Wi-Fi 开启/关闭 if (value[3] == 0x01 && value[4] == 0x01) { - sendData([0x5a, 0x01, 0x0b]); - sendData([0x5a, 0x01, 0x0c]); + _getWifi(); } break; case 0x0b: // WiFi 热点 SSID _wifiSSID = String.fromCharCodes(value.sublist(3, value.length - 2)); - if (_wifiSSID.isNotEmpty && _wifiPassword.isNotEmpty) { - _connectWifi(); - } + _connectWifi(); break; case 0x0c: // WiFi 热点密码 _wifiPassword = _decodePassword(value); - if (_wifiSSID.isNotEmpty && _wifiPassword.isNotEmpty) { - _connectWifi(); - } + _connectWifi(); + break; + case 0x03: // FTP 地址 + _ftpAddress = String.fromCharCodes(value.sublist(3, value.length - 2)); + sendData([0x5a, 0x01, 0x04]); + break; + case 0x04: // FTP 端口 + _ftpPort = String.fromCharCodes(value.sublist(3, value.length - 2)); + sendData([0x5a, 0x01, 0x05]); + break; + case 0x05: // FTP 账号 + _ftpAccount = String.fromCharCodes(value.sublist(3, value.length - 2)); + sendData([0x5a, 0x01, 0x06]); + break; + case 0x06: // FTP 密码 + _ftpPassword = _decodePassword(value); + _connectFtp(); break; default: } } - void _connectWifi() {} + void _getWifi() async { + await sendData([0x5a, 0x01, 0x0b]); + await sendData([0x5a, 0x01, 0x0c]); + } + + void _connectWifi() async { + if (_wifiSSID.isNotEmpty && _wifiPassword.isNotEmpty) { + stateStr.value = '正在连接 Wi-Fi...'; + await WiFiForIoTPlugin.connect( + _wifiSSID, + password: _wifiPassword, + security: NetworkSecurity.WPA, + ); + stateStr.value = 'Wi-Fi 连接成功'; + sendData([0x5a, 0x01, 0x03]); + } + } + + void _connectFtp() { + if (_ftpAddress.isNotEmpty && + _ftpPort.isNotEmpty && + _ftpAccount.isNotEmpty && + _ftpPassword.isNotEmpty) {} + } String _decodePassword(List data) { final passwordData = data.sublist(3, data.length - 2); diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index be59cb2bd..7c69a9216 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -45,8 +45,8 @@ class SettingsView extends GetView { padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), children: [ _buildUserInfoCard(isDarkMode), - SizedBox(height: 20.h), - _buildAISettingsSection(isDarkMode), + // SizedBox(height: 20.h), + // _buildAISettingsSection(isDarkMode), SizedBox(height: 20.h), _buildSystemSettingsSection(isDarkMode), SizedBox(height: 20.h), @@ -505,9 +505,9 @@ class SettingsView extends GetView { // 清除配对设备 _buildSimpleNavigationSetting( - title: '设备管理', - subtitle: '设备管理列表', - icon: Icons.bluetooth_disabled, + title: 'deviceManagement'.tr, // 设备管理 + subtitle: 'deviceManagementInfo'.tr, // 设备管理列表 + icon: Icons.settings_bluetooth, iconBgColor: isDarkMode ? Colors.red[900]!.withOpacity(0.3) : Colors.red[100]!, @@ -633,8 +633,8 @@ class SettingsView extends GetView { // : Colors.grey[200]), // 测试ble耳机固件版本升级 _buildSimpleNavigationSetting( - title: 'ota升级', - subtitle: '测试ble耳机固件版本升级', + title: '固件升级'.tr, + subtitle: 'ble耳机固件版本升级'.tr, icon: Icons.system_update_alt, iconBgColor: isDarkMode ? Colors.orange[900]!.withOpacity(0.3) @@ -660,140 +660,6 @@ class SettingsView extends GetView { Get.toNamed(Routes.permissions); }, ), - Divider( - height: 1, - color: isDarkMode - ? Colors.white.withOpacity(0.1) - : Colors.grey[200]), - - // 退出登录 - // _buildSimpleNavigationSetting( - // title: 'logout'.tr, // 退出登录 - // icon: Icons.logout, - // iconBgColor: isDarkMode - // ? Colors.red[900]!.withOpacity(0.3) - // : Colors.red[50]!, - // iconColor: isDarkMode ? Colors.red[300]! : Colors.red[600]!, - // titleColor: - // isDarkMode ? Colors.red[300]! : Colors.red[600]!, - // onTap: () { - // // 退出登录确认对话框 - // Get.dialog( - // AlertDialog( - // backgroundColor: - // isDarkMode ? Colors.grey[900] : Colors.white, - // title: Text( - // 'logout'.tr, // 退出登录 - // style: TextStyle( - // color: isDarkMode ? Colors.white : Colors.black87, - // ), - // ), - // content: Text( - // 'logoutTips'.tr, // 确定要退出当前账号吗? - // style: TextStyle( - // color: - // isDarkMode ? Colors.white70 : Colors.black54, - // ), - // ), - // actions: [ - // TextButton( - // onPressed: () => Get.back(), - // child: Text( - // 'cancel'.tr, // 取消 - // style: TextStyle( - // color: isDarkMode - // ? Colors.white70 - // : Colors.black54, - // ), - // ), - // ), - // TextButton( - // onPressed: () { - // Get.back(); - // controller.signOut(); - // }, - // child: Text( - // 'confirm'.tr, // 确定 - // style: TextStyle( - // color: isDarkMode - // ? Colors.red[300] - // : Colors.red[600], - // ), - // ), - // ), - // ], - // ), - // ); - // }, - // isDarkMode: isDarkMode, - // ), - // Divider( - // height: 1, - // color: isDarkMode - // ? Colors.white.withOpacity(0.1) - // : Colors.grey[200]), - - // 注销账号 - // _buildSimpleNavigationSetting( - // title: 'deleteAccount'.tr, // 注销账号 - // icon: Icons.delete_forever, - // iconBgColor: isDarkMode - // ? Colors.red[900]!.withOpacity(0.3) - // : Colors.red[50]!, - // iconColor: isDarkMode ? Colors.red[300]! : Colors.red[600]!, - // titleColor: - // isDarkMode ? Colors.red[300]! : Colors.red[600]!, - // onTap: () { - // // 注销账号确认对话框 - // Get.dialog( - // AlertDialog( - // backgroundColor: - // isDarkMode ? Colors.grey[900] : Colors.white, - // title: Text( - // 'deleteAccount'.tr, // 注销账号 - // style: TextStyle( - // color: isDarkMode ? Colors.white : Colors.black87, - // ), - // ), - // content: Text( - // 'deleteAccountTips'.tr, // 确定要永久删除您的账号吗?此操作不可撤销 - // style: TextStyle( - // color: - // isDarkMode ? Colors.white70 : Colors.black54, - // ), - // ), - // actions: [ - // TextButton( - // onPressed: () => Get.back(), - // child: Text( - // 'cancel'.tr, // 取消 - // style: TextStyle( - // color: isDarkMode - // ? Colors.white70 - // : Colors.black54, - // ), - // ), - // ), - // TextButton( - // onPressed: () { - // Get.back(); - // controller.deleteAccount(); - // }, - // child: Text( - // 'confirm'.tr, // 确定 - // style: TextStyle( - // color: isDarkMode - // ? Colors.red[300] - // : Colors.red[600], - // ), - // ), - // ), - // ], - // ), - // ); - // }, - // isDarkMode: isDarkMode, - // ), ], ), ), @@ -873,7 +739,7 @@ class SettingsView extends GetView { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'DeepSound', + 'DeapSound', style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.w500, diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index fb1d36394..aaa4887c5 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -822,39 +822,77 @@ class AzureAsrHelper(private val context: Context) { // 3. 单独封装线程启动逻辑 fun startCaptureThread() { - // 开始录音 - audioRecord?.startRecording() - captureThread = Thread { - // 根据实际采样率计算缓冲区大小 - val supportedSampleRates = intArrayOf(16000, 8000, 11025, 22050, 44100) - var bufferSize = 0 - // 查找设备支持的最佳采样率 - val sampleRate = supportedSampleRates.firstOrNull { rate -> - bufferSize = AudioRecord.getMinBufferSize( - rate, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT - ) - bufferSize > 0 // 返回正值表示支持 - } ?: 16000 // 默认回退值 - - val buffer = ByteArray(bufferSize) - - while (!Thread.interrupted() && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { - if (!isPaused) { - // 读取音频 - val bytesRead = audioRecord?.read(buffer, 0, buffer.size) ?: 0 - if (bytesRead > 0) { + try { + // 确保之前的线程已停止 + stopCaptureThread() + + // 开始录音 + audioRecord?.startRecording() + + captureThread = Thread({ + android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO) + + val supportedSampleRates = intArrayOf(16000, 8000, 11025, 22050, 44100) + var bufferSize = 0 + val sampleRate = supportedSampleRates.firstOrNull { rate -> + bufferSize = AudioRecord.getMinBufferSize( + rate, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + bufferSize > 0 + } ?: 16000 + + val buffer = ByteArray(bufferSize) + + try { + while (!Thread.currentThread().isInterrupted && + audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { - // 写入Azure流 - pushAudioStream?.write(buffer) - - // 保存录音 - recordfile?.saveAudioDataToWav(buffer) + if (!isPaused) { + val bytesRead = audioRecord?.read(buffer, 0, buffer.size) ?: 0 + if (bytesRead > 0) { + // 安全地写入Azure流 + try { + pushAudioStream?.write(buffer) + recordfile?.saveAudioDataToWav(buffer) + } catch (e: Exception) { + Log.w(tag, "写入音频流失败: ${e.message}") + } + } else if (bytesRead < 0) { + Log.w(tag, "AudioRecord读取错误: $bytesRead") + break + } + } else { + Thread.sleep(10) // 暂停时短暂休眠 + } } + } catch (e: InterruptedException) { + Log.d(tag, "音频捕获线程被中断") + } catch (e: Exception) { + Log.e(tag, "音频捕获异常: ${e.message}", e) + } + }, "AudioCaptureThread") + + captureThread?.start() + } catch (e: Exception) { + Log.e(tag, "启动音频捕获线程失败: ${e.message}", e) + throw e + } + } + + private fun stopCaptureThread() { + captureThread?.let { thread -> + if (thread.isAlive) { + thread.interrupt() + try { + thread.join(500) // 等待最多500ms + } catch (e: InterruptedException) { + Log.w(tag, "等待音频线程结束被中断") } } - }.apply { start() } + } + captureThread = null } /** @@ -1050,4 +1088,4 @@ class AzureAsrHelper(private val context: Context) { } -} \ No newline at end of file +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index e01fc5fb8..32cdf99ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git ref: main + wifi_iot: ^0.3.19+2 permission_handler: ^10.4.5 web_socket_channel: ^2.4.0 flutter_dotenv: ^5.1.0