Commit 989cdcd0 authored by Lorenz Meier's avatar Lorenz Meier

Merge pull request #818 from DonLakeFlyer/FTPProgressBar

FTP progress bar and download speed
parents 0cda5053 5868dbc1
...@@ -59,15 +59,17 @@ void QGCUASFileManagerUnitTest::init(void) ...@@ -59,15 +59,17 @@ void QGCUASFileManagerUnitTest::init(void)
Q_ASSERT(connected); Q_ASSERT(connected);
Q_UNUSED(connected); // Silent release build compiler warning Q_UNUSED(connected); // Silent release build compiler warning
connected = connect(_fileManager, SIGNAL(statusMessage(const QString&)), this, SLOT(statusMessage(const QString&))); connected = connect(_fileManager, SIGNAL(listEntry(const QString&)), this, SLOT(listEntry(const QString&)));
Q_ASSERT(connected); Q_ASSERT(connected);
_rgSignals[statusMessageSignalIndex] = SIGNAL(statusMessage(const QString&)); _rgSignals[listEntrySignalIndex] = SIGNAL(listEntry(const QString&));
_rgSignals[errorMessageSignalIndex] = SIGNAL(errorMessage(const QString&));
_rgSignals[resetStatusMessagesSignalIndex] = SIGNAL(resetStatusMessages(void));
_rgSignals[listCompleteSignalIndex] = SIGNAL(listComplete(void)); _rgSignals[listCompleteSignalIndex] = SIGNAL(listComplete(void));
_rgSignals[openFileLengthSignalIndex] = SIGNAL(openFileLength(unsigned int));
_rgSignals[downloadFileLengthSignalIndex] = SIGNAL(downloadFileLength(unsigned int));
_rgSignals[downloadFileCompleteSignalIndex] = SIGNAL(downloadFileComplete(void));
_rgSignals[errorMessageSignalIndex] = SIGNAL(errorMessage(const QString&));
_multiSpy = new MultiSignalSpy(); _multiSpy = new MultiSignalSpy();
Q_CHECK_PTR(_multiSpy); Q_CHECK_PTR(_multiSpy);
QCOMPARE(_multiSpy->init(_fileManager, _rgSignals, _cSignals), true); QCOMPARE(_multiSpy->init(_fileManager, _rgSignals, _cSignals), true);
...@@ -86,11 +88,11 @@ void QGCUASFileManagerUnitTest::cleanup(void) ...@@ -86,11 +88,11 @@ void QGCUASFileManagerUnitTest::cleanup(void)
_multiSpy = NULL; _multiSpy = NULL;
} }
/// @brief Connected to QGCUASFileManager statusMessage signal in order to catch list command output /// @brief Connected to QGCUASFileManager listEntry signal in order to catch list entries
void QGCUASFileManagerUnitTest::statusMessage(const QString& msg) void QGCUASFileManagerUnitTest::listEntry(const QString& entry)
{ {
// Keep a list of all names received so we can test it for correctness // Keep a list of all names received so we can test it for correctness
_fileListReceived += msg; _fileListReceived += entry;
} }
...@@ -151,14 +153,13 @@ void QGCUASFileManagerUnitTest::_listTest(void) ...@@ -151,14 +153,13 @@ void QGCUASFileManagerUnitTest::_listTest(void)
Q_ASSERT(_multiSpy->checkNoSignals() == true); Q_ASSERT(_multiSpy->checkNoSignals() == true);
// QGCUASFileManager::listDirectory signalling as follows: // QGCUASFileManager::listDirectory signalling as follows:
// Emits the resetStatusMessages signal // Emits a listEntry signal for each list entry
// Emits a statusMessage signal for each list entry
// Emits an errorMessage signal if: // Emits an errorMessage signal if:
// It gets a Nak back // It gets a Nak back
// Sequence number is incorrrect on any response // Sequence number is incorrrect on any response
// CRC is incorrect on any responses // CRC is incorrect on any responses
// List entry is formatted incorrectly // List entry is formatted incorrectly
// It is possible to get a number of good statusMessage signals, followed by an errorMessage signal // It is possible to get a number of good listEntry signals, followed by an errorMessage signal
// Emits listComplete after it receives the final list entry // Emits listComplete after it receives the final list entry
// If an errorMessage signal is signalled no listComplete is signalled // If an errorMessage signal is signalled no listComplete is signalled
...@@ -166,7 +167,7 @@ void QGCUASFileManagerUnitTest::_listTest(void) ...@@ -166,7 +167,7 @@ void QGCUASFileManagerUnitTest::_listTest(void)
// We should get a single resetStatusMessages signal // We should get a single resetStatusMessages signal
// We should get a single errorMessage signal // We should get a single errorMessage signal
_fileManager->listDirectory("/bogus"); _fileManager->listDirectory("/bogus");
QCOMPARE(_multiSpy->checkOnlySignalByMask(errorMessageSignalMask | resetStatusMessagesSignalMask), true); QCOMPARE(_multiSpy->checkOnlySignalByMask(errorMessageSignalMask), true);
_multiSpy->clearAllSignals(); _multiSpy->clearAllSignals();
// Setup the mock file server with a valid directory list // Setup the mock file server with a valid directory list
...@@ -187,15 +188,15 @@ void QGCUASFileManagerUnitTest::_listTest(void) ...@@ -187,15 +188,15 @@ void QGCUASFileManagerUnitTest::_listTest(void)
// For simulated server errors on subsequent Acks, the first Ack will go through. This means we should have gotten some // For simulated server errors on subsequent Acks, the first Ack will go through. This means we should have gotten some
// partial results. In the case of the directory list test set, all entries fit into the first ack, so we should have // partial results. In the case of the directory list test set, all entries fit into the first ack, so we should have
// gotten back all of them. // gotten back all of them.
QCOMPARE(_multiSpy->getSpyByIndex(statusMessageSignalIndex)->count(), fileList.count()); QCOMPARE(_multiSpy->getSpyByIndex(listEntrySignalIndex)->count(), fileList.count());
_multiSpy->clearSignalByIndex(statusMessageSignalIndex); _multiSpy->clearSignalByIndex(listEntrySignalIndex);
// And then it should have errored out because the next list Request would have failed. // And then it should have errored out because the next list Request would have failed.
QCOMPARE(_multiSpy->checkOnlySignalByMask(errorMessageSignalMask | resetStatusMessagesSignalMask), true); QCOMPARE(_multiSpy->checkOnlySignalByMask(errorMessageSignalMask), true);
} else { } else {
// For the simulated errors which failed the intial response we should not have gotten any results back at all. // For the simulated errors which failed the intial response we should not have gotten any results back at all.
// Just an error. // Just an error.
QCOMPARE(_multiSpy->checkOnlySignalByMask(errorMessageSignalMask | resetStatusMessagesSignalMask), true); QCOMPARE(_multiSpy->checkOnlySignalByMask(errorMessageSignalMask), true);
} }
// Set everything back to initial state // Set everything back to initial state
...@@ -206,9 +207,9 @@ void QGCUASFileManagerUnitTest::_listTest(void) ...@@ -206,9 +207,9 @@ void QGCUASFileManagerUnitTest::_listTest(void)
// Send a list command at the root of the directory tree which should succeed // Send a list command at the root of the directory tree which should succeed
_fileManager->listDirectory("/"); _fileManager->listDirectory("/");
QCOMPARE(_multiSpy->checkSignalByMask(resetStatusMessagesSignalMask | listCompleteSignalMask), true); QCOMPARE(_multiSpy->checkSignalByMask(listCompleteSignalMask), true);
QCOMPARE(_multiSpy->checkNoSignalByMask(errorMessageSignalMask), true); QCOMPARE(_multiSpy->checkNoSignalByMask(errorMessageSignalMask), true);
QCOMPARE(_multiSpy->getSpyByIndex(statusMessageSignalIndex)->count(), fileList.count()); QCOMPARE(_multiSpy->getSpyByIndex(listEntrySignalIndex)->count(), fileList.count());
QVERIFY(_fileListReceived == fileList); QVERIFY(_fileListReceived == fileList);
} }
...@@ -238,26 +239,26 @@ void QGCUASFileManagerUnitTest::_downloadTest(void) ...@@ -238,26 +239,26 @@ void QGCUASFileManagerUnitTest::_downloadTest(void)
Q_ASSERT(_multiSpy->checkNoSignals() == true); Q_ASSERT(_multiSpy->checkNoSignals() == true);
// QGCUASFileManager::downloadPath works as follows: // QGCUASFileManager::downloadPath works as follows:
// Emits the resetStatusMessages signal
// Sends an Open Command to the server // Sends an Open Command to the server
// Expects an Ack Response back from the server with the correct sequence numner // Expects an Ack Response back from the server with the correct sequence numner
// Emits an errorMessage signal if it gets a Nak back // Emits an errorMessage signal if it gets a Nak back
// Emits an openFileLength signal with the file length if it gets back a good Ack // Emits an downloadFileLength signal with the file length if it gets back a good Ack
// Sends subsequent Read commands to the server until it gets the full file contents back // Sends subsequent Read commands to the server until it gets the full file contents back
// Emits a downloadFileProgress for each read command ack it gets back
// Sends Terminate command to server when download is complete to close Open command // Sends Terminate command to server when download is complete to close Open command
// Mock file server will signal terminateCommandReceived when it gets a Terminate command // Mock file server will signal terminateCommandReceived when it gets a Terminate command
// Sends statusMessage signal to indicate the download is complete // Sends downloadFileComplete signal to indicate the download is complete
// Emits an errorMessage signal if sequence number is incorrrect on any response // Emits an errorMessage signal if sequence number is incorrrect on any response
// Emits an errorMessage signal if CRC is incorrect on any responses // Emits an errorMessage signal if CRC is incorrect on any responses
// Expected signals if the Open command fails for any reason // Expected signals if the Open command fails for any reason
quint16 signalMaskOpenFailure = resetStatusMessagesSignalMask | errorMessageSignalMask; quint16 signalMaskOpenFailure = errorMessageSignalMask;
// Expected signals if the Read command fails for any reason // Expected signals if the Read command fails for any reason
quint16 signalMaskReadFailure = resetStatusMessagesSignalMask | openFileLengthSignalMask | errorMessageSignalMask; quint16 signalMaskReadFailure = downloadFileLengthSignalMask | errorMessageSignalMask;
// Expected signals if the downloadPath command succeeds // Expected signals if the downloadPath command succeeds
quint16 signalMaskDownloadSuccess = resetStatusMessagesSignalMask | openFileLengthSignalMask | statusMessageSignalMask; quint16 signalMaskDownloadSuccess = downloadFileLengthSignalMask | downloadFileCompleteSignalMask;
// Send a bogus path // Send a bogus path
// We should get a single resetStatusMessages signal // We should get a single resetStatusMessages signal
...@@ -296,7 +297,8 @@ void QGCUASFileManagerUnitTest::_downloadTest(void) ...@@ -296,7 +297,8 @@ void QGCUASFileManagerUnitTest::_downloadTest(void)
// For simulated server errors on subsequent Acks, the first Ack will go through. We must handle things differently depending // For simulated server errors on subsequent Acks, the first Ack will go through. We must handle things differently depending
// on whether the downloaded file requires multiple packets to complete the download. // on whether the downloaded file requires multiple packets to complete the download.
if (testCase->fMultiPacketResponse) { if (testCase->fMultiPacketResponse) {
// The downloaded file requires multiple Acks to complete. Hence second Read should have failed. // The downloaded file requires multiple Acks to complete. Hence first Read should have succeeded and sent one downloadFileComplete.
// Second Read should have failed.
QCOMPARE(_multiSpy->checkOnlySignalByMask(signalMaskReadFailure), true); QCOMPARE(_multiSpy->checkOnlySignalByMask(signalMaskReadFailure), true);
// Open command succeeded, so we should get a Terminate for the open // Open command succeeded, so we should get a Terminate for the open
...@@ -333,7 +335,7 @@ void QGCUASFileManagerUnitTest::_downloadTest(void) ...@@ -333,7 +335,7 @@ void QGCUASFileManagerUnitTest::_downloadTest(void)
QCOMPARE(_multiSpy->checkOnlySignalByMask(signalMaskDownloadSuccess), true); QCOMPARE(_multiSpy->checkOnlySignalByMask(signalMaskDownloadSuccess), true);
// Make sure the file length coming back through the openFileLength signal is correct // Make sure the file length coming back through the openFileLength signal is correct
QVERIFY(_multiSpy->getSpyByIndex(openFileLengthSignalIndex)->takeFirst().at(0).toInt() == testCase->length); QVERIFY(_multiSpy->getSpyByIndex(downloadFileLengthSignalIndex)->takeFirst().at(0).toInt() == testCase->length);
_multiSpy->clearAllSignals(); _multiSpy->clearAllSignals();
......
...@@ -58,29 +58,28 @@ private slots: ...@@ -58,29 +58,28 @@ private slots:
void _listTest(void); void _listTest(void);
void _downloadTest(void); void _downloadTest(void);
// Connected to QGCUASFileManager statusMessage signal // Connected to QGCUASFileManager listEntry signal
void statusMessage(const QString&); void listEntry(const QString& entry);
private: private:
void _validateFileContents(const QString& filePath, uint8_t length); void _validateFileContents(const QString& filePath, uint8_t length);
enum { enum {
statusMessageSignalIndex = 0, listEntrySignalIndex = 0,
errorMessageSignalIndex,
resetStatusMessagesSignalIndex,
listCompleteSignalIndex, listCompleteSignalIndex,
openFileLengthSignalIndex, downloadFileLengthSignalIndex,
downloadFileCompleteSignalIndex,
errorMessageSignalIndex,
maxSignalIndex maxSignalIndex
}; };
enum { enum {
statusMessageSignalMask = 1 << statusMessageSignalIndex, listEntrySignalMask = 1 << listEntrySignalIndex,
errorMessageSignalMask = 1 << errorMessageSignalIndex,
resetStatusMessagesSignalMask = 1 << resetStatusMessagesSignalIndex,
listCompleteSignalMask = 1 << listCompleteSignalIndex, listCompleteSignalMask = 1 << listCompleteSignalIndex,
openFileLengthSignalMask = 1 << openFileLengthSignalIndex, downloadFileLengthSignalMask = 1 << downloadFileLengthSignalIndex,
downloadFileCompleteSignalMask = 1 << downloadFileCompleteSignalIndex,
errorMessageSignalMask = 1 << errorMessageSignalIndex,
}; };
MockUAS _mockUAS; MockUAS _mockUAS;
MockMavlinkFileServer _mockFileServer; MockMavlinkFileServer _mockFileServer;
......
...@@ -107,7 +107,9 @@ void QGCUASFileManager::_openAckResponse(Request* openAck) ...@@ -107,7 +107,9 @@ void QGCUASFileManager::_openAckResponse(Request* openAck)
// File length comes back in data // File length comes back in data
Q_ASSERT(openAck->hdr.size == sizeof(uint32_t)); Q_ASSERT(openAck->hdr.size == sizeof(uint32_t));
emit openFileLength(openAck->openFileLength); emit downloadFileLength(openAck->openFileLength);
// Start the sequence of read commands
_readOffset = 0; // Start reading at beginning of file _readOffset = 0; // Start reading at beginning of file
_readFileAccumulator.clear(); // Start with an empty file _readFileAccumulator.clear(); // Start with an empty file
...@@ -142,7 +144,7 @@ void QGCUASFileManager::_closeReadSession(bool success) ...@@ -142,7 +144,7 @@ void QGCUASFileManager::_closeReadSession(bool success)
} }
file.close(); file.close();
_emitStatusMessage(tr("Download complete '%1'").arg(downloadFilePath)); emit downloadFileComplete();
} }
// Close the open session // Close the open session
...@@ -167,6 +169,7 @@ void QGCUASFileManager::_readAckResponse(Request* readAck) ...@@ -167,6 +169,7 @@ void QGCUASFileManager::_readAckResponse(Request* readAck)
} }
_readFileAccumulator.append((const char*)readAck->data, readAck->hdr.size); _readFileAccumulator.append((const char*)readAck->data, readAck->hdr.size);
emit downloadFileProgress(_readFileAccumulator.length());
if (readAck->hdr.size == sizeof(readAck->data)) { if (readAck->hdr.size == sizeof(readAck->data)) {
// Possibly still more data to read, send next read request // Possibly still more data to read, send next read request
...@@ -221,7 +224,9 @@ void QGCUASFileManager::_listAckResponse(Request* listAck) ...@@ -221,7 +224,9 @@ void QGCUASFileManager::_listAckResponse(Request* listAck)
// Returned names are prepended with D for directory, F for file, U for unknown // Returned names are prepended with D for directory, F for file, U for unknown
if (*ptr == 'F' || *ptr == 'D') { if (*ptr == 'F' || *ptr == 'D') {
// put it in the view // put it in the view
_emitStatusMessage(ptr); _emitListEntry(ptr);
} else {
qDebug() << "unknown entry" << ptr;
} }
// account for the name + NUL // account for the name + NUL
...@@ -357,9 +362,6 @@ void QGCUASFileManager::listDirectory(const QString& dirPath) ...@@ -357,9 +362,6 @@ void QGCUASFileManager::listDirectory(const QString& dirPath)
return; return;
} }
// clear the text widget
emit resetStatusMessages();
// initialise the lister // initialise the lister
_listPath = dirPath; _listPath = dirPath;
_listOffset = 0; _listOffset = 0;
...@@ -411,8 +413,6 @@ void QGCUASFileManager::downloadPath(const QString& from, const QDir& downloadDi ...@@ -411,8 +413,6 @@ void QGCUASFileManager::downloadPath(const QString& from, const QDir& downloadDi
i++; // move past slash i++; // move past slash
_readFileDownloadFilename = from.right(from.size() - i); _readFileDownloadFilename = from.right(from.size() - i);
emit resetStatusMessages();
_currentOperation = kCOOpen; _currentOperation = kCOOpen;
Request request; Request request;
...@@ -532,10 +532,10 @@ void QGCUASFileManager::_emitErrorMessage(const QString& msg) ...@@ -532,10 +532,10 @@ void QGCUASFileManager::_emitErrorMessage(const QString& msg)
emit errorMessage(msg); emit errorMessage(msg);
} }
void QGCUASFileManager::_emitStatusMessage(const QString& msg) void QGCUASFileManager::_emitListEntry(const QString& entry)
{ {
qDebug() << "QGCUASFileManager: Status" << msg; qDebug() << "QGCUASFileManager: list entry" << entry;
emit statusMessage(msg); emit listEntry(entry);
} }
/// @brief Sends the specified Request out to the UAS. /// @brief Sends the specified Request out to the UAS.
......
...@@ -45,11 +45,28 @@ public: ...@@ -45,11 +45,28 @@ public:
static const int ackTimerTimeoutMsecs = 1000; static const int ackTimerTimeoutMsecs = 1000;
signals: signals:
void statusMessage(const QString& msg); /// @brief Signalled whenever an error occurs during the listDirectory or downloadPath methods.
void resetStatusMessages();
void errorMessage(const QString& msg); void errorMessage(const QString& msg);
// Signals associated with the listDirectory method
/// @brief Signalled to indicate a new directory entry was received.
void listEntry(const QString& entry);
/// @brief Signalled after listDirectory completes. If an error occurs during directory listing this signal will not be emitted.
void listComplete(void); void listComplete(void);
void openFileLength(unsigned int length);
// Signals associated with the downloadPath method
/// @brief Signalled after downloadPath is called to indicate length of file being downloaded
void downloadFileLength(unsigned int length);
/// @brief Signalled during file download to indicate download progress
/// @param bytesReceived Number of bytes currently received from file
void downloadFileProgress(unsigned int bytesReceived);
/// @brief Signaled to indicate completion of file download. If an error occurs during download this signal will not be emitted.
void downloadFileComplete(void);
public slots: public slots:
void receiveMessage(LinkInterface* link, mavlink_message_t message); void receiveMessage(LinkInterface* link, mavlink_message_t message);
...@@ -140,7 +157,7 @@ protected: ...@@ -140,7 +157,7 @@ protected:
void _setupAckTimeout(void); void _setupAckTimeout(void);
void _clearAckTimeout(void); void _clearAckTimeout(void);
void _emitErrorMessage(const QString& msg); void _emitErrorMessage(const QString& msg);
void _emitStatusMessage(const QString& msg); void _emitListEntry(const QString& entry);
void _sendRequest(Request* request); void _sendRequest(Request* request);
void _fillRequestWithString(Request* request, const QString& str); void _fillRequestWithString(Request* request, const QString& str);
void _openAckResponse(Request* openAck); void _openAckResponse(Request* openAck);
......
This diff is collapsed.
...@@ -42,29 +42,38 @@ protected: ...@@ -42,29 +42,38 @@ protected:
private slots: private slots:
void _refreshTree(void); void _refreshTree(void);
void _downloadFiles(void); void _listEntryReceived(const QString& entry);
void _treeStatusMessage(const QString& msg); void _listErrorMessage(const QString& msg);
void _treeErrorMessage(const QString& msg);
void _listComplete(void); void _listComplete(void);
void _downloadStatusMessage(const QString& msg);
void _downloadFile(void);
void _downloadLength(unsigned int length);
void _downloadProgress(unsigned int length);
void _downloadErrorMessage(const QString& msg);
void _downloadComplete(void);
void _currentItemChanged(QTreeWidgetItem* current, QTreeWidgetItem* previous); void _currentItemChanged(QTreeWidgetItem* current, QTreeWidgetItem* previous);
void _listCompleteTimeout(void);
private: private:
void _setupListCompleteTimeout(void); void _connectDownloadSignals(void);
void _clearListCompleteTimeout(void); void _disconnectDownloadSignals(void);
void _connectListSignals(void);
void _disconnectListSignals(void);
void _requestDirectoryList(const QString& dir); void _requestDirectoryList(const QString& dir);
static const int _typeFile = QTreeWidgetItem::UserType + 1; static const int _typeFile = QTreeWidgetItem::UserType + 1;
static const int _typeDir = QTreeWidgetItem::UserType + 2; static const int _typeDir = QTreeWidgetItem::UserType + 2;
static const int _typeError = QTreeWidgetItem::UserType + 3; static const int _typeError = QTreeWidgetItem::UserType + 3;
QTimer _listCompleteTimer; ///> Used to signal a timeout waiting for a listComplete signal
static const int _listCompleteTimerTimeoutMsecs = 5000; ///> Timeout in msecs for listComplete timer
QList<int> _walkIndexStack; QList<int> _walkIndexStack;
QList<QTreeWidgetItem*> _walkItemStack; QList<QTreeWidgetItem*> _walkItemStack;
Ui::QGCUASFileView _ui; Ui::QGCUASFileView _ui;
QString _downloadFilename; ///< File currently being downloaded, not including path
QTime _downloadStartTime; ///< Time at which download started
bool _listInProgress; ///< Indicates that a listDirectory command is in progress
bool _downloadInProgress; ///< Indicates that a downloadPath command is in progress
}; };
#endif // QGCUASFILEVIEW_H #endif // QGCUASFILEVIEW_H
...@@ -14,13 +14,23 @@ ...@@ -14,13 +14,23 @@
<string>Form</string> <string>Form</string>
</property> </property>
<layout class="QGridLayout" name="gridLayout"> <layout class="QGridLayout" name="gridLayout">
<item row="1" column="1"> <item row="3" column="1">
<widget class="QPushButton" name="listFilesButton"> <widget class="QPushButton" name="listFilesButton">
<property name="text"> <property name="text">
<string>List Files</string> <string>List Files</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="1" column="0" colspan="3">
<widget class="QProgressBar" name="progressBar">
<property name="value">
<number>24</number>
</property>
<property name="textVisible">
<bool>false</bool>
</property>
</widget>
</item>
<item row="0" column="0" colspan="3"> <item row="0" column="0" colspan="3">
<widget class="QTreeWidget" name="treeWidget"> <widget class="QTreeWidget" name="treeWidget">
<property name="headerHidden"> <property name="headerHidden">
...@@ -33,7 +43,7 @@ ...@@ -33,7 +43,7 @@
</column> </column>
</widget> </widget>
</item> </item>
<item row="1" column="2"> <item row="3" column="2">
<widget class="QPushButton" name="downloadButton"> <widget class="QPushButton" name="downloadButton">
<property name="enabled"> <property name="enabled">
<bool>false</bool> <bool>false</bool>
...@@ -43,6 +53,19 @@ ...@@ -43,6 +53,19 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="2" column="0" colspan="3">
<widget class="QLabel" name="statusText">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
</layout> </layout>
</widget> </widget>
<resources/> <resources/>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment