diff options
-rw-r--r-- | .travis.yml | 1 | ||||
-rw-r--r-- | CMakeLists.txt | 8 | ||||
-rw-r--r-- | examples/CMakeLists.txt | 2 | ||||
-rw-r--r-- | examples/qmc-example.cpp | 151 | ||||
-rw-r--r-- | lib/connection.cpp | 15 | ||||
-rw-r--r-- | lib/connection.h | 6 | ||||
-rw-r--r-- | lib/eventitem.cpp | 26 | ||||
-rw-r--r-- | lib/eventitem.h | 15 | ||||
-rw-r--r-- | lib/events/eventcontent.cpp | 32 | ||||
-rw-r--r-- | lib/events/eventcontent.h | 11 | ||||
-rw-r--r-- | lib/events/roommessageevent.cpp | 65 | ||||
-rw-r--r-- | lib/events/roommessageevent.h | 21 | ||||
-rw-r--r-- | lib/events/stateevent.h | 6 | ||||
-rw-r--r-- | lib/room.cpp | 187 | ||||
-rw-r--r-- | lib/room.h | 25 | ||||
-rw-r--r-- | libqmatrixclient.pri | 2 |
16 files changed, 496 insertions, 77 deletions
diff --git a/.travis.yml b/.travis.yml index b515b8fb..fc143a62 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ addons: packages: - g++-5 - qt57base + - qt57multimedia - valgrind homebrew: packages: diff --git a/CMakeLists.txt b/CMakeLists.txt index 8a3193a4..c48a7ba9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,7 +40,9 @@ foreach (FLAG all "" pedantic extra error=return-type no-unused-parameter no-gnu endif () endforeach () -find_package(Qt5 5.4.1 REQUIRED Network Gui) +# Qt 5.6+ is the formal requirement but for the sake of supporting UBPorts +# upstream Qt 5.4 is required. +find_package(Qt5 5.4.1 REQUIRED Network Gui Multimedia) get_filename_component(Qt5_Prefix "${Qt5_DIR}/../../../.." ABSOLUTE) if (GTAD_PATH) @@ -140,7 +142,7 @@ add_library(QMatrixClient ${libqmatrixclient_SRCS} ${libqmatrixclient_job_SRCS} ${libqmatrixclient_csdef_SRCS} ${libqmatrixclient_cswellknown_SRCS} ${libqmatrixclient_asdef_SRCS} ${libqmatrixclient_isdef_SRCS}) -set(API_VERSION "0.4") +set(API_VERSION "0.5") set_property(TARGET QMatrixClient PROPERTY VERSION "${API_VERSION}.0") set_property(TARGET QMatrixClient PROPERTY SOVERSION ${API_VERSION} ) set_property(TARGET QMatrixClient PROPERTY @@ -152,7 +154,7 @@ target_include_directories(QMatrixClient PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/lib> $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}> ) -target_link_libraries(QMatrixClient Qt5::Core Qt5::Network Qt5::Gui) +target_link_libraries(QMatrixClient Qt5::Core Qt5::Network Qt5::Gui Qt5::Multimedia) add_executable(qmc-example ${example_SRCS}) target_link_libraries(qmc-example Qt5::Core QMatrixClient) diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 49e0089a..cd5e15ed 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -45,7 +45,7 @@ foreach (FLAG all "" pedantic extra error=return-type no-unused-parameter no-gnu endif () endforeach () -find_package(Qt5 5.6 REQUIRED Network Gui) +find_package(Qt5 5.6 REQUIRED Network Gui Multimedia) get_filename_component(Qt5_Prefix "${Qt5_DIR}/../../../.." ABSOLUTE) find_package(QMatrixClient REQUIRED) diff --git a/examples/qmc-example.cpp b/examples/qmc-example.cpp index 894167a9..8fbf4824 100644 --- a/examples/qmc-example.cpp +++ b/examples/qmc-example.cpp @@ -9,6 +9,8 @@ #include <QtCore/QCoreApplication> #include <QtCore/QStringBuilder> #include <QtCore/QTimer> +#include <QtCore/QTemporaryFile> +#include <QtCore/QFileInfo> #include <iostream> #include <functional> @@ -20,7 +22,7 @@ using namespace std::placeholders; class QMCTest : public QObject { public: - QMCTest(Connection* conn, QString targetRoomName, QString source); + QMCTest(Connection* conn, QString testRoomName, QString source); private slots: void setupAndRun(); @@ -29,6 +31,9 @@ class QMCTest : public QObject void doTests(); void loadMembers(); void sendMessage(); + void sendFile(); + void checkFileSendingOutcome(const QString& txnId, + const QString& fileName); void addAndRemoveTag(); void sendAndRedact(); void checkRedactionOutcome(const QString& evtIdToRedact, @@ -47,6 +52,8 @@ class QMCTest : public QObject QString origin; QString targetRoomName; Room* targetRoom = nullptr; + + bool validatePendingEvent(const QString& txnId); }; #define QMC_CHECK(description, condition) \ @@ -69,6 +76,14 @@ class QMCTest : public QObject } \ } +bool QMCTest::validatePendingEvent(const QString& txnId) +{ + auto it = targetRoom->findPendingEvent(txnId); + return it != targetRoom->pendingEvents().end() && + it->deliveryStatus() == EventStatus::Submitted && + (*it)->transactionId() == txnId; +} + QMCTest::QMCTest(Connection* conn, QString testRoomName, QString source) : c(conn), origin(std::move(source)), targetRoomName(std::move(testRoomName)) { @@ -142,7 +157,8 @@ void QMCTest::run() { // TODO: Waiting for proper futures to come so that it could be: // targetRoom->postPlainText(origin % ": All tests finished") -// .then(this, &QMCTest::leave); +// .then(this, &QMCTest::leave); // Qt-style +// .then([this] { leave(); }); // STL-style auto txnId = targetRoom->postPlainText(origin % ": All tests finished"); connect(targetRoom, &Room::messageSent, this, @@ -166,6 +182,7 @@ void QMCTest::doTests() return; sendMessage(); + sendFile(); addAndRemoveTag(); sendAndRedact(); markDirectChat(); @@ -206,19 +223,133 @@ void QMCTest::sendMessage() running.push_back("Message sending"); cout << "Sending a message" << endl; auto txnId = targetRoom->postPlainText("Hello, " % origin % " is here"); - auto& pending = targetRoom->pendingEvents(); - if (pending.empty()) + if (!validatePendingEvent(txnId)) { + cout << "Invalid pending event right after submitting" << endl; QMC_CHECK("Message sending", false); return; } - auto it = std::find_if(pending.begin(), pending.end(), - [&txnId] (const auto& e) { - return e->transactionId() == txnId; + + QMetaObject::Connection sc; + sc = connect(targetRoom, &Room::pendingEventAboutToMerge, this, + [this,sc,txnId] (const RoomEvent* evt, int pendingIdx) { + const auto& pendingEvents = targetRoom->pendingEvents(); + Q_ASSERT(pendingIdx >= 0 && pendingIdx < int(pendingEvents.size())); + + if (evt->transactionId() != txnId) + return; + + disconnect(sc); + + QMC_CHECK("Message sending", + is<RoomMessageEvent>(*evt) && !evt->id().isEmpty() && + pendingEvents[size_t(pendingIdx)]->transactionId() + == evt->transactionId()); + }); +} + +void QMCTest::sendFile() +{ + running.push_back("File sending"); + cout << "Sending a file" << endl; + auto* tf = new QTemporaryFile; + if (!tf->open()) + { + cout << "Failed to create a temporary file" << endl; + QMC_CHECK("File sending", false); + return; + } + tf->write("Test"); + tf->close(); + // QFileInfo::fileName brings only the file name; QFile::fileName brings + // the full path + const auto tfName = QFileInfo(*tf).fileName(); + cout << "Sending file" << tfName.toStdString() << endl; + const auto txnId = targetRoom->postFile("Test file", + QUrl::fromLocalFile(tf->fileName())); + if (!validatePendingEvent(txnId)) + { + cout << "Invalid pending event right after submitting" << endl; + QMC_CHECK("File sending", false); + delete tf; + return; + } + + QMetaObject::Connection scCompleted, scFailed; + scCompleted = connect(targetRoom, &Room::fileTransferCompleted, this, + [this,txnId,tf,tfName,scCompleted,scFailed] (const QString& id) { + auto fti = targetRoom->fileTransferInfo(id); + Q_ASSERT(fti.status == FileTransferInfo::Completed); + + if (id != txnId) + return; + + disconnect(scCompleted); + disconnect(scFailed); + delete tf; + + checkFileSendingOutcome(txnId, tfName); + }); + scFailed = connect(targetRoom, &Room::fileTransferFailed, this, + [this,txnId,tf,scCompleted,scFailed] + (const QString& id, const QString& error) { + if (id != txnId) + return; + + targetRoom->postPlainText(origin % ": File upload failed: " % error); + disconnect(scCompleted); + disconnect(scFailed); + delete tf; + + QMC_CHECK("File sending", false); + }); +} + +void QMCTest::checkFileSendingOutcome(const QString& txnId, + const QString& fileName) +{ + auto it = targetRoom->findPendingEvent(txnId); + if (it == targetRoom->pendingEvents().end()) + { + cout << "Pending file event dropped before upload completion" + << endl; + QMC_CHECK("File sending", false); + return; + } + if (it->deliveryStatus() != EventStatus::FileUploaded) + { + cout << "Pending file event status upon upload completion is " + << it->deliveryStatus() << " != FileUploaded(" + << EventStatus::FileUploaded << ')' << endl; + QMC_CHECK("File sending", false); + return; + } + + QMetaObject::Connection sc; + sc = connect(targetRoom, &Room::pendingEventAboutToMerge, this, + [this,sc,txnId,fileName] (const RoomEvent* evt, int pendingIdx) { + const auto& pendingEvents = targetRoom->pendingEvents(); + Q_ASSERT(pendingIdx >= 0 && pendingIdx < int(pendingEvents.size())); + + if (evt->transactionId() != txnId) + return; + + cout << "Event " << txnId.toStdString() + << " arrived in the timeline" << endl; + disconnect(sc); + visit(*evt, + [&] (const RoomMessageEvent& e) { + QMC_CHECK("File sending", + !e.id().isEmpty() && + pendingEvents[size_t(pendingIdx)] + ->transactionId() == txnId && + e.hasFileContent() && + e.content()->fileInfo()->originalName == fileName); + }, + [this] (const RoomEvent&) { + QMC_CHECK("File sending", false); }); - QMC_CHECK("Message sending", it != pending.end()); - // TODO: Wait when it actually gets sent; check that it obtained an id - // Independently, check when it shows up in the timeline. + }); } void QMCTest::addAndRemoveTag() diff --git a/lib/connection.cpp b/lib/connection.cpp index a16bc753..c17cbffc 100644 --- a/lib/connection.cpp +++ b/lib/connection.cpp @@ -43,6 +43,7 @@ #include <QtCore/QStringBuilder> #include <QtCore/QElapsedTimer> #include <QtCore/QRegularExpression> +#include <QtCore/QMimeDatabase> #include <QtCore/QCoreApplication> using namespace QMatrixClient; @@ -466,13 +467,21 @@ MediaThumbnailJob* Connection::getThumbnail(const QUrl& url, } UploadContentJob* Connection::uploadContent(QIODevice* contentSource, - const QString& filename, const QString& contentType) const + const QString& filename, const QString& overrideContentType) const { + auto contentType = overrideContentType; + if (contentType.isEmpty()) + { + contentType = + QMimeDatabase().mimeTypeForFileNameAndData(filename, contentSource) + .name(); + contentSource->open(QIODevice::ReadOnly); + } return callApi<UploadContentJob>(contentSource, filename, contentType); } UploadContentJob* Connection::uploadFile(const QString& fileName, - const QString& contentType) + const QString& overrideContentType) { auto sourceFile = new QFile(fileName); if (!sourceFile->open(QIODevice::ReadOnly)) @@ -482,7 +491,7 @@ UploadContentJob* Connection::uploadFile(const QString& fileName, return nullptr; } return uploadContent(sourceFile, QFileInfo(*sourceFile).fileName(), - contentType); + overrideContentType); } GetContentJob* Connection::getContent(const QString& mediaId) const diff --git a/lib/connection.h b/lib/connection.h index 9a94aad6..ff3e2028 100644 --- a/lib/connection.h +++ b/lib/connection.h @@ -402,10 +402,10 @@ namespace QMatrixClient // QIODevice* should already be open UploadContentJob* uploadContent(QIODevice* contentSource, - const QString& filename = {}, - const QString& contentType = {}) const; + const QString& filename = {}, + const QString& overrideContentType = {}) const; UploadContentJob* uploadFile(const QString& fileName, - const QString& contentType = {}); + const QString& overrideContentType = {}); GetContentJob* getContent(const QString& mediaId) const; GetContentJob* getContent(const QUrl& url) const; // If localFilename is empty, a temporary file will be created diff --git a/lib/eventitem.cpp b/lib/eventitem.cpp index 79ef769c..8ec3fe48 100644 --- a/lib/eventitem.cpp +++ b/lib/eventitem.cpp @@ -17,3 +17,29 @@ */ #include "eventitem.h" + +#include "events/roommessageevent.h" +#include "events/roomavatarevent.h" + +using namespace QMatrixClient; + +void PendingEventItem::setFileUploaded(const QUrl& remoteUrl) +{ + // TODO: eventually we might introduce hasFileContent to RoomEvent, + // and unify the code below. + if (auto* rme = getAs<RoomMessageEvent>()) + { + Q_ASSERT(rme->hasFileContent()); + rme->editContent([remoteUrl] (EventContent::TypedBase& ec) { + ec.fileInfo()->url = remoteUrl; + }); + } + if (auto* rae = getAs<RoomAvatarEvent>()) + { + Q_ASSERT(rae->content().fileInfo()); + rae->editContent([remoteUrl] (EventContent::FileInfo& fi) { + fi.url = remoteUrl; + }); + } + setStatus(EventStatus::FileUploaded); +} diff --git a/lib/eventitem.h b/lib/eventitem.h index 5f1d10c9..36ed2132 100644 --- a/lib/eventitem.h +++ b/lib/eventitem.h @@ -33,16 +33,17 @@ namespace QMatrixClient /** Special marks an event can assume * * This is used to hint at a special status of some events in UI. - * Most status values are mutually exclusive. + * All values except Redacted and Hidden are mutually exclusive. */ enum Code { Normal = 0x0, //< No special designation Submitted = 0x01, //< The event has just been submitted for sending - Departed = 0x02, //< The event has left the client - ReachedServer = 0x03, //< The server has received the event - SendingFailed = 0x04, //< The server could not receive the event + FileUploaded = 0x02, //< The file attached to the event has been uploaded to the server + Departed = 0x03, //< The event has left the client + ReachedServer = 0x04, //< The server has received the event + SendingFailed = 0x05, //< The server could not receive the event Redacted = 0x08, //< The event has been redacted - Hidden = 0x10, //< The event should be hidden + Hidden = 0x10, //< The event should not be shown in the timeline }; Q_DECLARE_FLAGS(Status, Code) Q_FLAG(Status) @@ -70,6 +71,9 @@ namespace QMatrixClient return std::exchange(evt, move(other)); } + protected: + template <typename EventT> + EventT* getAs() { return eventCast<EventT>(evt); } private: RoomEventPtr evt; }; @@ -116,6 +120,7 @@ namespace QMatrixClient QString annotation() const { return _annotation; } void setDeparted() { setStatus(EventStatus::Departed); } + void setFileUploaded(const QUrl& remoteUrl); void setReachedServer(const QString& eventId) { setStatus(EventStatus::ReachedServer); diff --git a/lib/events/eventcontent.cpp b/lib/events/eventcontent.cpp index a6b1c763..9a5e872c 100644 --- a/lib/events/eventcontent.cpp +++ b/lib/events/eventcontent.cpp @@ -17,6 +17,8 @@ */ #include "eventcontent.h" + +#include "converters.h" #include "util.h" #include <QtCore/QMimeDatabase> @@ -30,7 +32,7 @@ QJsonObject Base::toJson() const return o; } -FileInfo::FileInfo(const QUrl& u, int payloadSize, const QMimeType& mimeType, +FileInfo::FileInfo(const QUrl& u, qint64 payloadSize, const QMimeType& mimeType, const QString& originalFilename) : mimeType(mimeType), url(u), payloadSize(payloadSize) , originalName(originalFilename) @@ -41,7 +43,7 @@ FileInfo::FileInfo(const QUrl& u, const QJsonObject& infoJson, : originalInfoJson(infoJson) , mimeType(QMimeDatabase().mimeTypeForName(infoJson["mimetype"_ls].toString())) , url(u) - , payloadSize(infoJson["size"_ls].toInt()) + , payloadSize(fromJson<qint64>(infoJson["size"_ls])) , originalName(originalFilename) { if (!mimeType.isValid()) @@ -51,13 +53,15 @@ FileInfo::FileInfo(const QUrl& u, const QJsonObject& infoJson, void FileInfo::fillInfoJson(QJsonObject* infoJson) const { Q_ASSERT(infoJson); - infoJson->insert(QStringLiteral("size"), payloadSize); - infoJson->insert(QStringLiteral("mimetype"), mimeType.name()); + if (payloadSize != -1) + infoJson->insert(QStringLiteral("size"), payloadSize); + if (mimeType.isValid()) + infoJson->insert(QStringLiteral("mimetype"), mimeType.name()); } -ImageInfo::ImageInfo(const QUrl& u, int fileSize, QMimeType mimeType, - const QSize& imageSize) - : FileInfo(u, fileSize, mimeType), imageSize(imageSize) +ImageInfo::ImageInfo(const QUrl& u, qint64 fileSize, QMimeType mimeType, + const QSize& imageSize, const QString& originalFilename) + : FileInfo(u, fileSize, mimeType, originalFilename), imageSize(imageSize) { } ImageInfo::ImageInfo(const QUrl& u, const QJsonObject& infoJson, @@ -69,8 +73,10 @@ ImageInfo::ImageInfo(const QUrl& u, const QJsonObject& infoJson, void ImageInfo::fillInfoJson(QJsonObject* infoJson) const { FileInfo::fillInfoJson(infoJson); - infoJson->insert(QStringLiteral("w"), imageSize.width()); - infoJson->insert(QStringLiteral("h"), imageSize.height()); + if (imageSize.width() != -1) + infoJson->insert(QStringLiteral("w"), imageSize.width()); + if (imageSize.height() != -1) + infoJson->insert(QStringLiteral("h"), imageSize.height()); } Thumbnail::Thumbnail(const QJsonObject& infoJson) @@ -80,7 +86,9 @@ Thumbnail::Thumbnail(const QJsonObject& infoJson) void Thumbnail::fillInfoJson(QJsonObject* infoJson) const { - infoJson->insert(QStringLiteral("thumbnail_url"), url.toString()); - infoJson->insert(QStringLiteral("thumbnail_info"), - toInfoJson<ImageInfo>(*this)); + if (url.isValid()) + infoJson->insert(QStringLiteral("thumbnail_url"), url.toString()); + if (!imageSize.isEmpty()) + infoJson->insert(QStringLiteral("thumbnail_info"), + toInfoJson<ImageInfo>(*this)); } diff --git a/lib/events/eventcontent.h b/lib/events/eventcontent.h index ea321fb6..0588c0e2 100644 --- a/lib/events/eventcontent.h +++ b/lib/events/eventcontent.h @@ -88,7 +88,7 @@ namespace QMatrixClient class FileInfo { public: - explicit FileInfo(const QUrl& u, int payloadSize = -1, + explicit FileInfo(const QUrl& u, qint64 payloadSize = -1, const QMimeType& mimeType = {}, const QString& originalFilename = {}); FileInfo(const QUrl& u, const QJsonObject& infoJson, @@ -109,7 +109,7 @@ namespace QMatrixClient QJsonObject originalInfoJson; QMimeType mimeType; QUrl url; - int payloadSize; + qint64 payloadSize; QString originalName; }; @@ -127,9 +127,10 @@ namespace QMatrixClient class ImageInfo : public FileInfo { public: - explicit ImageInfo(const QUrl& u, int fileSize = -1, + explicit ImageInfo(const QUrl& u, qint64 fileSize = -1, QMimeType mimeType = {}, - const QSize& imageSize = {}); + const QSize& imageSize = {}, + const QString& originalFilename = {}); ImageInfo(const QUrl& u, const QJsonObject& infoJson, const QString& originalFilename = {}); @@ -167,6 +168,7 @@ namespace QMatrixClient explicit TypedBase(const QJsonObject& o = {}) : Base(o) { } virtual QMimeType type() const = 0; virtual const FileInfo* fileInfo() const { return nullptr; } + virtual FileInfo* fileInfo() { return nullptr; } virtual const Thumbnail* thumbnailInfo() const { return nullptr; } }; @@ -196,6 +198,7 @@ namespace QMatrixClient QMimeType type() const override { return InfoT::mimeType; } const FileInfo* fileInfo() const override { return this; } + FileInfo* fileInfo() override { return this; } protected: void fillJson(QJsonObject* json) const override diff --git a/lib/events/roommessageevent.cpp b/lib/events/roommessageevent.cpp index 1c5cf058..c3007fa0 100644 --- a/lib/events/roommessageevent.cpp +++ b/lib/events/roommessageevent.cpp @@ -21,6 +21,9 @@ #include "logging.h" #include <QtCore/QMimeDatabase> +#include <QtCore/QFileInfo> +#include <QtGui/QImageReader> +#include <QtMultimedia/QMediaResource> using namespace QMatrixClient; using namespace EventContent; @@ -71,8 +74,8 @@ MsgType jsonToMsgType(const QString& matrixType) return MsgType::Unknown; } -inline QJsonObject toMsgJson(const QString& plainBody, const QString& jsonMsgType, - TypedBase* content) +QJsonObject RoomMessageEvent::assembleContentJson(const QString& plainBody, + const QString& jsonMsgType, TypedBase* content) { auto json = content ? content->toJson() : QJsonObject(); json.insert(QStringLiteral("msgtype"), jsonMsgType); @@ -86,7 +89,7 @@ static const auto BodyKey = "body"_ls; RoomMessageEvent::RoomMessageEvent(const QString& plainBody, const QString& jsonMsgType, TypedBase* content) : RoomEvent(typeId(), matrixTypeId(), - toMsgJson(plainBody, jsonMsgType, content)) + assembleContentJson(plainBody, jsonMsgType, content)) , _content(content) { } @@ -95,6 +98,40 @@ RoomMessageEvent::RoomMessageEvent(const QString& plainBody, : RoomMessageEvent(plainBody, msgTypeToJson(msgType), content) { } +TypedBase* contentFromFile(const QFileInfo& file, bool asGenericFile) +{ + auto filePath = file.absoluteFilePath(); + auto localUrl = QUrl::fromLocalFile(filePath); + auto mimeType = QMimeDatabase().mimeTypeForFile(file); + if (!asGenericFile) + { + auto mimeTypeName = mimeType.name(); + if (mimeTypeName.startsWith("image/")) + return new ImageContent(localUrl, file.size(), mimeType, + QImageReader(filePath).size(), + file.fileName()); + + // duration can only be obtained asynchronously and can only be reliably + // done by starting to play the file. Left for a future implementation. + if (mimeTypeName.startsWith("video/")) + return new VideoContent(localUrl, file.size(), mimeType, + QMediaResource(localUrl).resolution(), + file.fileName()); + + if (mimeTypeName.startsWith("audio/")) + return new AudioContent(localUrl, file.size(), mimeType, + file.fileName()); + } + return new FileContent(localUrl, file.size(), mimeType, file.fileName()); +} + +RoomMessageEvent::RoomMessageEvent(const QString& plainBody, + const QFileInfo& file, bool asGenericFile) + : RoomMessageEvent(plainBody, + asGenericFile ? QStringLiteral("m.file") : rawMsgTypeForFile(file), + contentFromFile(file, asGenericFile)) +{ } + RoomMessageEvent::RoomMessageEvent(const QJsonObject& obj) : RoomEvent(typeId(), obj), _content(nullptr) { @@ -162,6 +199,25 @@ bool RoomMessageEvent::hasThumbnail() const return content() && content()->thumbnailInfo(); } +QString rawMsgTypeForMimeType(const QMimeType& mimeType) +{ + auto name = mimeType.name(); + return name.startsWith("image/") ? QStringLiteral("m.image") : + name.startsWith("video/") ? QStringLiteral("m.video") : + name.startsWith("audio/") ? QStringLiteral("m.audio") : + QStringLiteral("m.file"); +} + +QString RoomMessageEvent::rawMsgTypeForUrl(const QUrl& url) +{ + return rawMsgTypeForMimeType(QMimeDatabase().mimeTypeForUrl(url)); +} + +QString RoomMessageEvent::rawMsgTypeForFile(const QFileInfo& fi) +{ + return rawMsgTypeForMimeType(QMimeDatabase().mimeTypeForFile(fi)); +} + TextContent::TextContent(const QString& text, const QString& contentType) : mimeType(QMimeDatabase().mimeTypeForName(contentType)), body(text) { @@ -200,7 +256,8 @@ void TextContent::fillJson(QJsonObject* json) const } } -LocationContent::LocationContent(const QString& geoUri, const ImageInfo& thumbnail) +LocationContent::LocationContent(const QString& geoUri, + const Thumbnail& thumbnail) : geoUri(geoUri), thumbnail(thumbnail) { } diff --git a/lib/events/roommessageevent.h b/lib/events/roommessageevent.h index 4c29a93e..d5b570f5 100644 --- a/lib/events/roommessageevent.h +++ b/lib/events/roommessageevent.h @@ -21,6 +21,8 @@ #include "roomevent.h" #include "eventcontent.h" +class QFileInfo; + namespace QMatrixClient { namespace MessageEventContent = EventContent; // Back-compatibility @@ -49,6 +51,9 @@ namespace QMatrixClient explicit RoomMessageEvent(const QString& plainBody, MsgType msgType = MsgType::Text, EventContent::TypedBase* content = nullptr); + explicit RoomMessageEvent(const QString& plainBody, + const QFileInfo& file, + bool asGenericFile = false); explicit RoomMessageEvent(const QJsonObject& obj); MsgType msgtype() const; @@ -56,14 +61,27 @@ namespace QMatrixClient QString plainBody() const; EventContent::TypedBase* content() const { return _content.data(); } + template <typename VisitorT> + void editContent(VisitorT visitor) + { + visitor(*_content); + editJson()[ContentKeyL] = + assembleContentJson(plainBody(), rawMsgtype(), content()); + } QMimeType mimeType() const; bool hasTextContent() const; bool hasFileContent() const; bool hasThumbnail() const; + static QString rawMsgTypeForUrl(const QUrl& url); + static QString rawMsgTypeForFile(const QFileInfo& fi); + private: QScopedPointer<EventContent::TypedBase> _content; + static QJsonObject assembleContentJson(const QString& plainBody, + const QString& jsonMsgType, EventContent::TypedBase* content); + REGISTER_ENUM(MsgType) }; REGISTER_EVENT_TYPE(RoomMessageEvent) @@ -112,7 +130,7 @@ namespace QMatrixClient { public: LocationContent(const QString& geoUri, - const ImageInfo& thumbnail); + const Thumbnail& thumbnail = {}); explicit LocationContent(const QJsonObject& json); QMimeType type() const override; @@ -132,6 +150,7 @@ namespace QMatrixClient class PlayableContent : public ContentT { public: + using ContentT::ContentT; PlayableContent(const QJsonObject& json) : ContentT(json) , duration(ContentT::originalInfoJson["duration"_ls].toInt()) diff --git a/lib/events/stateevent.h b/lib/events/stateevent.h index d82de7e1..d488c0a0 100644 --- a/lib/events/stateevent.h +++ b/lib/events/stateevent.h @@ -88,6 +88,12 @@ namespace QMatrixClient { } const ContentT& content() const { return _content; } + template <typename VisitorT> + void editContent(VisitorT&& visitor) + { + visitor(_content); + editJson()[ContentKeyL] = _content.toJson(); + } [[deprecated("Use prevContent instead")]] const ContentT* prev_content() const { return prevContent(); } const ContentT* prevContent() const diff --git a/lib/room.cpp b/lib/room.cpp index 156b5b1f..8f50607f 100644 --- a/lib/room.cpp +++ b/lib/room.cpp @@ -53,6 +53,7 @@ #include <QtCore/QPointer> #include <QtCore/QDir> #include <QtCore/QTemporaryFile> +#include <QtCore/QMimeDatabase> #include <array> #include <functional> @@ -67,9 +68,11 @@ using std::llround; enum EventsPlacement : int { Older = -1, Newer = 1 }; -// A workaround for MSVC 2015 that fails with "error C2440: 'return': -// cannot convert from 'initializer list' to 'QMatrixClient::FileTransferInfo'" -#if (defined(_MSC_VER) && _MSC_VER < 1910) || (defined(__GNUC__) && __GNUC__ <= 4) +// A workaround for MSVC 2015 and older GCC's that don't handle initializer +// lists right (MSVC 2015, notably, fails with "error C2440: 'return': +// cannot convert from 'initializer list' to 'QMatrixClient::FileTransferInfo'") +#if (defined(_MSC_VER) && _MSC_VER < 1910) || \ + (defined(__GNUC__) && !defined(__clang__) && __GNUC__ <= 4) # define WORKAROUND_EXTENDED_INITIALIZER_LIST #endif @@ -126,15 +129,17 @@ class Room::Private struct FileTransferPrivateInfo { -#ifdef WORKAROUND_EXTENDED_INITIALIZER_LIST FileTransferPrivateInfo() = default; - FileTransferPrivateInfo(BaseJob* j, QString fileName) - : job(j), localFileInfo(fileName) + FileTransferPrivateInfo(BaseJob* j, const QString& fileName, + bool isUploading = false) + : status(FileTransferInfo::Started), job(j) + , localFileInfo(fileName), isUpload(isUploading) { } -#endif + + FileTransferInfo::Status status = FileTransferInfo::None; QPointer<BaseJob> job = nullptr; QFileInfo localFileInfo { }; - FileTransferInfo::Status status = FileTransferInfo::Started; + bool isUpload = false; qint64 progress = 0; qint64 total = -1; @@ -234,6 +239,8 @@ class Room::Private return sendEvent(makeEvent<EventT>(std::forward<ArgTs>(eventArgs)...)); } + RoomEvent* addAsPending(RoomEventPtr&& event); + QString doSendEvent(const RoomEvent* pEvent); PendingEvents::iterator findAsPending(const RoomEvent* rawEvtPtr); void onEventSendingFailure(const RoomEvent* pEvent, @@ -592,6 +599,19 @@ Room::rev_iter_t Room::findInTimeline(const QString& evtId) const return timelineEdge(); } +Room::PendingEvents::iterator Room::findPendingEvent(const QString& txnId) +{ + return std::find_if(d->unsyncedEvents.begin(), d->unsyncedEvents.end(), + [txnId] (const auto& item) { return item->transactionId() == txnId; }); +} + +Room::PendingEvents::const_iterator +Room::findPendingEvent(const QString& txnId) const +{ + return std::find_if(d->unsyncedEvents.cbegin(), d->unsyncedEvents.cend(), + [txnId] (const auto& item) { return item->transactionId() == txnId; }); +} + void Room::Private::getAllMembers() { // If already loaded or already loading, there's nothing to do here. @@ -909,7 +929,7 @@ QString Room::Private::fileNameToDownload(const RoomMessageEvent* event) const return fileName; } -QUrl Room::urlToThumbnail(const QString& eventId) +QUrl Room::urlToThumbnail(const QString& eventId) const { if (auto* event = d->getEventWithFile(eventId)) if (event->hasThumbnail()) @@ -923,7 +943,7 @@ QUrl Room::urlToThumbnail(const QString& eventId) return {}; } -QUrl Room::urlToDownload(const QString& eventId) +QUrl Room::urlToDownload(const QString& eventId) const { if (auto* event = d->getEventWithFile(eventId)) { @@ -935,7 +955,7 @@ QUrl Room::urlToDownload(const QString& eventId) return {}; } -QString Room::fileNameToDownload(const QString& eventId) +QString Room::fileNameToDownload(const QString& eventId) const { if (auto* event = d->getEventWithFile(eventId)) return d->fileNameToDownload(event); @@ -969,13 +989,28 @@ FileTransferInfo Room::fileTransferInfo(const QString& id) const fti.localPath = QUrl::fromLocalFile(infoIt->localFileInfo.absoluteFilePath()); return fti; #else - return { infoIt->status, int(progress), int(total), + return { infoIt->status, infoIt->isUpload, int(progress), int(total), QUrl::fromLocalFile(infoIt->localFileInfo.absolutePath()), QUrl::fromLocalFile(infoIt->localFileInfo.absoluteFilePath()) }; #endif } +QUrl Room::fileSource(const QString& id) const +{ + auto url = urlToDownload(id); + if (url.isValid()) + return url; + + // No urlToDownload means it's a pending or completed upload. + auto infoIt = d->fileTransfers.find(id); + if (infoIt != d->fileTransfers.end()) + return QUrl::fromLocalFile(infoIt->localFileInfo.absoluteFilePath()); + + qCWarning(MAIN) << "File source for identifier" << id << "not found"; + return {}; +} + QString Room::prettyPrint(const QString& plainText) const { return QMatrixClient::prettyPrint(plainText); @@ -1255,7 +1290,7 @@ void Room::updateData(SyncRoomData&& data, bool fromCache) } } -QString Room::Private::sendEvent(RoomEventPtr&& event) +RoomEvent* Room::Private::addAsPending(RoomEventPtr&& event) { if (event->transactionId().isEmpty()) event->setTransactionId(connection->generateTxnId()); @@ -1263,7 +1298,12 @@ QString Room::Private::sendEvent(RoomEventPtr&& event) emit q->pendingEventAboutToAdd(pEvent); unsyncedEvents.emplace_back(move(event)); emit q->pendingEventAdded(); - return doSendEvent(pEvent); + return pEvent; +} + +QString Room::Private::sendEvent(RoomEventPtr&& event) +{ + return doSendEvent(addAsPending(std::move(event))); } QString Room::Private::doSendEvent(const RoomEvent* pEvent) @@ -1336,10 +1376,35 @@ void Room::Private::onEventSendingFailure(const RoomEvent* pEvent, QString Room::retryMessage(const QString& txnId) { - auto it = std::find_if(d->unsyncedEvents.begin(), d->unsyncedEvents.end(), - [txnId] (const auto& evt) { return evt->transactionId() == txnId; }); + const auto it = findPendingEvent(txnId); Q_ASSERT(it != d->unsyncedEvents.end()); qDebug(EVENTS) << "Retrying transaction" << txnId; + const auto& transferIt = d->fileTransfers.find(txnId); + if (transferIt != d->fileTransfers.end()) + { + Q_ASSERT(transferIt->isUpload); + if (transferIt->status == FileTransferInfo::Completed) + { + qCDebug(MAIN) << "File for transaction" << txnId + << "has already been uploaded, bypassing re-upload"; + } else { + if (isJobRunning(transferIt->job)) + { + qCDebug(MAIN) << "Abandoning the upload job for transaction" + << txnId << "and starting again"; + transferIt->job->abandon(); + emit fileTransferFailed(txnId, tr("File upload will be retried")); + } + uploadFile(txnId, + QUrl::fromLocalFile(transferIt->localFileInfo.absoluteFilePath())); + // FIXME: Content type is no more passed here but it should + } + } + if (it->deliveryStatus() == EventStatus::ReachedServer) + { + qCWarning(MAIN) << "The previous attempt has reached the server; two" + " events are likely to be in the timeline after retry"; + } it->resetStatus(); return d->doSendEvent(it->event()); } @@ -1350,7 +1415,22 @@ void Room::discardMessage(const QString& txnId) [txnId] (const auto& evt) { return evt->transactionId() == txnId; }); Q_ASSERT(it != d->unsyncedEvents.end()); qDebug(EVENTS) << "Discarding transaction" << txnId; - emit pendingEventAboutToDiscard(it - d->unsyncedEvents.begin()); + const auto& transferIt = d->fileTransfers.find(txnId); + if (transferIt != d->fileTransfers.end()) + { + Q_ASSERT(transferIt->isUpload); + if (isJobRunning(transferIt->job)) + { + transferIt->status = FileTransferInfo::Cancelled; + transferIt->job->abandon(); + emit fileTransferFailed(txnId, tr("File upload cancelled")); + } else if (transferIt->status == FileTransferInfo::Completed) + { + qCWarning(MAIN) << "File for transaction" << txnId + << "has been uploaded but the message was discarded"; + } + } + emit pendingEventAboutToDiscard(int(it - d->unsyncedEvents.begin())); d->unsyncedEvents.erase(it); emit pendingEventDiscarded(); } @@ -1377,6 +1457,61 @@ QString Room::postHtmlText(const QString& plainText, const QString& html) return postHtmlMessage(plainText, html, MessageEventType::Text); } +QString Room::postFile(const QString& plainText, const QUrl& localPath, + bool asGenericFile) +{ + QFileInfo localFile { localPath.toLocalFile() }; + Q_ASSERT(localFile.isFile()); + // Remote URL will only be known after upload; fill in the local path + // to enable the preview while the event is pending. + auto* pEvent = d->addAsPending( + makeEvent<RoomMessageEvent>(plainText, localFile, asGenericFile)); + const auto txnId = pEvent->transactionId(); + uploadFile(txnId, localPath); + QMetaObject::Connection cCompleted, cCancelled; + cCompleted = connect(this, &Room::fileTransferCompleted, this, + [cCompleted,cCancelled,this,pEvent,txnId] + (const QString& id, QUrl, const QUrl& mxcUri) { + if (id == txnId) + { + auto it = d->findAsPending(pEvent); + if (it != d->unsyncedEvents.end()) + { + it->setFileUploaded(mxcUri); + emit pendingEventChanged( + int(it - d->unsyncedEvents.begin())); + d->doSendEvent(pEvent); + } else { + // Normally in this situation we should instruct + // the media server to delete the file; alas, there's no + // API specced for that. + qCWarning(MAIN) << "File uploaded to" << mxcUri + << "but the event referring to it was cancelled"; + } + disconnect(cCompleted); + disconnect(cCancelled); + } + }); + cCancelled = connect(this, &Room::fileTransferCancelled, this, + [cCompleted,cCancelled,this,pEvent,txnId] (const QString& id) { + if (id == txnId) + { + auto it = d->findAsPending(pEvent); + if (it != d->unsyncedEvents.end()) + { + emit pendingEventAboutToDiscard( + int(it - d->unsyncedEvents.begin())); + d->unsyncedEvents.erase(it); + emit pendingEventDiscarded(); + } + disconnect(cCompleted); + disconnect(cCancelled); + } + }); + + return txnId; +} + QString Room::postEvent(RoomEvent* event) { if (usesEncryption()) @@ -1532,7 +1667,7 @@ void Room::uploadFile(const QString& id, const QUrl& localFilename, auto job = connection()->uploadFile(fileName, overrideContentType); if (isJobRunning(job)) { - d->fileTransfers.insert(id, { job, fileName }); + d->fileTransfers.insert(id, { job, fileName, true }); connect(job, &BaseJob::uploadProgress, this, [this,id] (qint64 sent, qint64 total) { d->fileTransfers[id].update(sent, total); @@ -1555,8 +1690,8 @@ void Room::downloadFile(const QString& eventId, const QUrl& localFilename) if (ongoingTransfer != d->fileTransfers.end() && ongoingTransfer->status == FileTransferInfo::Started) { - qCWarning(MAIN) << "Download for" << eventId - << "already started; to restart, cancel it first"; + qCWarning(MAIN) << "Transfer for" << eventId + << "is ongoing; download won't start"; return; } @@ -1819,11 +1954,15 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events) break; it = nextPending + 1; - emit q->pendingEventAboutToMerge(nextPending->get(), - nextPendingPair.second - unsyncedEvents.begin()); + auto* nextPendingEvt = nextPending->get(); + emit q->pendingEventAboutToMerge(nextPendingEvt, + int(nextPendingPair.second - unsyncedEvents.begin())); qDebug(EVENTS) << "Merging pending event from transaction" - << (*nextPending)->transactionId() << "into" - << (*nextPending)->id(); + << nextPendingEvt->transactionId() << "into" + << nextPendingEvt->id(); + auto transfer = fileTransfers.take(nextPendingEvt->transactionId()); + if (transfer.status != FileTransferInfo::None) + fileTransfers.insert(nextPendingEvt->id(), transfer); unsyncedEvents.erase(nextPendingPair.second); if (auto insertedSize = moveEventsToTimeline({nextPending, it}, Newer)) { @@ -42,10 +42,17 @@ namespace QMatrixClient class SetRoomStateWithKeyJob; class RedactEventJob; + /** The data structure used to expose file transfer information to views + * + * This is specifically tuned to work with QML exposing all traits as + * Q_PROPERTY values. + */ class FileTransferInfo { Q_GADGET + Q_PROPERTY(bool isUpload MEMBER isUpload CONSTANT) Q_PROPERTY(bool active READ active CONSTANT) + Q_PROPERTY(bool started READ started CONSTANT) Q_PROPERTY(bool completed READ completed CONSTANT) Q_PROPERTY(bool failed READ failed CONSTANT) Q_PROPERTY(int progress MEMBER progress CONSTANT) @@ -53,16 +60,17 @@ namespace QMatrixClient Q_PROPERTY(QUrl localDir MEMBER localDir CONSTANT) Q_PROPERTY(QUrl localPath MEMBER localPath CONSTANT) public: - enum Status { None, Started, Completed, Failed }; + enum Status { None, Started, Completed, Failed, Cancelled }; Status status = None; + bool isUpload = false; int progress = 0; int total = -1; QUrl localDir { }; QUrl localPath { }; - bool active() const - { return status == Started || status == Completed; } + bool started() const { return status == Started; } bool completed() const { return status == Completed; } + bool active() const { return started() || completed(); } bool failed() const { return status == Failed; } }; @@ -226,6 +234,8 @@ namespace QMatrixClient rev_iter_t findInTimeline(TimelineItem::index_t index) const; rev_iter_t findInTimeline(const QString& evtId) const; + PendingEvents::iterator findPendingEvent(const QString & txnId); + PendingEvents::const_iterator findPendingEvent(const QString & txnId) const; bool displayed() const; /// Mark the room as currently displayed to the user @@ -334,10 +344,11 @@ namespace QMatrixClient /// Get the list of users this room is a direct chat with QList<User*> directChatUsers() const; - Q_INVOKABLE QUrl urlToThumbnail(const QString& eventId); - Q_INVOKABLE QUrl urlToDownload(const QString& eventId); - Q_INVOKABLE QString fileNameToDownload(const QString& eventId); + Q_INVOKABLE QUrl urlToThumbnail(const QString& eventId) const; + Q_INVOKABLE QUrl urlToDownload(const QString& eventId) const; + Q_INVOKABLE QString fileNameToDownload(const QString& eventId) const; Q_INVOKABLE FileTransferInfo fileTransferInfo(const QString& id) const; + Q_INVOKABLE QUrl fileSource(const QString& id) const; /** Pretty-prints plain text into HTML * As of now, it's exactly the same as QMatrixClient::prettyPrint(); @@ -365,6 +376,8 @@ namespace QMatrixClient QString postHtmlMessage(const QString& plainText, const QString& html, MessageEventType type); QString postHtmlText(const QString& plainText, const QString& html); + QString postFile(const QString& plainText, const QUrl& localPath, + bool asGenericFile = false); /** Post a pre-created room message event * * Takes ownership of the event, deleting it once the matching one diff --git a/libqmatrixclient.pri b/libqmatrixclient.pri index 8ca43e56..eefaec67 100644 --- a/libqmatrixclient.pri +++ b/libqmatrixclient.pri @@ -1,4 +1,4 @@ -QT += network +QT += network multimedia CONFIG += c++14 warn_on rtti_off create_prl object_parallel_to_source win32-msvc* { |