diff --git a/src/client/QXmppMixManager.cpp b/src/client/QXmppMixManager.cpp index d3d62a439..22681bbae 100644 --- a/src/client/QXmppMixManager.cpp +++ b/src/client/QXmppMixManager.cpp @@ -4,10 +4,12 @@ #include "QXmppMixManager.h" +#include "QXmppAccountMigrationManager.h" #include "QXmppClient.h" #include "QXmppConstants_p.h" #include "QXmppDiscoveryIq.h" #include "QXmppDiscoveryManager.h" +#include "QXmppGlobal.h" #include "QXmppMessage.h" #include "QXmppMixInfoItem.h" #include "QXmppMixInvitation.h" @@ -17,11 +19,14 @@ #include "QXmppPubSubManager.h" #include "QXmppRosterManager.h" #include "QXmppUtils.h" +#include "QXmppUtils_p.h" #include "Algorithms.h" +#include "StringLiterals.h" #include +using namespace QXmpp; using namespace QXmpp::Private; class QXmppMixManagerPrivate @@ -34,6 +39,64 @@ class QXmppMixManagerPrivate QList services; }; +namespace QXmpp::Private { + +struct MixData { + struct Item { + QString jid; + QString nick; + + void parse(const QDomElement &element) { + jid = element.attribute(u"jid"_s); + nick = element.attribute(u"nick"_s); + } + + void toXml(QXmlStreamWriter *writer) const { + writer->writeStartElement(QSL65("item")); + writeOptionalXmlAttribute(writer, u"jid", jid); + writeOptionalXmlAttribute(writer, u"nick", nick); + writer->writeEndElement(); + } + }; + + using Items = QList; + + Items items; + + static std::variant fromDom(const QDomElement &el) + { + if (el.tagName() != u"mix" || el.namespaceURI() != ns_qxmpp_export) { + return QXmppError { u"Invalid element."_s, {} }; + } + + MixData d; + + for (const auto &itemEl : iterChildElements(el, u"item")) { + Item item; + item.parse(itemEl); + d.items.push_back(std::move(item)); + } + + return d; + } + + void toXml(QXmlStreamWriter &writer) const + { + writer.writeStartElement(QSL65("mix")); + for (const auto &item : items) { + item.toXml(&writer); + } + writer.writeEndElement(); + } +}; + +static void serializeMixData(const MixData &d, QXmlStreamWriter &writer) +{ + d.toXml(writer); +} + +} // namespace QXmpp::Private + /// /// \class QXmppMixManager /// @@ -366,6 +429,7 @@ constexpr QStringView MIX_SERVICE_DISCOVERY_NODE = u"mix"; QXmppMixManager::QXmppMixManager() : d(new QXmppMixManagerPrivate()) { + QXmppExportData::registerExtension(u"mix", ns_qxmpp_export); } QXmppMixManager::~QXmppMixManager() = default; @@ -988,6 +1052,99 @@ void QXmppMixManager::onRegistered(QXmppClient *client) d->pubSubManager = client->findExtension(); Q_ASSERT_X(d->pubSubManager, "QXmppMixManager", "QXmppPubSubManager is missing"); + + // data import/export + if (auto manager = client->findExtension()) { + auto rosterManager = client->findExtension(); + Q_ASSERT_X(rosterManager, "QXmppMixManager", "QXmppRosterManager is missing"); + + using ImportResult = std::variant; + auto importData = [this, client, manager](const MixData &data) -> QXmppTask { + if (data.items.isEmpty()) { + return makeReadyTask(Success()); + } + + const auto defaultNick = client->configuration().user(); + QXmppPromise promise; + auto counter = std::make_shared(data.items.size()); + + for (const auto &item : std::as_const(data.items)) { + const auto nick = item.nick.isEmpty() ? defaultNick : item.nick; + + joinChannel(item.jid, nick).then(this, [manager, promise, counter](auto &&result) mutable { + if (promise.task().isFinished()) { + return; + } + + // We do not break import/export on mix errors, we only notify about it + if (auto error = std::get_if(&result); error) { + Q_EMIT manager->errorOccurred(std::move(*error)); + } + + if ((--(*counter)) == 0) { + return promise.finish(Success()); + } + }); + } + + return promise.task(); + }; + + using ExportResult = std::variant; + auto exportData = [this, client, manager, rosterManager]() -> QXmppTask { + QXmppPromise promise; + + rosterManager->requestRoster().then(this, [this, manager, promise](auto &&rosterResult) mutable { + if (auto error = std::get_if(&rosterResult); error) { + return promise.finish(std::move(*error)); + } + + const auto iq = std::move(std::get(rosterResult)); + const auto iqItems = transformFilter>(iq.items(), [](const auto &item) -> std::optional { + if (item.isMixChannel()) { + return item; + } + + return {}; + }); + + auto result = std::make_shared(); + auto counter = std::make_shared(iqItems.size()); + + result->items.reserve(*counter); + + for (const auto &item : std::as_const(iqItems)) { + requestParticipants(item.bareJid()).then(this, [manager, result, promise, counter, channelId = item.bareJid(), participantId = item.mixParticipantId()](auto &&participantsResult) mutable { + if (promise.task().isFinished()) { + return; + } + + // We do not break import/export on mix errors, we only notify about it + if (auto error = std::get_if(&participantsResult); error) { + Q_EMIT manager->errorOccurred(std::move(*error)); + } else { + const auto participants = std::get>(participantsResult); + + for (const QXmppMixParticipantItem &participant: participants) { + if (participant.id() == participantId) { + result->items.append({ channelId, participant.nick() }); + break; + } + } + } + + if ((--(*counter)) == 0) { + return promise.finish(*result.get()); + } + }); + } + }); + + return promise.task(); + }; + + manager->registerExportData(importData, exportData); + } } void QXmppMixManager::onUnregistered(QXmppClient *client) @@ -995,6 +1152,10 @@ void QXmppMixManager::onUnregistered(QXmppClient *client) disconnect(d->discoveryManager, &QXmppDiscoveryManager::infoReceived, this, &QXmppMixManager::handleDiscoInfo); resetCachedData(); disconnect(client, &QXmppClient::connected, this, nullptr); + + if (auto manager = client->findExtension()) { + manager->unregisterExportData(); + } } bool QXmppMixManager::handlePubSubEvent(const QDomElement &element, const QString &pubSubService, const QString &nodeName) diff --git a/src/client/QXmppRosterManager.cpp b/src/client/QXmppRosterManager.cpp index 2b0c3cb67..0ba953243 100644 --- a/src/client/QXmppRosterManager.cpp +++ b/src/client/QXmppRosterManager.cpp @@ -17,6 +17,7 @@ #include "QXmppUtils.h" #include "QXmppUtils_p.h" +#include "Algorithms.h" #include "StringLiterals.h" #include @@ -27,7 +28,9 @@ using namespace QXmpp::Private; namespace QXmpp::Private { struct RosterData { - QList items; + using Items = QList; + + Items items; static std::variant fromDom(const QDomElement &el) { @@ -548,47 +551,54 @@ void QXmppRosterManager::onRegistered(QXmppClient *client) { // data import/export if (auto manager = client->findExtension()) { - using Result = std::variant; - auto importData = [this](const RosterData &data) -> QXmppTask { + using ImportResult = std::variant; + auto importData = [this, client, manager](const RosterData &data) -> QXmppTask { if (data.items.isEmpty()) { - return makeReadyTask(Success()); + return makeReadyTask(Success()); } - QXmppPromise promise; + QXmppPromise promise; auto counter = std::make_shared(data.items.size()); for (const auto &item : std::as_const(data.items)) { + Q_ASSERT(!item.isMixChannel()); + QXmppRosterIq iq; iq.addItem(item); iq.setType(QXmppIq::Set); - this->client()->sendGenericIq(std::move(iq)).then(this, [promise, counter](auto &&result) mutable { + client->sendGenericIq(std::move(iq)).then(this, [promise, counter](auto &&result) mutable { if (promise.task().isFinished()) { return; } - if (std::holds_alternative(result)) { - return promise.finish(std::get(std::move(result))); + if (auto error = std::get_if(&result); error) { + return promise.finish(std::move(*error)); } if ((--(*counter)) == 0) { - promise.finish(Success()); + return promise.finish(Success()); } }); } return promise.task(); }; - auto exportData = [this]() { return chainMapSuccess(requestRoster(), this, [](QXmppRosterIq &&iq) -> RosterData { - auto items = iq.items(); + const auto items = transformFilter(iq.items(), [](const auto &item) -> std::optional { + if (item.isMixChannel()) { + return {}; + } - // We don't want this to be sent while importing. - // See https://datatracker.ietf.org/doc/html/rfc6121#section-2.1.2.2 - for (auto &item: items) { - item.setSubscriptionStatus({}); - } + auto fixed = item; + + // We don't want this to be sent while importing. + // See https://datatracker.ietf.org/doc/html/rfc6121#section-2.1.2.2 + fixed.setSubscriptionStatus({}); + + return fixed; + }); return { items }; }); diff --git a/src/client/QXmppRosterManager.h b/src/client/QXmppRosterManager.h index 2edce2cd4..bed457dac 100644 --- a/src/client/QXmppRosterManager.h +++ b/src/client/QXmppRosterManager.h @@ -148,6 +148,8 @@ private Q_SLOTS: QXmppTask requestRoster(); const std::unique_ptr d; + + friend class QXmppMixManager; }; #endif // QXMPPROSTER_H diff --git a/tests/qxmppaccountmigrationmanager/tst_qxmppaccountmigrationmanager.cpp b/tests/qxmppaccountmigrationmanager/tst_qxmppaccountmigrationmanager.cpp index f75ed96c8..6c2248d05 100644 --- a/tests/qxmppaccountmigrationmanager/tst_qxmppaccountmigrationmanager.cpp +++ b/tests/qxmppaccountmigrationmanager/tst_qxmppaccountmigrationmanager.cpp @@ -4,6 +4,9 @@ #include "QXmppAccountMigrationManager.h" #include "QXmppClient.h" +#include "QXmppDiscoveryManager.h" +#include "QXmppMixManager.h" +#include "QXmppPubSubManager.h" #include "QXmppRosterManager.h" #include "QXmppUtils_p.h" #include "QXmppVCardIq.h" @@ -38,6 +41,17 @@ static QXmppRosterIq::Item newRosterItem(const QString &bareJid, const QString & return item; } +static QXmppRosterIq::Item newMixRosterItem(const QString &channelId, const QString &channelName, const QString &participantId) +{ + QXmppRosterIq::Item item; + item.setBareJid(channelId); + item.setName(channelName); + item.setIsMixChannel(true); + item.setMixParticipantId(participantId); + item.setSubscriptionType(QXmppRosterIq::Item::NotSet); + return item; +} + static QXmppRosterIq newRoster(TestClient *client, int version, const std::optional &id, const std::optional &type = {}, int index = -1) { QXmppRosterIq roster; @@ -56,7 +70,7 @@ static QXmppRosterIq newRoster(TestClient *client, int version, const std::optio roster.addItem(newRosterItem(u"1@bare.com"_s, u"1 Bare"_s, { u"all"_s })); } if (index == -1 || index == 1) { - roster.addItem(newRosterItem(u"2@bare.com"_s, u"2 Bare"_s, { u"all"_s })); + roster.addItem(newMixRosterItem(u"mix1@bare.com"_s, u"Mix 1 Bare"_s, u"mix1BareId"_s)); } break; case 1: @@ -64,7 +78,7 @@ static QXmppRosterIq newRoster(TestClient *client, int version, const std::optio roster.addItem(newRosterItem(u"3@gamer.com"_s, u"3 Gamer"_s, { u"gamers"_s })); } if (index == -1 || index == 1) { - roster.addItem(newRosterItem(u"4@gamer.com"_s, u"4 Gamer"_s, { u"gamers"_s })); + roster.addItem(newMixRosterItem(u"mix2@gamer.com"_s, u"Mix 2 Gamer"_s, u"mix2BareId"_s)); } break; default: @@ -106,16 +120,19 @@ static QXmppVCardIq newClientVCard(TestClient *client, int version, const std::o return vcard; } -static std::unique_ptr newClient(bool withManagers) +static std::unique_ptr newClient(bool withManagers, bool autoResetEnabled = true) { - auto client = std::make_unique(); + auto client = std::make_unique(false, autoResetEnabled); client->addNewExtension(); client->configuration().setJid("pasnox@xmpp.example"); if (withManagers) { client->addNewExtension(); + client->addNewExtension(); + client->addNewExtension(); client->addNewExtension(client.get()); + client->addNewExtension(); } return client; @@ -127,8 +144,8 @@ class tst_QXmppAccountMigrationManager : public QObject private: Q_SLOT void testImportExport(); - Q_SLOT void realImportExport(); - Q_SLOT void serialization(); + Q_SLOT void testRealImportExport(); + Q_SLOT void testSerialization(); }; struct DataExtension { @@ -198,9 +215,9 @@ void tst_QXmppAccountMigrationManager::testImportExport() expectFutureVariant(importTask); } -void tst_QXmppAccountMigrationManager::realImportExport() +void tst_QXmppAccountMigrationManager::testRealImportExport() { - auto client = newClient(true); + auto client = newClient(true, false); auto *manager = client->findExtension(); auto *rosterManager = client->findExtension(); auto *vcardManager = client->findExtension(); @@ -217,53 +234,102 @@ void tst_QXmppAccountMigrationManager::realImportExport() "" "" ""_s); - client->expect(u"" + client->inject(packetToXml(newRoster(client.get(), 1, "qxmpp2", QXmppIq::Result))); + + client->expect(u"" + "" + "" + "" + ""_s); + client->inject(packetToXml(newRoster(client.get(), 1, "qxmpp3", QXmppIq::Result))); + + client->expect(u"" "" "" "<ROLE/>" "</vCard>" "</iq>"_s); + client->inject(packetToXml(newClientVCard(client.get(), 1, "qxmpp4", QXmppIq::Result))); - client->inject(packetToXml(newRoster(client.get(), 1, "qxmpp2", QXmppIq::Result))); - client->inject(packetToXml(newClientVCard(client.get(), 1, "qxmpp3", QXmppIq::Result))); + client->expect(u"<iq id='qxmpp7' to='mix2@gamer.com' type='get'>" + "<pubsub xmlns='http://jabber.org/protocol/pubsub'>" + "<items node='urn:xmpp:mix:nodes:participants'/>" + "</pubsub>" + "</iq>"_s); + client->inject(u"<iq id='qxmpp7' from='mix2@gamer.com' type='result'>" + "<pubsub xmlns='http://jabber.org/protocol/pubsub'>" + "<items node='urn:xmpp:mix:nodes:participants'>" + "<item id='mix2BareId'>" + "<participant xmlns='urn:xmpp:mix:core:1'>" + "<nick>Joe @ Mix 2 Gamer</nick>" + "<jid>mix_user@domain.ext</jid>" + "</participant>" + "</item>" + "</items>" + "</pubsub>" + "</iq>"_s); + + client->expectNoPacket(); auto data = expectFutureVariant<QXmppExportData>(exportTask); // import exported data auto importTask = manager->importData(data); - client->expect("<iq id='qxmpp3' to='pasnox@xmpp.example' type='set'>" + client->expect(u"<iq id='qxmpp13' to='pasnox@xmpp.example' type='set'>" + "<client-join xmlns='urn:xmpp:mix:pam:2' channel='mix2@gamer.com'>" + "<join xmlns='urn:xmpp:mix:core:1'>" + "<subscribe node='urn:xmpp:mix:nodes:allowed'/>" + "<subscribe node='urn:xmpp:avatar:data'/>" + "<subscribe node='urn:xmpp:avatar:metadata'/>" + "<subscribe node='urn:xmpp:mix:nodes:banned'/>" + "<subscribe node='urn:xmpp:mix:nodes:config'/>" + "<subscribe node='urn:xmpp:mix:nodes:info'/>" + "<subscribe node='urn:xmpp:mix:nodes:jidmap'/>" + "<subscribe node='urn:xmpp:mix:nodes:messages'/>" + "<subscribe node='urn:xmpp:mix:nodes:participants'/>" + "<subscribe node='urn:xmpp:mix:nodes:presence'/>" + "<nick>Joe @ Mix 2 Gamer</nick>" + "</join>" + "</client-join>" + "</iq>"_s); + client->inject(u"<iq id='qxmpp13' type='result'>" + "<client-join xmlns='urn:xmpp:mix:pam:2'>" + "<join xmlns='urn:xmpp:mix:core:1' id='mix2BareId'>" + "<subscribe node='urn:xmpp:mix:nodes:messages'/>" + "<subscribe node='urn:xmpp:mix:nodes:presence'/>" + "<nick>Joe @ Mix 2 Gamer</nick>" + "</join>" + "</client-join>" + "</iq>"_s); + + client->expect(u"<iq id='qxmpp4' to='pasnox@xmpp.example' type='set'>" "<vCard xmlns='vcard-temp'>" "<NICKNAME>It is me Bookri</NICKNAME>" "<N><GIVEN>Nox</GIVEN><FAMILY>Bookri</FAMILY></N>" "<TITLE/>" "<ROLE/>" "</vCard>" - "</iq>"); - client->expect("<iq id='qxmpp1' type='set'>" + "</iq>"_s); + client->inject(packetToXml(newClientVCard(client.get(), 1, "qxmpp4", QXmppIq::Result))); + + client->expect(u"<iq id='qxmpp14' type='set'>" "<query xmlns='jabber:iq:roster'>" "<item jid='3@gamer.com' name='3 Gamer'>" "<group>gamers</group>" "</item>" "</query>" - "</iq>"); - client->expect("<iq id='qxmpp2' type='set'>" - "<query xmlns='jabber:iq:roster'>" - "<item jid='4@gamer.com' name='4 Gamer'>" - "<group>gamers</group>" - "</item>" - "</query>" - "</iq>"); - client->inject(packetToXml(newClientVCard(client.get(), 1, "qxmpp3", QXmppIq::Result))); - client->inject(packetToXml(newRoster(client.get(), 1, "qxmpp1", QXmppIq::Result, 0))); - client->inject(packetToXml(newRoster(client.get(), 1, "qxmpp2", QXmppIq::Result, 1))); + "</iq>"_s); + client->inject(packetToXml(newRoster(client.get(), 1, "qxmpp14", QXmppIq::Result, 0))); + + client->expectNoPacket(); expectFutureVariant<Success>(importTask); } -void tst_QXmppAccountMigrationManager::serialization() +void tst_QXmppAccountMigrationManager::testSerialization() { - auto client = newClient(true); + auto client = newClient(true, false); auto *manager = client->findExtension<QXmppAccountMigrationManager>(); auto *rosterManager = client->findExtension<QXmppRosterManager>(); auto *vcardManager = client->findExtension<QXmppVCardManager>(); @@ -276,30 +342,60 @@ void tst_QXmppAccountMigrationManager::serialization() auto exportTask = manager->exportData(); QVERIFY(!exportTask.isFinished()); - client->expect(QStringLiteral("<iq id='qxmpp2' from='pasnox@xmpp.example/QXmpp' type='get'>" - "<query xmlns='jabber:iq:roster'>" - "<annotate xmlns='urn:xmpp:mix:roster:0'/>" - "</query>" - "</iq>")); - client->expect(QStringLiteral("<iq id='qxmpp3' to='pasnox@xmpp.example' type='get'>" - "<vCard xmlns='vcard-temp'>" - "<TITLE/>" - "<ROLE/>" - "</vCard>" - "</iq>")); - + client->expect(u"<iq id='qxmpp2' from='pasnox@xmpp.example/QXmpp' type='get'>" + "<query xmlns='jabber:iq:roster'>" + "<annotate xmlns='urn:xmpp:mix:roster:0'/>" + "</query>" + "</iq>"_s); client->inject(packetToXml(newRoster(client.get(), 1, "qxmpp2", QXmppIq::Result))); - client->inject(packetToXml(newClientVCard(client.get(), 1, "qxmpp3", QXmppIq::Result))); + + client->expect(u"<iq id='qxmpp3' from='pasnox@xmpp.example/QXmpp' type='get'>" + "<query xmlns='jabber:iq:roster'>" + "<annotate xmlns='urn:xmpp:mix:roster:0'/>" + "</query>" + "</iq>"_s); + client->inject(packetToXml(newRoster(client.get(), 1, "qxmpp3", QXmppIq::Result))); + + client->expect(u"<iq id='qxmpp4' to='pasnox@xmpp.example' type='get'>" + "<vCard xmlns='vcard-temp'>" + "<TITLE/>" + "<ROLE/>" + "</vCard>" + "</iq>"_s); + client->inject(packetToXml(newClientVCard(client.get(), 1, "qxmpp4", QXmppIq::Result))); + + client->expect(u"<iq id='qxmpp7' to='mix2@gamer.com' type='get'>" + "<pubsub xmlns='http://jabber.org/protocol/pubsub'>" + "<items node='urn:xmpp:mix:nodes:participants'/>" + "</pubsub>" + "</iq>"_s); + client->inject(u"<iq id='qxmpp7' from='mix2@gamer.com' type='result'>" + "<pubsub xmlns='http://jabber.org/protocol/pubsub'>" + "<items node='urn:xmpp:mix:nodes:participants'>" + "<item id='mix2BareId'>" + "<participant xmlns='urn:xmpp:mix:core:1'>" + "<nick>Joe @ Mix 2 Gamer</nick>" + "<jid>mix_user@domain.ext</jid>" + "</participant>" + "</item>" + "</items>" + "</pubsub>" + "</iq>"_s); + + client->expectNoPacket(); // test serialize - auto data = expectFutureVariant<QXmppExportData>(exportTask); - auto xml1 = packetToXml(data); - QByteArray xml2 = + const auto data = expectFutureVariant<QXmppExportData>(exportTask); + + const auto xml1 = packetToXml(data); + const QByteArray xml2 = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" "<account-data xmlns=\"org.qxmpp.export\" jid=\"pasnox@xmpp.example\">" + "<mix>" + "<item jid=\"mix2@gamer.com\" nick=\"Joe @ Mix 2 Gamer\"/>" + "</mix>" "<roster>" "<item xmlns=\"jabber:iq:roster\" jid=\"3@gamer.com\" name=\"3 Gamer\"><group>gamers</group></item>" - "<item xmlns=\"jabber:iq:roster\" jid=\"4@gamer.com\" name=\"4 Gamer\"><group>gamers</group></item>" "</roster>" "<vcard>" "<vCard xmlns=\"vcard-temp\">" @@ -312,21 +408,23 @@ void tst_QXmppAccountMigrationManager::serialization() if (xml1 != xml2) { qDebug() << "Actual:\n" - << xml1; + << xml1.constData(); qDebug() << "Expected:\n" - << xml2; + << xml2.constData(); } QCOMPARE(xml1, xml2); // test parse (and re-serialize) auto parsedData = expectVariant<QXmppExportData>(QXmppExportData::fromDom(xmlToDom(xml2))); - auto xml3 = packetToXml(parsedData); - QByteArray xml4 = + const auto xml3 = packetToXml(parsedData); + const QByteArray xml4 = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" "<account-data xmlns=\"org.qxmpp.export\" jid=\"pasnox@xmpp.example\">" + "<mix>" + "<item jid=\"mix2@gamer.com\" nick=\"Joe @ Mix 2 Gamer\"/>" + "</mix>" "<roster>" "<item xmlns=\"jabber:iq:roster\" jid=\"3@gamer.com\" name=\"3 Gamer\"><group>gamers</group></item>" - "<item xmlns=\"jabber:iq:roster\" jid=\"4@gamer.com\" name=\"4 Gamer\"><group>gamers</group></item>" "</roster>" "<vcard>" "<vCard xmlns=\"vcard-temp\">" @@ -339,9 +437,9 @@ void tst_QXmppAccountMigrationManager::serialization() if (xml3 != xml4) { qDebug() << "Actual:\n" - << xml3; + << xml3.constData(); qDebug() << "Expected:\n" - << xml4; + << xml4.constData(); } QCOMPARE(xml3, xml4); }