diff --git a/interfaces/conversationmessage.cpp b/interfaces/conversationmessage.cpp index b37bce1a..2aaebef1 100644 --- a/interfaces/conversationmessage.cpp +++ b/interfaces/conversationmessage.cpp @@ -1,141 +1,145 @@ /** * Copyright 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "conversationmessage.h" +#include #include +Q_LOGGING_CATEGORY(CONVERSATION_MESSAGE_LOGGING_CATEGORY, "kdeconnect.interfaces.conversationmessage") ConversationMessage::ConversationMessage(const QVariantMap& args) : m_eventField(args[QStringLiteral("event")].toInt()), m_body(args[QStringLiteral("body")].toString()), - m_address(args[QStringLiteral("address")].toString()), m_date(args[QStringLiteral("date")].toLongLong()), m_type(args[QStringLiteral("type")].toInt()), m_read(args[QStringLiteral("read")].toInt()), m_threadID(args[QStringLiteral("thread_id")].toLongLong()), m_uID(args[QStringLiteral("_id")].toInt()) { + QString test = QLatin1String(args[QStringLiteral("addresses")].typeName()); + QVariantList jsonAddresses = args[QStringLiteral("addresses")].toList(); + for (const QVariant& addressField : jsonAddresses) { + const auto& rawAddress = addressField.toMap(); + m_addresses.append(ConversationAddress(rawAddress[QStringLiteral("address")].value())); + } } ConversationMessage::ConversationMessage (const qint32& eventField, const QString& body, - const QString& address, const qint64& date, + const QList& addresses, const qint64& date, const qint32& type, const qint32& read, const qint64& threadID, const qint32& uID) : m_eventField(eventField) , m_body(body) - , m_address(address) + , m_addresses(addresses) , m_date(date) , m_type(type) , m_read(read) , m_threadID(threadID) , m_uID(uID) { } ConversationMessage::ConversationMessage(const ConversationMessage& other) : m_eventField(other.m_eventField) , m_body(other.m_body) - , m_address(other.m_address) + , m_addresses(other.m_addresses) , m_date(other.m_date) , m_type(other.m_type) , m_read(other.m_read) , m_threadID(other.m_threadID) , m_uID(other.m_uID) { } ConversationMessage::~ConversationMessage() { } ConversationMessage& ConversationMessage::operator=(const ConversationMessage& other) { this->m_eventField = other.m_eventField; this->m_body = other.m_body; - this->m_address = other.m_address; + this->m_addresses = other.m_addresses; this->m_date = other.m_date; this->m_type = other.m_type; this->m_read = other.m_read; this->m_threadID = other.m_threadID; this->m_uID = other.m_uID; return *this; } +ConversationMessage ConversationMessage::fromDBus(const QDBusVariant& var) +{ + QDBusArgument data = var.variant().value(); + ConversationMessage message; + data >> message; + return message; +} + QVariantMap ConversationMessage::toVariant() const { + QVariantList addresses; + for (const ConversationAddress& address : m_addresses) { + addresses.push_back(address.toVariant()); + } + return { {QStringLiteral("event"), m_eventField}, {QStringLiteral("body"), m_body}, - {QStringLiteral("address"), m_address}, + {QStringLiteral("addresses"), addresses}, {QStringLiteral("date"), m_date}, {QStringLiteral("type"), m_type}, {QStringLiteral("read"), m_read}, {QStringLiteral("thread_id"), m_threadID}, {QStringLiteral("_id"), m_uID}, }; } -QDBusArgument &operator<<(QDBusArgument &argument, const ConversationMessage &message) +ConversationAddress::ConversationAddress(QString address) + : m_address(address) +{} + +ConversationAddress::ConversationAddress(const ConversationAddress& other) + : m_address(other.address()) +{} + +ConversationAddress::~ConversationAddress() +{} + +ConversationAddress& ConversationAddress::operator=(const ConversationAddress& other) { - argument.beginStructure(); - argument << message.eventField() - << message.body() - << message.address() - << message.date() - << message.type() - << message.read() - << message.threadID() - << message.uID(); - argument.endStructure(); - return argument; + this->m_address = other.m_address; + return *this; } -const QDBusArgument &operator>>(const QDBusArgument &argument, ConversationMessage &message) +QVariantMap ConversationAddress::toVariant() const { - qint32 event; - QString body; - QString address; - qint64 date; - qint32 type; - qint32 read; - qint64 threadID; - qint32 uID; - - argument.beginStructure(); - argument >> event; - argument >> body; - argument >> address; - argument >> date; - argument >> type; - argument >> read; - argument >> threadID; - argument >> uID; - argument.endStructure(); - - message = ConversationMessage(event, body, address, date, type, read, threadID, uID); - - return argument; + return { + {QStringLiteral("address"), address()}, + }; } void ConversationMessage::registerDbusType() { qDBusRegisterMetaType(); qRegisterMetaType(); + qDBusRegisterMetaType(); + qRegisterMetaType(); } diff --git a/interfaces/conversationmessage.h b/interfaces/conversationmessage.h index 316b1d9c..80d11742 100644 --- a/interfaces/conversationmessage.h +++ b/interfaces/conversationmessage.h @@ -1,128 +1,223 @@ /** * Copyright 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #ifndef PLUGINS_TELEPHONY_CONVERSATIONMESSAGE_H_ #define PLUGINS_TELEPHONY_CONVERSATIONMESSAGE_H_ #include +#include #include "kdeconnectinterfaces_export.h" +Q_DECLARE_LOGGING_CATEGORY(CONVERSATION_MESSAGE_LOGGING_CATEGORY) + +class ConversationAddress; + class KDECONNECTINTERFACES_EXPORT ConversationMessage { public: // TYPE field values from Android enum Types { MessageTypeAll = 0, MessageTypeInbox = 1, MessageTypeSent = 2, MessageTypeDraft = 3, MessageTypeOutbox = 4, MessageTypeFailed = 5, MessageTypeQueued = 6, }; /** * Values describing the possible type of events contained in a message * A message's eventField is constructed as a bitwise-OR of events * Any events which are unsupported should be ignored */ enum Events { EventTextMessage = 0x1, // This message has a body field which contains pure, human-readable text EventMultiTarget = 0x2, // This is a multitarget (group) message which has an "addresses" field which is a list of participants in the group }; /** * Build a new message from a keyword argument dictionary * * @param args mapping of field names to values as might be contained in a network packet containing a message */ ConversationMessage(const QVariantMap& args = QVariantMap()); - ConversationMessage(const qint32& eventField, const QString& body, const QString& address, + ConversationMessage(const qint32& eventField, const QString& body, const QList& addresses, const qint64& date, const qint32& type, const qint32& read, const qint64& threadID, const qint32& uID); ConversationMessage(const ConversationMessage& other); ~ConversationMessage(); ConversationMessage& operator=(const ConversationMessage& other); + static ConversationMessage fromDBus(const QDBusVariant&); static void registerDbusType(); qint32 eventField() const { return m_eventField; } QString body() const { return m_body; } - QString address() const { return m_address; } + QList addresses() const { return m_addresses; } qint64 date() const { return m_date; } qint32 type() const { return m_type; } qint32 read() const { return m_read; } qint64 threadID() const { return m_threadID; } qint32 uID() const { return m_uID; } QVariantMap toVariant() const; bool containsTextBody() const { return (eventField() & ConversationMessage::EventTextMessage); } bool isMultitarget() const { return (eventField() & ConversationMessage::EventMultiTarget); } + bool isIncoming() const { return type() == MessageTypeInbox; } + bool isOutgoing() const { return type() == MessageTypeSent; } + + /** + * Return the address of the other party of a single-target conversation + * Calling this method with a multi-target conversation is ill-defined + */ + QString getOtherPartyAddress() const; + protected: /** * Bitwise OR of event flags * Unsupported flags shall cause the message to be ignored */ qint32 m_eventField; /** * Body of the message */ QString m_body; /** - * Remote-side address of the message. Most likely a phone number, but may be an email address + * List of all addresses involved in this conversation + * An address is most likely a phone number, but may be something else like an email address */ - QString m_address; + QList m_addresses; /** * Date stamp (Unix epoch millis) associated with the message */ qint64 m_date; /** * Type of the message. See the message.type enum */ qint32 m_type; /** * Whether we have a read report for this message */ qint32 m_read; /** * Tag which binds individual messages into a thread */ qint64 m_threadID; /** * Value which uniquely identifies a message */ qint32 m_uID; }; +class KDECONNECTINTERFACES_EXPORT ConversationAddress +{ +public: + ConversationAddress(QString address = QStringLiteral()); + ConversationAddress(const ConversationAddress& other); + ~ConversationAddress(); + ConversationAddress& operator=(const ConversationAddress& other); + + QString address() const { return m_address; } + + QVariantMap toVariant() const; +private: + QString m_address; +}; + +inline QDBusArgument &operator<<(QDBusArgument &argument, const ConversationMessage &message) +{ + argument.beginStructure(); + argument << message.eventField() + << message.body() + << message.addresses() + << message.date() + << message.type() + << message.read() + << message.threadID() + << message.uID(); + argument.endStructure(); + return argument; +} + +inline const QDBusArgument &operator>>(const QDBusArgument &argument, ConversationMessage &message) +{ + qint32 event; + QString body; + QList addresses; + qint64 date; + qint32 type; + qint32 read; + qint64 threadID; + qint32 uID; + + argument.beginStructure(); + argument >> event; + argument >> body; + argument >> addresses; + argument >> date; + argument >> type; + argument >> read; + argument >> threadID; + argument >> uID; + argument.endStructure(); + + message = ConversationMessage(event, body, addresses, date, type, read, threadID, uID); + + return argument; +} + +inline QDBusArgument& operator<<(QDBusArgument& argument, const ConversationAddress& address) +{ + argument.beginStructure(); + argument << address.address(); + argument.endStructure(); + return argument; +} + +inline const QDBusArgument& operator>>(const QDBusArgument& argument, ConversationAddress& address) +{ + QString addressField; + + argument.beginStructure(); + argument >> addressField; + argument.endStructure(); + + address = ConversationAddress(addressField); + + return argument; +} + Q_DECLARE_METATYPE(ConversationMessage); +Q_DECLARE_METATYPE(ConversationAddress); #endif /* PLUGINS_TELEPHONY_CONVERSATIONMESSAGE_H_ */ diff --git a/plugins/sms/conversationsdbusinterface.cpp b/plugins/sms/conversationsdbusinterface.cpp index ff68e6f7..0a6fed99 100644 --- a/plugins/sms/conversationsdbusinterface.cpp +++ b/plugins/sms/conversationsdbusinterface.cpp @@ -1,207 +1,214 @@ /** * Copyright 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "conversationsdbusinterface.h" #include "interfaces/dbusinterfaces.h" #include "interfaces/conversationmessage.h" #include "requestconversationworker.h" #include #include #include Q_LOGGING_CATEGORY(KDECONNECT_CONVERSATIONS, "kdeconnect.conversations") QMap ConversationsDbusInterface::liveConversationInterfaces; ConversationsDbusInterface::ConversationsDbusInterface(KdeConnectPlugin* plugin) : QDBusAbstractAdaptor(const_cast(plugin->device())) , m_device(plugin->device()->id()) , m_plugin(plugin) , m_lastId(0) , m_smsInterface(m_device) { ConversationMessage::registerDbusType(); // Check for an existing interface for the same device // If there is already an interface for this device, we can safely delete is since we have just replaced it const auto& oldInterfaceItr = ConversationsDbusInterface::liveConversationInterfaces.find(m_device); if (oldInterfaceItr != ConversationsDbusInterface::liveConversationInterfaces.end()) { ConversationsDbusInterface* oldInterface = oldInterfaceItr.value(); oldInterface->deleteLater(); ConversationsDbusInterface::liveConversationInterfaces.erase(oldInterfaceItr); } ConversationsDbusInterface::liveConversationInterfaces[m_device] = this; } ConversationsDbusInterface::~ConversationsDbusInterface() { // Wake all threads which were waiting for a reply from this interface // This might result in some noise on dbus, but it's better than leaking a bunch of resources! waitingForMessagesLock.lock(); conversationsWaitingForMessages.clear(); waitingForMessages.wakeAll(); waitingForMessagesLock.unlock(); // Erase this interface from the list of known interfaces const auto myIterator = ConversationsDbusInterface::liveConversationInterfaces.find(m_device); ConversationsDbusInterface::liveConversationInterfaces.erase(myIterator); } QVariantList ConversationsDbusInterface::activeConversations() { QList toReturn; toReturn.reserve(m_conversations.size()); for (auto it = m_conversations.cbegin(); it != m_conversations.cend(); ++it) { const auto& conversation = it.value().values(); if (conversation.isEmpty()) { // This should really never happen because we create a conversation at the same time // as adding a message, but better safe than sorry qCWarning(KDECONNECT_CONVERSATIONS) << "Conversation with ID" << it.key() << "is unexpectedly empty"; break; } - const QVariantMap& message = (*conversation.crbegin()).toVariant(); + const QVariant& message = QVariant::fromValue(*conversation.crbegin()); toReturn.append(message); } return toReturn; } void ConversationsDbusInterface::requestConversation(const qint64& conversationID, int start, int end) { if (start < 0 || end < 0) { qCWarning(KDECONNECT_CONVERSATIONS) << "requestConversation" << "Start and end must be >= 0"; return; } if (end - start < 0) { qCWarning(KDECONNECT_CONVERSATIONS) << "requestConversation" <<"Start must be before end"; return; } RequestConversationWorker* worker = new RequestConversationWorker(conversationID, start, end, this); connect(worker, &RequestConversationWorker::conversationMessageRead, this, &ConversationsDbusInterface::conversationUpdated, Qt::QueuedConnection); worker->work(); } void ConversationsDbusInterface::addMessages(const QList &messages) { QSet updatedConversationIDs; for (const auto& message : messages) { const qint32& threadId = message.threadID(); // We might discover that there are no new messages in this conversation, thus calling it // "updated" might turn out to be a bit misleading // However, we need to report it as updated regardless, for the case where we have already // cached every message of the conversation but we have received a request for more, otherwise // we will never respond to that request updatedConversationIDs.insert(message.threadID()); if (m_known_messages[threadId].contains(message.uID())) { // This message has already been processed. Don't do anything. continue; } // Store the Message in the list corresponding to its thread bool newConversation = !m_conversations.contains(threadId); const auto& threadPosition = m_conversations[threadId].insert(message.date(), message); m_known_messages[threadId].insert(message.uID()); // If this message was inserted at the end of the list, it is the latest message in the conversation bool latestMessage = threadPosition == m_conversations[threadId].end() - 1; // Tell the world about what just happened if (newConversation) { - Q_EMIT conversationCreated(message.toVariant()); + Q_EMIT conversationCreated(QDBusVariant(QVariant::fromValue(message))); } else if (latestMessage) { - Q_EMIT conversationUpdated(message.toVariant()); + Q_EMIT conversationUpdated(QDBusVariant(QVariant::fromValue(message))); } } waitingForMessagesLock.lock(); // Remove the waiting flag for all conversations which we just processed conversationsWaitingForMessages.subtract(updatedConversationIDs); waitingForMessages.wakeAll(); waitingForMessagesLock.unlock(); } void ConversationsDbusInterface::removeMessage(const QString& internalId) { // TODO: Delete the specified message from our internal structures Q_UNUSED(internalId); } QList ConversationsDbusInterface::getConversation(const qint64& conversationID) const { return m_conversations.value(conversationID).values(); } void ConversationsDbusInterface::updateConversation(const qint64& conversationID) { waitingForMessagesLock.lock(); if (conversationsWaitingForMessages.contains(conversationID)) { // This conversation is already being waited on, don't allow more than one thread to wait at a time qCDebug(KDECONNECT_CONVERSATIONS) << "Not allowing two threads to wait for conversationID" << conversationID; waitingForMessagesLock.unlock(); return; } qCDebug(KDECONNECT_CONVERSATIONS) << "Requesting conversation with ID" << conversationID << "from remote"; conversationsWaitingForMessages.insert(conversationID); m_smsInterface.requestConversation(conversationID); while (conversationsWaitingForMessages.contains(conversationID)) { waitingForMessages.wait(&waitingForMessagesLock); } waitingForMessagesLock.unlock(); } void ConversationsDbusInterface::replyToConversation(const qint64& conversationID, const QString& message) { const auto messagesList = m_conversations[conversationID]; if (messagesList.isEmpty()) { // Since there are no messages in the conversation, we can't do anything sensible qCWarning(KDECONNECT_CONVERSATIONS) << "Got a conversationID for a conversation with no messages!"; return; } - // Caution: - // This method assumes that the address of any message (in this case, whichever one pops out - // with .first()) will be the same. This works fine for single-target SMS but might break down - // for group MMS, etc. - const QString& address = messagesList.first().address(); - m_smsInterface.sendSms(address, message); + + if (messagesList.first().isMultitarget()) { + qWarning(KDECONNECT_CONVERSATIONS) << "Tried to reply to a group MMS which is not supported in this version of KDE Connect"; + return; + } + + const QList& addresses = messagesList.first().addresses(); + if (addresses.size() > 1) { + // TODO: Upgrade for multitarget replies + qCWarning(KDECONNECT_CONVERSATIONS) << "Sending replies to multiple recipients is not supported"; + return; + } + m_smsInterface.sendSms(addresses[0].address(), message); } void ConversationsDbusInterface::requestAllConversationThreads() { // Prepare the list of conversations by requesting the first in every thread m_smsInterface.requestAllConversations(); } QString ConversationsDbusInterface::newId() { return QString::number(++m_lastId); } diff --git a/plugins/sms/conversationsdbusinterface.h b/plugins/sms/conversationsdbusinterface.h index e738fa63..3dbb34f6 100644 --- a/plugins/sms/conversationsdbusinterface.h +++ b/plugins/sms/conversationsdbusinterface.h @@ -1,151 +1,151 @@ /** * Copyright 2013 Albert Vaca * Copyright 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #ifndef CONVERSATIONSDBUSINTERFACE_H #define CONVERSATIONSDBUSINTERFACE_H #include #include #include #include #include #include #include #include #include "interfaces/conversationmessage.h" #include "interfaces/dbusinterfaces.h" class KdeConnectPlugin; class Device; Q_DECLARE_LOGGING_CATEGORY(KDECONNECT_CONVERSATIONS) class ConversationsDbusInterface : public QDBusAbstractAdaptor { Q_OBJECT Q_CLASSINFO("D-Bus Interface", "org.kde.kdeconnect.device.conversations") public: explicit ConversationsDbusInterface(KdeConnectPlugin* plugin); ~ConversationsDbusInterface() override; void addMessages(const QList &messages); void removeMessage(const QString& internalId); /** * Return a shallow copy of the requested conversation */ QList getConversation(const qint64& conversationID) const; /** * Get all of the messages in the requested conversation from the remote device * TODO: Make interface capable of requesting smaller window of messages */ void updateConversation(const qint64& conversationID); public Q_SLOTS: /** * Return a list of the first message in every conversation * * Note that the return value is a list of QVariants, which in turn have a value of * QVariantMap created from each message */ QVariantList activeConversations(); /** * Request the specified range of the specified conversation * * Emits conversationUpdated for every message in the requested range * * If the conversation does not have enough messages to fill the request, * this method may return fewer messages */ void requestConversation(const qint64 &conversationID, int start, int end); /** * Send a new message to this conversation */ void replyToConversation(const qint64& conversationID, const QString& message); /** * Send the request to the Telephony plugin to update the list of conversation threads */ void requestAllConversationThreads(); Q_SIGNALS: /** * Emitted whenever a conversation with no cached messages is added, either because the cache * is being populated or because a new conversation has been created */ - Q_SCRIPTABLE void conversationCreated(const QVariantMap& msg); + Q_SCRIPTABLE void conversationCreated(const QDBusVariant& msg); /** * Emitted whenever a conversation is being deleted */ Q_SCRIPTABLE void conversationRemoved(const qint64& conversationID); /** * Emitted whenever a message is added to a conversation and it is the newest message in the * conversation */ - Q_SCRIPTABLE void conversationUpdated(const QVariantMap& msg); + Q_SCRIPTABLE void conversationUpdated(const QDBusVariant& msg); private /*methods*/: QString newId(); //Generates successive identifitiers to use as public ids private /*attributes*/: const QString m_device; KdeConnectPlugin* m_plugin; /** * Mapping of threadID to the messages which make up that thread * * The messages are stored as a QMap of the timestamp to the actual message object so that * we can use .values() to get a sorted list of messages from least- to most-recent */ QHash> m_conversations; /** * Mapping of threadID to the set of uIDs known in the corresponding conversation */ QHash> m_known_messages; /* * Keep a map of all interfaces ever constructed * Because of how Qt's Dbus is designed, we are unable to immediately delete the interface once * the device has disconnected. We save the list of existing interfaces and delete them only after * we have replaced them (in ConversationsDbusInterface's constructor) * See the comment in ~NotificationsPlugin() for more information */ static QMap liveConversationInterfaces; int m_lastId; SmsDbusInterface m_smsInterface; QSet conversationsWaitingForMessages; QMutex waitingForMessagesLock; QWaitCondition waitingForMessages; }; #endif // CONVERSATIONSDBUSINTERFACE_H diff --git a/plugins/sms/requestconversationworker.cpp b/plugins/sms/requestconversationworker.cpp index 061f07c8..8480662a 100644 --- a/plugins/sms/requestconversationworker.cpp +++ b/plugins/sms/requestconversationworker.cpp @@ -1,93 +1,93 @@ /** * Copyright 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "requestconversationworker.h" #include "conversationsdbusinterface.h" #include RequestConversationWorker::RequestConversationWorker(const qint64& conversationID, int start, int end, ConversationsDbusInterface* interface) : //QObject(interface) conversationID(conversationID) , start(start) , parent(interface) , m_thread(new QThread) { Q_ASSERT(end >= start && "Not allowed to have a negative-length range"); howMany = end - start; this->moveToThread(m_thread); connect(m_thread, &QThread::started, this, &RequestConversationWorker::handleRequestConversation); connect(m_thread, &QThread::finished, m_thread, &QObject::deleteLater); connect(this, &RequestConversationWorker::finished, m_thread, &QThread::quit); connect(this, &RequestConversationWorker::finished, this, &QObject::deleteLater); } void RequestConversationWorker::handleRequestConversation() { auto messagesList = parent->getConversation(conversationID); if (messagesList.isEmpty()) { // Since there are no messages in the conversation, it's likely that it is a junk ID, but go ahead anyway qCWarning(KDECONNECT_CONVERSATIONS) << "Got a conversationID for a conversation with no messages!" << conversationID; } // In case the remote takes awhile to respond, we should go ahead and do anything we can from the cache size_t numHandled = replyForConversation(messagesList, start, howMany); if (numHandled < howMany) { size_t numRemaining = howMany - numHandled; // If we don't have enough messages in cache, go get some more // TODO: Make Android interface capable of requesting small window of messages parent->updateConversation(conversationID); messagesList = parent->getConversation(conversationID); //ConversationsDbusInterface::getConversation blocks until it sees new messages in the requested conversation replyForConversation(messagesList, start + numHandled, numRemaining); } Q_EMIT finished(); } size_t RequestConversationWorker::replyForConversation(const QList& conversation, int start, size_t howMany) { Q_ASSERT(start >= 0); // Messages are sorted in ascending order of keys, meaning the front of the list has the oldest // messages (smallest timestamp number) // Therefore, return the end of the list first (most recent messages) size_t i = 0; for(auto it = conversation.crbegin() + start; it != conversation.crend(); ++it) { if (i >= howMany) { break; } - Q_EMIT conversationMessageRead(it->toVariant()); + Q_EMIT conversationMessageRead(QDBusVariant(QVariant::fromValue(*it))); i++; } return i; } void RequestConversationWorker::work() { m_thread->start(); } diff --git a/plugins/sms/requestconversationworker.h b/plugins/sms/requestconversationworker.h index 98d04a7b..1eb4ba93 100644 --- a/plugins/sms/requestconversationworker.h +++ b/plugins/sms/requestconversationworker.h @@ -1,80 +1,80 @@ /** * Copyright 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #ifndef REQUESTCONVERSATIONWORKER_H #define REQUESTCONVERSATIONWORKER_H #include "conversationsdbusinterface.h" #include #include /** * In case we need to wait for more messages to be downloaded from Android, * Do the actual work of a requestConversation call in a separate thread * * This class is the worker for that thread */ class RequestConversationWorker : public QObject { Q_OBJECT public: RequestConversationWorker(const qint64& conversationID, int start, int end, ConversationsDbusInterface* interface); public Q_SLOTS: /** * Main body of this worker * * Reply to a request for messages and, if needed, wait for the remote to reply with more * * Emits conversationMessageRead for every message handled */ void handleRequestConversation(); void work(); Q_SIGNALS: - void conversationMessageRead(const QVariantMap& msg); + void conversationMessageRead(const QDBusVariant& msg); void finished(); private: qint64 conversationID; int start; // Start of range to request messages size_t howMany; // Number of messages being requested ConversationsDbusInterface* parent; QThread* m_thread; /** * Reply with all messages we currently have available in the requested range * * If the range specified by start and howMany is not in the range of messages in the conversation, * reply with only as many messages as we have available in that range * * @param conversation Conversation to send messages from * @param start Start of requested range, 0-indexed, inclusive * @param howMany Maximum number of messages to return * $return Number of messages processed */ size_t replyForConversation(const QList& conversation, int start, size_t howMany); }; #endif // REQUESTCONVERSATIONWORKER_H diff --git a/plugins/sms/smsplugin.cpp b/plugins/sms/smsplugin.cpp index e518388a..2f9c3cd4 100644 --- a/plugins/sms/smsplugin.cpp +++ b/plugins/sms/smsplugin.cpp @@ -1,126 +1,126 @@ /** * Copyright 2013 Albert Vaca * Copyright 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "smsplugin.h" #include #include #include #include #include #include #include #include "sendreplydialog.h" K_PLUGIN_CLASS_WITH_JSON(SmsPlugin, "kdeconnect_sms.json") Q_LOGGING_CATEGORY(KDECONNECT_PLUGIN_SMS, "kdeconnect.plugin.sms") SmsPlugin::SmsPlugin(QObject* parent, const QVariantList& args) : KdeConnectPlugin(parent, args) , m_telepathyInterface(QStringLiteral("org.freedesktop.Telepathy.ConnectionManager.kdeconnect"), QStringLiteral("/kdeconnect")) , m_conversationInterface(new ConversationsDbusInterface(this)) { } SmsPlugin::~SmsPlugin() { // m_conversationInterface is self-deleting, see ~ConversationsDbusInterface for more information } bool SmsPlugin::receivePacket(const NetworkPacket& np) { if (np.type() == PACKET_TYPE_SMS_MESSAGES) { return handleBatchMessages(np); } return true; } void SmsPlugin::sendSms(const QString& phoneNumber, const QString& messageBody) { NetworkPacket np(PACKET_TYPE_SMS_REQUEST, { {QStringLiteral("sendSms"), true}, {QStringLiteral("phoneNumber"), phoneNumber}, {QStringLiteral("messageBody"), messageBody} }); qCDebug(KDECONNECT_PLUGIN_SMS) << "Dispatching SMS send request to remote"; sendPacket(np); } void SmsPlugin::requestAllConversations() { NetworkPacket np(PACKET_TYPE_SMS_REQUEST_CONVERSATIONS); sendPacket(np); } void SmsPlugin::requestConversation (const qint64& conversationID) const { NetworkPacket np(PACKET_TYPE_SMS_REQUEST_CONVERSATION); np.set(QStringLiteral("threadID"), conversationID); sendPacket(np); } void SmsPlugin::forwardToTelepathy(const ConversationMessage& message) { // If we don't have a valid Telepathy interface, bail out if (!(m_telepathyInterface.isValid())) return; qCDebug(KDECONNECT_PLUGIN_SMS) << "Passing a text message to the telepathy interface"; connect(&m_telepathyInterface, SIGNAL(messageReceived(QString,QString)), SLOT(sendSms(QString,QString)), Qt::UniqueConnection); const QString messageBody = message.body(); const QString contactName; // TODO: When telepathy support is improved, look up the contact with KPeople - const QString phoneNumber = message.address(); + const QString phoneNumber = message.addresses()[0].address(); m_telepathyInterface.call(QDBus::NoBlock, QStringLiteral("sendMessage"), phoneNumber, contactName, messageBody); } bool SmsPlugin::handleBatchMessages(const NetworkPacket& np) { const auto messages = np.get(QStringLiteral("messages")); QList messagesList; messagesList.reserve(messages.count()); for (const QVariant& body : messages) { ConversationMessage message(body.toMap()); if (message.containsTextBody()) { forwardToTelepathy(message); } messagesList.append(message); } m_conversationInterface->addMessages(messagesList); return true; } QString SmsPlugin::dbusPath() const { return QStringLiteral("/modules/kdeconnect/devices/") + device()->id() + QStringLiteral("/sms"); } #include "smsplugin.moc" diff --git a/smsapp/CMakeLists.txt b/smsapp/CMakeLists.txt index 907e3fd1..873d75e1 100644 --- a/smsapp/CMakeLists.txt +++ b/smsapp/CMakeLists.txt @@ -1,49 +1,52 @@ qt5_add_resources(KCSMS_SRCS resources.qrc) find_package(KF5People) add_library(kdeconnectsmshelper smshelper.cpp ) set_target_properties(kdeconnectsmshelper PROPERTIES VERSION ${KDECONNECT_VERSION} SOVERSION ${KDECONNECT_VERSION_MAJOR} ) generate_export_header(kdeconnectsmshelper EXPORT_FILE_NAME ${CMAKE_CURRENT_BINARY_DIR}/kdeconnectsms_export.h BASE_NAME KDEConnectSmsAppLib) target_include_directories(kdeconnectsmshelper PUBLIC ${CMAKE_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR}) target_link_libraries(kdeconnectsmshelper -LINK_PUBLIC +PUBLIC Qt5::Core + Qt5::DBus + KF5::People + kdeconnectinterfaces ) # If ever this library is actually used by someone else, we should export these headers set(libkdeconnectsmshelper_HEADERS smshelper.h ${CMAKE_CURRENT_BINARY_DIR}/kdeconnectsms_export.h ) add_executable(kdeconnect-sms main.cpp conversationlistmodel.cpp conversationmodel.cpp ${KCSMS_SRCS}) target_include_directories(kdeconnect-sms PUBLIC ${CMAKE_BINARY_DIR}) target_link_libraries(kdeconnect-sms kdeconnectsmshelper kdeconnectinterfaces Qt5::Quick Qt5::Widgets KF5::CoreAddons KF5::DBusAddons KF5::I18n KF5::People ) install(TARGETS kdeconnect-sms ${INSTALL_TARGETS_DEFAULT_ARGS}) install(TARGETS kdeconnectsmshelper ${INSTALL_TARGETS_DEFAULT_ARGS} LIBRARY NAMELINK_SKIP) install(PROGRAMS org.kde.kdeconnect.sms.desktop DESTINATION ${KDE_INSTALL_APPDIR}) diff --git a/smsapp/conversationlistmodel.cpp b/smsapp/conversationlistmodel.cpp index 6cf82879..8875f7ff 100644 --- a/smsapp/conversationlistmodel.cpp +++ b/smsapp/conversationlistmodel.cpp @@ -1,224 +1,200 @@ /** * Copyright (C) 2018 Aleix Pol Gonzalez * Copyright (C) 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "conversationlistmodel.h" #include #include +#include #include #include "interfaces/conversationmessage.h" #include "interfaces/dbusinterfaces.h" #include "smshelper.h" Q_LOGGING_CATEGORY(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL, "kdeconnect.sms.conversations_list") OurSortFilterProxyModel::OurSortFilterProxyModel(){} OurSortFilterProxyModel::~OurSortFilterProxyModel(){} ConversationListModel::ConversationListModel(QObject* parent) : QStandardItemModel(parent) , m_conversationsInterface(nullptr) { //qCDebug(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) << "Constructing" << this; auto roles = roleNames(); roles.insert(FromMeRole, "fromMe"); - roles.insert(AddressRole, "address"); - roles.insert(PersonUriRole, "personUri"); + roles.insert(SenderRole, "sender"); + roles.insert(DateRole, "date"); + roles.insert(AddressesRole, "addresses"); roles.insert(ConversationIdRole, "conversationId"); roles.insert(MultitargetRole, "isMultitarget"); - roles.insert(DateRole, "date"); setItemRoleNames(roles); ConversationMessage::registerDbusType(); } ConversationListModel::~ConversationListModel() { } void ConversationListModel::setDeviceId(const QString& deviceId) { if (deviceId == m_deviceId) { return; } if (deviceId.isEmpty()) { return; } qCDebug(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) << "setDeviceId" << deviceId << "of" << this; if (m_conversationsInterface) { - disconnect(m_conversationsInterface, SIGNAL(conversationCreated(QVariantMap)), this, SLOT(handleCreatedConversation(QVariantMap))); - disconnect(m_conversationsInterface, SIGNAL(conversationUpdated(QVariantMap)), this, SLOT(handleConversationUpdated(QVariantMap))); + disconnect(m_conversationsInterface, SIGNAL(conversationCreated(QDBusVariant)), this, SLOT(handleCreatedConversation(QDBusVariant))); + disconnect(m_conversationsInterface, SIGNAL(conversationUpdated(QDBusVariant)), this, SLOT(handleConversationUpdated(QDBusVariant))); delete m_conversationsInterface; m_conversationsInterface = nullptr; } // This method still gets called *with a valid deviceID* when the device is not connected while the component is setting up // Detect that case and don't do anything. DeviceDbusInterface device(deviceId); if (!(device.isValid() && device.isReachable())) { return; } m_deviceId = deviceId; Q_EMIT deviceIdChanged(); m_conversationsInterface = new DeviceConversationsDbusInterface(deviceId, this); - connect(m_conversationsInterface, SIGNAL(conversationCreated(QVariantMap)), this, SLOT(handleCreatedConversation(QVariantMap))); - connect(m_conversationsInterface, SIGNAL(conversationUpdated(QVariantMap)), this, SLOT(handleConversationUpdated(QVariantMap))); + connect(m_conversationsInterface, SIGNAL(conversationCreated(QDBusVariant)), this, SLOT(handleCreatedConversation(QDBusVariant))); + connect(m_conversationsInterface, SIGNAL(conversationUpdated(QDBusVariant)), this, SLOT(handleConversationUpdated(QDBusVariant))); prepareConversationsList(); m_conversationsInterface->requestAllConversationThreads(); } void ConversationListModel::prepareConversationsList() { if (!m_conversationsInterface->isValid()) { qCWarning(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) << "Tried to prepareConversationsList with an invalid interface!"; return; } QDBusPendingReply validThreadIDsReply = m_conversationsInterface->activeConversations(); setWhenAvailable(validThreadIDsReply, [this](const QVariantList& convs) { clear(); // If we clear before we receive the reply, there might be a (several second) visual gap! for (const QVariant& headMessage : convs) { QDBusArgument data = headMessage.value(); - QVariantMap message; + ConversationMessage message; data >> message; - handleCreatedConversation(message); + createRowFromMessage(message); } }, this); } -void ConversationListModel::handleCreatedConversation(const QVariantMap& msg) +void ConversationListModel::handleCreatedConversation(const QDBusVariant& msg) { - createRowFromMessage(msg); + ConversationMessage message = ConversationMessage::fromDBus(msg); + createRowFromMessage(message); } -void ConversationListModel::handleConversationUpdated(const QVariantMap& msg) +void ConversationListModel::handleConversationUpdated(const QDBusVariant& msg) { - createRowFromMessage(msg); + ConversationMessage message = ConversationMessage::fromDBus(msg); + createRowFromMessage(message); } void ConversationListModel::printDBusError(const QDBusError& error) { qCWarning(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) << error; } QStandardItem * ConversationListModel::conversationForThreadId(qint32 threadId) { for(int i=0, c=rowCount(); idata(ConversationIdRole) == threadId) return it; } return nullptr; } -void ConversationListModel::createRowFromMessage(const QVariantMap& msg) +void ConversationListModel::createRowFromMessage(const ConversationMessage& message) { - const ConversationMessage message(msg); if (message.type() == -1) { // The Android side currently hacks in -1 if something weird comes up // TODO: Remove this hack when MMS support is implemented return; } bool toadd = false; QStandardItem* item = conversationForThreadId(message.threadID()); if (!item) { toadd = true; item = new QStandardItem(); - QScopedPointer personData(lookupPersonByAddress(message.address())); - if (personData) { - item->setText(personData->name()); - item->setIcon(QIcon(personData->photo())); - item->setData(personData->personUri(), PersonUriRole); - } else { - item->setData(QString(), PersonUriRole); - item->setText(message.address()); - } + + /** The address of everyone involved in this conversation, which we should not display (check if they are known contacts first) */ + QList rawAddresses = message.addresses(); + + QString displayNames = SmsHelper::getTitleForAddresses(rawAddresses); + QIcon displayIcon = SmsHelper::getIconForAddresses(rawAddresses); + + item->setText(displayNames); + item->setIcon(displayIcon); item->setData(message.threadID(), ConversationIdRole); + item->setData(rawAddresses[0].address(), SenderRole); } // TODO: Upgrade to support other kinds of media // Get the body that we should display QString displayBody = message.containsTextBody() ? message.body() : i18n("(Unsupported Message Type)"); - // TODO: Upgrade with multitarget support - if (message.isMultitarget()) { - item->setText(i18n("(Multitarget Message)")); + // Prepend the sender's name + if (message.isOutgoing()) { + displayBody = i18n("You: %1", displayBody); + } else { + // If the message is incoming, the sender is the first Address + QString senderAddress = item->data(SenderRole).toString(); + QScopedPointer sender(SmsHelper::lookupPersonByAddress(senderAddress)); + QString senderName = sender == nullptr? senderAddress : SmsHelper::lookupPersonByAddress(senderAddress)->name(); + displayBody = i18n("%1: %2", senderName, displayBody); } + // Update the message if the data is newer // This will be true if a conversation receives a new message, but false when the user // does something to trigger past conversation history loading bool oldDateExists; qint64 oldDate = item->data(DateRole).toLongLong(&oldDateExists); if (!oldDateExists || message.date() >= oldDate) { // If there was no old data or incoming data is newer, update the record - item->setData(message.address(), AddressRole); - item->setData(message.type() == ConversationMessage::MessageTypeSent, FromMeRole); + item->setData(QVariant::fromValue(message.addresses()), AddressesRole); + item->setData(message.isOutgoing(), FromMeRole); item->setData(displayBody, Qt::ToolTipRole); item->setData(message.date(), DateRole); item->setData(message.isMultitarget(), MultitargetRole); } if (toadd) appendRow(item); } - -KPeople::PersonData* ConversationListModel::lookupPersonByAddress(const QString& address) -{ - const QString& canonicalAddress = SmsHelper::canonicalizePhoneNumber(address); - int rowIndex = 0; - for (rowIndex = 0; rowIndex < m_people.rowCount(); rowIndex++) { - const QString& uri = m_people.get(rowIndex, KPeople::PersonsModel::PersonUriRole).toString(); - KPeople::PersonData* person = new KPeople::PersonData(uri); - - const QStringList& allEmails = person->allEmails(); - for (const QString& email : allEmails) { - // Although we are nominally an SMS messaging app, it is possible to send messages to phone numbers using email -> sms bridges - if (address == email) { - return person; - } - } - - // TODO: Either upgrade KPeople with an allPhoneNumbers method - const QVariantList allPhoneNumbers = person->contactCustomProperty(QStringLiteral("all-phoneNumber")).toList(); - for (const QVariant& rawPhoneNumber : allPhoneNumbers) { - const QString& phoneNumber = SmsHelper::canonicalizePhoneNumber(rawPhoneNumber.toString()); - bool matchingPhoneNumber = SmsHelper::isPhoneNumberMatchCanonicalized(canonicalAddress, phoneNumber); - - if (matchingPhoneNumber) { - //qCDebug(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) << "Matched" << address << "to" << person->name(); - return person; - } - } - - delete person; - } - - return nullptr; -} diff --git a/smsapp/conversationlistmodel.h b/smsapp/conversationlistmodel.h index 77595b72..5b49e2c5 100644 --- a/smsapp/conversationlistmodel.h +++ b/smsapp/conversationlistmodel.h @@ -1,120 +1,114 @@ /** * Copyright (C) 2018 Aleix Pol Gonzalez * Copyright (C) 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #ifndef CONVERSATIONLISTMODEL_H #define CONVERSATIONLISTMODEL_H #include #include #include -#include -#include #include "interfaces/conversationmessage.h" #include "interfaces/dbusinterfaces.h" Q_DECLARE_LOGGING_CATEGORY(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) class OurSortFilterProxyModel : public QSortFilterProxyModel, public QQmlParserStatus { Q_OBJECT Q_INTERFACES(QQmlParserStatus) Q_PROPERTY(Qt::SortOrder sortOrder READ sortOrder WRITE setSortOrder) public: Qt::SortOrder sortOrder() const { return m_sortOrder; } void setSortOrder(Qt::SortOrder sortOrder) { if (m_sortOrder != sortOrder) { m_sortOrder = sortOrder; sortNow(); } } void classBegin() override {} void componentComplete() override { m_completed = true; sortNow(); } OurSortFilterProxyModel(); ~OurSortFilterProxyModel(); private: void sortNow() { if (m_completed && dynamicSortFilter()) sort(0, m_sortOrder); } bool m_completed = false; Qt::SortOrder m_sortOrder = Qt::AscendingOrder; }; class ConversationListModel : public QStandardItemModel { Q_OBJECT Q_PROPERTY(QString deviceId READ deviceId WRITE setDeviceId NOTIFY deviceIdChanged) public: ConversationListModel(QObject* parent = nullptr); ~ConversationListModel(); enum Roles { + /* Roles which apply while working as a single message */ FromMeRole = Qt::UserRole, - PersonUriRole, - AddressRole, - ConversationIdRole, - DateRole, - MultitargetRole, // Indicate that this conversation is multitarget + SenderRole, // The sender of the message. Undefined if this is an outgoing message + DateRole, // The date of this message + /* Roles which apply while working as the head of a conversation */ + AddressesRole, // The Addresses involved in the conversation + ConversationIdRole, // The ThreadID of the conversation + MultitargetRole, // Indicate that this conversation is multitarget }; Q_ENUM(Roles) QString deviceId() const { return m_deviceId; } void setDeviceId(const QString &/*deviceId*/); public Q_SLOTS: - void handleCreatedConversation(const QVariantMap& msg); - void handleConversationUpdated(const QVariantMap& msg); - void createRowFromMessage(const QVariantMap& message); + void handleCreatedConversation(const QDBusVariant& msg); + void handleConversationUpdated(const QDBusVariant& msg); + void createRowFromMessage(const ConversationMessage& message); void printDBusError(const QDBusError& error); Q_SIGNALS: void deviceIdChanged(); private: /** * Get all conversations currently known by the conversationsInterface, if any */ void prepareConversationsList(); - /** - * Get the data for a particular person given their contact address - */ - KPeople::PersonData* lookupPersonByAddress(const QString& address); - QStandardItem* conversationForThreadId(qint32 threadId); DeviceConversationsDbusInterface* m_conversationsInterface; QString m_deviceId; - KPeople::PersonsModel m_people; }; #endif // CONVERSATIONLISTMODEL_H diff --git a/smsapp/conversationmodel.cpp b/smsapp/conversationmodel.cpp index 99e0c45b..dbca8682 100644 --- a/smsapp/conversationmodel.cpp +++ b/smsapp/conversationmodel.cpp @@ -1,141 +1,150 @@ /** * Copyright (C) 2018 Aleix Pol Gonzalez * Copyright (C) 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "conversationmodel.h" #include #include #include "interfaces/conversationmessage.h" +#include "smshelper.h" Q_LOGGING_CATEGORY(KDECONNECT_SMS_CONVERSATION_MODEL, "kdeconnect.sms.conversation") ConversationModel::ConversationModel(QObject* parent) : QStandardItemModel(parent) , m_conversationsInterface(nullptr) { auto roles = roleNames(); roles.insert(FromMeRole, "fromMe"); roles.insert(DateRole, "date"); + roles.insert(SenderRole, "sender"); + roles.insert(AvatarRole, "avatar"); setItemRoleNames(roles); } ConversationModel::~ConversationModel() { } qint64 ConversationModel::threadId() const { return m_threadId; } void ConversationModel::setThreadId(const qint64& threadId) { if (m_threadId == threadId) return; m_threadId = threadId; clear(); knownMessageIDs.clear(); if (m_threadId != INVALID_THREAD_ID && !m_deviceId.isEmpty()) { requestMoreMessages(); } } void ConversationModel::setDeviceId(const QString& deviceId) { if (deviceId == m_deviceId) return; qCDebug(KDECONNECT_SMS_CONVERSATION_MODEL) << "setDeviceId" << "of" << this; if (m_conversationsInterface) { - disconnect(m_conversationsInterface, SIGNAL(conversationUpdated(QVariantMap)), this, SLOT(handleConversationUpdate(QVariantMap))); + disconnect(m_conversationsInterface, SIGNAL(conversationUpdated(QDBusVariant)), this, SLOT(handleConversationUpdate(QDBusVariant))); delete m_conversationsInterface; } m_deviceId = deviceId; m_conversationsInterface = new DeviceConversationsDbusInterface(deviceId, this); - connect(m_conversationsInterface, SIGNAL(conversationUpdated(QVariantMap)), this, SLOT(handleConversationUpdate(QVariantMap))); + connect(m_conversationsInterface, SIGNAL(conversationUpdated(QDBusVariant)), this, SLOT(handleConversationUpdate(QDBusVariant))); } void ConversationModel::sendReplyToConversation(const QString& message) { //qCDebug(KDECONNECT_SMS_CONVERSATION_MODEL) << "Trying to send" << message << "to conversation with ID" << m_threadId; m_conversationsInterface->replyToConversation(m_threadId, message); } void ConversationModel::requestMoreMessages(const quint32& howMany) { if (m_threadId == INVALID_THREAD_ID) { return; } const auto& numMessages = rowCount(); m_conversationsInterface->requestConversation(m_threadId, numMessages, numMessages + howMany); } -void ConversationModel::createRowFromMessage(const QVariantMap& msg, int pos) -{ - const ConversationMessage message(msg); +QString ConversationModel::getTitleForAddresses(const QList& addresses) { + return SmsHelper::getTitleForAddresses(addresses); +} +void ConversationModel::createRowFromMessage(const ConversationMessage& message, int pos) +{ if (message.threadID() != m_threadId) { // Because of the asynchronous nature of the current implementation of this model, if the // user clicks quickly between threads or for some other reason a message comes when we're // not expecting it, we should not display it in the wrong place qCDebug(KDECONNECT_SMS_CONVERSATION_MODEL) << "Got a message for a thread" << message.threadID() << "but we are currently viewing" << m_threadId << "Discarding."; return; } if (knownMessageIDs.contains(message.uID())) { qCDebug(KDECONNECT_SMS_CONVERSATION_MODEL) << "Ignoring duplicate message with ID" << message.uID(); return; } // TODO: Upgrade to support other kinds of media // Get the body that we should display QString displayBody = message.containsTextBody() ? message.body() : i18n("(Unsupported Message Type)"); + ConversationAddress sender = message.addresses().first(); + QString senderName = message.isMultitarget() ? SmsHelper::getTitleForAddresses({sender}) : QString(); + auto item = new QStandardItem; item->setText(displayBody); item->setData(message.type() == ConversationMessage::MessageTypeSent, FromMeRole); item->setData(message.date(), DateRole); + item->setData(senderName, SenderRole); insertRow(pos, item); knownMessageIDs.insert(message.uID()); } -void ConversationModel::handleConversationUpdate(const QVariantMap& msg) +void ConversationModel::handleConversationUpdate(const QDBusVariant& msg) { - const ConversationMessage message(msg); + ConversationMessage message = ConversationMessage::fromDBus(msg); if (message.threadID() != m_threadId) { // If a conversation which we are not currently viewing was updated, discard the information qCDebug(KDECONNECT_SMS_CONVERSATION_MODEL) << "Saw update for thread" << message.threadID() << "but we are currently viewing" << m_threadId; return; } - createRowFromMessage(msg, 0); + createRowFromMessage(message, 0); } diff --git a/smsapp/conversationmodel.h b/smsapp/conversationmodel.h index ddab1c69..99b39970 100644 --- a/smsapp/conversationmodel.h +++ b/smsapp/conversationmodel.h @@ -1,73 +1,78 @@ /** * Copyright (C) 2018 Aleix Pol Gonzalez * Copyright (C) 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #ifndef CONVERSATIONMODEL_H #define CONVERSATIONMODEL_H #include #include #include +#include "interfaces/conversationmessage.h" #include "interfaces/dbusinterfaces.h" Q_DECLARE_LOGGING_CATEGORY(KDECONNECT_SMS_CONVERSATION_MODEL) #define INVALID_THREAD_ID -1 class ConversationModel : public QStandardItemModel { Q_OBJECT Q_PROPERTY(qint64 threadId READ threadId WRITE setThreadId) Q_PROPERTY(QString deviceId READ deviceId WRITE setDeviceId) public: ConversationModel(QObject* parent = nullptr); ~ConversationModel(); enum Roles { FromMeRole = Qt::UserRole, + SenderRole, // The sender of the message. Undefined if this is an outgoing message DateRole, + AvatarRole, // URI to the avatar of the sender of the message. Undefined if outgoing. }; Q_ENUM(Roles) qint64 threadId() const; void setThreadId(const qint64& threadId); QString deviceId() const { return m_deviceId; } void setDeviceId(const QString &/*deviceId*/); Q_INVOKABLE void sendReplyToConversation(const QString& message); Q_INVOKABLE void requestMoreMessages(const quint32& howMany = 10); + Q_INVOKABLE QString getTitleForAddresses(const QList& addresses); private Q_SLOTS: - void createRowFromMessage(const QVariantMap &msg, int pos); - void handleConversationUpdate(const QVariantMap &msg); + void handleConversationUpdate(const QDBusVariant &message); private: + void createRowFromMessage(const ConversationMessage &message, int pos); + DeviceConversationsDbusInterface* m_conversationsInterface; QString m_deviceId; qint64 m_threadId = INVALID_THREAD_ID; QSet knownMessageIDs; // Set of known Message uIDs }; #endif // CONVERSATIONMODEL_H diff --git a/smsapp/qml/ChatMessage.qml b/smsapp/qml/ChatMessage.qml index 22a87c24..ed724e6f 100644 --- a/smsapp/qml/ChatMessage.qml +++ b/smsapp/qml/ChatMessage.qml @@ -1,109 +1,117 @@ /* * Kaidan - A user-friendly XMPP client for every device! * * Copyright (C) 2016-2018 Kaidan developers and contributors * (see the LICENSE file for a full list of copyright authors) * * Kaidan is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * In addition, as a special exception, the author of Kaidan gives * permission to link the code of its release with the OpenSSL * project's "OpenSSL" library (or with modified versions of it that * use the same license as the "OpenSSL" library), and distribute the * linked executables. You must obey the GNU General Public License in * all respects for all of the code used other than "OpenSSL". If you * modify this file, you may extend this exception to your version of * the file, but you are not obligated to do so. If you do not wish to * do so, delete this exception statement from your version. * * Kaidan is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Kaidan. If not, see . */ import QtQuick 2.6 import QtGraphicalEffects 1.0 import QtQuick.Layouts 1.3 import QtQuick.Controls 2.0 as Controls import org.kde.kirigami 2.0 as Kirigami RowLayout { id: root property bool sentByMe: true property string messageBody property date dateTime property bool isRead: false property string recipientAvatarUrl + property string senderName // own messages are on the right, others on the left layoutDirection: sentByMe ? Qt.RightToLeft : Qt.LeftToRight spacing: Kirigami.Units.largeSpacing width: parent.width - Kirigami.Units.largeSpacing * 4 anchors.horizontalCenter: parent.horizontalCenter RoundImage { id: avatar visible: !sentByMe source: recipientAvatarUrl fillMode: Image.PreserveAspectFit mipmap: true height: width Layout.preferredHeight: Kirigami.Units.gridUnit * 2.2 Layout.preferredWidth: Kirigami.Units.gridUnit * 2.2 Layout.alignment: Qt.AlignHCenter | Qt.AlignTop sourceSize.height: Kirigami.Units.gridUnit * 2.2 sourceSize.width: Kirigami.Units.gridUnit * 2.2 } Rectangle { id: box Layout.preferredWidth: content.width + Kirigami.Units.gridUnit * 0.9 Layout.preferredHeight: content.height + Kirigami.Units.gridUnit * 0.6 color: sentByMe ? Kirigami.Theme.complementaryTextColor : Kirigami.Theme.highlightColor radius: Kirigami.Units.smallSpacing * 2 layer.enabled: box.visible layer.effect: DropShadow { verticalOffset: Kirigami.Units.gridUnit * 0.08 horizontalOffset: Kirigami.Units.gridUnit * 0.08 color: Kirigami.Theme.disabledTextColor samples: 10 spread: 0.1 } ColumnLayout { id: content spacing: 0 anchors.centerIn: box + Controls.Label { + id: senderNameLabel + visible: !sentByMe && senderName != "" + text: senderName + color: Kirigami.Theme.disabledTextColor + } + Controls.Label { text: messageBody textFormat: Text.PlainText wrapMode: Text.Wrap color: sentByMe ? Kirigami.Theme.buttonTextColor : Kirigami.Theme.complementaryTextColor Layout.maximumWidth: root.width - Kirigami.Units.gridUnit * 6 } RowLayout { Controls.Label { id: dateLabel text: Qt.formatDateTime(dateTime, "dd. MMM yyyy, hh:mm") color: Kirigami.Theme.disabledTextColor } } } } Item { Layout.fillWidth: true } } diff --git a/smsapp/qml/ConversationDisplay.qml b/smsapp/qml/ConversationDisplay.qml index 08623658..2c1e8276 100644 --- a/smsapp/qml/ConversationDisplay.qml +++ b/smsapp/qml/ConversationDisplay.qml @@ -1,204 +1,204 @@ /** * Copyright (C) 2018 Aleix Pol Gonzalez * Copyright (C) 2018 Nicolas Fella * Copyright (C) 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import QtQuick 2.1 import QtQuick.Controls 2.2 import QtQuick.Layouts 1.1 import org.kde.people 1.0 import org.kde.kirigami 2.4 as Kirigami import org.kde.kdeconnect.sms 1.0 import QtGraphicalEffects 1.0 Kirigami.ScrollablePage { id: page - property alias personUri: person.personUri - readonly property QtObject person: PersonData { - id: person - } property bool deviceConnected property string deviceId // property QtObject device property string conversationId property bool isMultitarget property string initialMessage - property string phoneNumber - title: person.person && person.person.name ? person.person.name : phoneNumber + property var conversationModel: ConversationModel { + deviceId: page.deviceId + threadId: page.conversationId + } + + property var addresses + title: conversationModel.getTitleForAddresses(addresses) Component.onCompleted: { if (initialMessage.length > 0) { messageField.text = initialMessage; initialMessage = "" } } /** * Build a chat message which is representative of all chat messages * * In other words, one which I can use to get a reasonable height guess */ ChatMessage { id: genericMessage + senderName: "Generic Sender" messageBody: "Generic Message Body" dateTime: new Date('2000-0-0') visible: false enabled: false } ListView { id: viewport model: QSortFilterProxyModel { id: model sortOrder: Qt.AscendingOrder sortRole: ConversationModel.DateRole - sourceModel: ConversationModel { - deviceId: page.deviceId - threadId: page.conversationId - } + sourceModel: conversationModel } spacing: Kirigami.Units.largeSpacing delegate: ChatMessage { + senderName: model.sender messageBody: model.display sentByMe: model.fromMe dateTime: new Date(model.date) ListView.onAdd: { if (index == viewport.count - 1) // This message is being inserted at the newest position // We want to scroll to show it if the user is "almost" looking at it // Define some fudge area. If the message is being drawn offscreen but within // this distance, we move to show it anyway. // Selected to be genericMessage.height because that value scales for different // font sizes / DPI / etc. -- Better ideas are welcome! // Double the value works nicely var offscreenFudge = 2 * genericMessage.height var viewportYBottom = viewport.contentY + viewport.height if (y < viewportYBottom + genericMessage.height) { viewport.currentIndex = index } } } onMovementEnded: { // Unset the highlightRangeMode if it was set previously highlightRangeMode = ListView.ApplyRange highlightMoveDuration: -1 // "Re-enable" the highlight animation if (atYBeginning) { // "Lock" the view to the message currently at the beginning of the view // This prevents the view from snapping to the top of the messages we are about to request currentIndex = 0 // Index 0 is the beginning of the view preferredHighlightBegin = visibleArea.yPosition preferredHighlightEnd = preferredHighlightBegin + currentItem.height highlightRangeMode = ListView.StrictlyEnforceRange highlightMoveDuration = 1 // This is not ideal: I would like to disable the highlight animation altogether // Get more messages - model.sourceModel.requestMoreMessages() + conversationModel.requestMoreMessages() } } } footer: Pane { id: sendingArea enabled: page.deviceConnected && !page.isMultitarget layer.enabled: sendingArea.enabled layer.effect: DropShadow { verticalOffset: 1 color: Kirigami.Theme.disabledTextColor samples: 20 spread: 0.3 } Layout.fillWidth: true padding: 0 wheelEnabled: true background: Rectangle { color: Kirigami.Theme.viewBackgroundColor } RowLayout { anchors.fill: parent TextArea { id: messageField Layout.fillWidth: true placeholderText: page.isMultitarget ? i18n("Replying to multitarget messages is not supported") : i18n("Compose message") wrapMode: TextArea.Wrap topPadding: Kirigami.Units.gridUnit * 0.5 bottomPadding: topPadding selectByMouse: true background: Item {} Keys.onReturnPressed: { if (event.key === Qt.Key_Return) { if (event.modifiers & Qt.ShiftModifier) { messageField.append("") } else { sendButton.onClicked() event.accepted = true } } } } ToolButton { id: sendButton Layout.preferredWidth: Kirigami.Units.gridUnit * 2 Layout.preferredHeight: Kirigami.Units.gridUnit * 2 padding: 0 Kirigami.Icon { source: "document-send" enabled: sendButton.enabled isMask: true smooth: true anchors.centerIn: parent width: Kirigami.Units.gridUnit * 1.5 height: width } onClicked: { // don't send empty messages if (!messageField.text.length) { return } // disable the button to prevent sending // the same message several times sendButton.enabled = false // send the message - model.sourceModel.sendReplyToConversation(messageField.text) + conversationModel.sendReplyToConversation(messageField.text) messageField.text = "" // re-enable the button sendButton.enabled = true } } } } } diff --git a/smsapp/qml/ConversationList.qml b/smsapp/qml/ConversationList.qml index b5598ed9..64139bd1 100644 --- a/smsapp/qml/ConversationList.qml +++ b/smsapp/qml/ConversationList.qml @@ -1,170 +1,169 @@ /** * Copyright (C) 2018 Aleix Pol Gonzalez * Copyright (C) 2018 Nicolas Fella * Copyright (C) 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import QtQuick 2.5 import QtQuick.Controls 2.1 import QtQuick.Layouts 1.1 import org.kde.people 1.0 import org.kde.kirigami 2.4 as Kirigami import org.kde.kdeconnect 1.0 import org.kde.kdeconnect.sms 1.0 Kirigami.ScrollablePage { id: page ToolTip { id: noDevicesWarning visible: !devicesCombo.enabled timeout: -1 text: "⚠️ " + i18n("No devices available") + " ⚠️" MouseArea { // Detect mouseover and show another tooltip with more information anchors.fill: parent hoverEnabled: true ToolTip.visible: containsMouse ToolTip.delay: Qt.styleHints.mousePressAndHoldInterval // TODO: Wrap text if line is too long for the screen ToolTip.text: i18n("No new messages can be sent or received, but you can browse cached content") } } property string initialMessage header: Kirigami.InlineMessage { Layout.fillWidth: true visible: page.initialMessage.length > 0 text: i18n("Choose recipient") actions: [ Kirigami.Action { iconName: "dialog-cancel" text: "Cancel" onTriggered: initialMessage = "" } ] } footer: ComboBox { id: devicesCombo enabled: count > 0 displayText: !enabled ? i18n("No devices available") : undefined model: DevicesSortProxyModel { id: devicesModel //TODO: make it possible to filter if they can do sms sourceModel: DevicesModel { displayFilter: DevicesModel.Paired | DevicesModel.Reachable } onRowsInserted: if (devicesCombo.currentIndex < 0) { devicesCombo.currentIndex = 0 } } textRole: "display" } readonly property bool deviceConnected: devicesCombo.enabled readonly property QtObject device: devicesCombo.currentIndex >= 0 ? devicesModel.data(devicesModel.index(devicesCombo.currentIndex, 0), DevicesModel.DeviceRole) : null readonly property alias lastDeviceId: conversationListModel.deviceId Component { id: chatView ConversationDisplay { deviceId: page.lastDeviceId deviceConnected: page.deviceConnected } } ListView { id: view currentIndex: 0 model: QSortFilterProxyModel { sortOrder: Qt.DescendingOrder sortRole: ConversationListModel.DateRole filterCaseSensitivity: Qt.CaseInsensitive sourceModel: ConversationListModel { id: conversationListModel deviceId: device ? device.id() : "" } } header: TextField { /** * Used as the filter of the list of messages */ id: filter placeholderText: i18n("Filter...") width: parent.width z: 10 onTextChanged: { view.model.setFilterFixedString(filter.text); view.currentIndex = 0 } onAccepted: { view.currentItem.startChat() } Keys.onReturnPressed: { event.accepted = true filter.onAccepted() } Keys.onEscapePressed: { event.accepted = filter.text != "" filter.text = "" } Shortcut { sequence: "Ctrl+F" onActivated: filter.forceActiveFocus() } } headerPositioning: ListView.OverlayHeader Keys.forwardTo: [headerItem] delegate: Kirigami.BasicListItem { hoverEnabled: true label: i18n("%1
%2", display, toolTip) icon: decoration function startChat() { applicationWindow().pageStack.push(chatView, { - personUri: model.personUri, - phoneNumber: address, + addresses: addresses, conversationId: model.conversationId, isMultitarget: isMultitarget, initialMessage: page.initialMessage, device: device}) initialMessage = "" } onClicked: { startChat(); view.currentIndex = index } // Keep the currently-open chat highlighted even if this element is not focused highlighted: chatView.conversationId == model.conversationId } Component.onCompleted: { currentIndex = -1 focus = true } } } diff --git a/smsapp/smshelper.cpp b/smsapp/smshelper.cpp index eac16c44..c081988d 100644 --- a/smsapp/smshelper.cpp +++ b/smsapp/smshelper.cpp @@ -1,116 +1,237 @@ /** * Copyright (C) 2019 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "smshelper.h" -#include +#include +#include #include +#include #include +#include +#include + +#include "interfaces/conversationmessage.h" + Q_LOGGING_CATEGORY(KDECONNECT_SMS_SMSHELPER, "kdeconnect.sms.smshelper") bool SmsHelper::isPhoneNumberMatchCanonicalized(const QString& canonicalPhone1, const QString& canonicalPhone2) { if (canonicalPhone1.isEmpty() || canonicalPhone2.isEmpty()) { // The empty string is not a valid phone number so does not match anything return false; } // To decide if a phone number matches: // 1. Are they similar lengths? If two numbers are very different, probably one is junk data and should be ignored // 2. Is one a superset of the other? Phone number digits get more specific the further towards the end of the string, // so if one phone number ends with the other, it is probably just a more-complete version of the same thing const QString& longerNumber = canonicalPhone1.length() >= canonicalPhone2.length() ? canonicalPhone1 : canonicalPhone2; const QString& shorterNumber = canonicalPhone1.length() < canonicalPhone2.length() ? canonicalPhone1 : canonicalPhone2; const CountryCode& country = determineCountryCode(longerNumber); const bool shorterNumberIsShortCode = isShortCode(shorterNumber, country); const bool longerNumberIsShortCode = isShortCode(longerNumber, country); if ((shorterNumberIsShortCode && !longerNumberIsShortCode) || (!shorterNumberIsShortCode && longerNumberIsShortCode)) { // If only one of the numbers is a short code, they clearly do not match return false; } bool matchingPhoneNumber = longerNumber.endsWith(shorterNumber); return matchingPhoneNumber; } bool SmsHelper::isPhoneNumberMatch(const QString& phone1, const QString& phone2) { const QString& canonicalPhone1 = canonicalizePhoneNumber(phone1); const QString& canonicalPhone2 = canonicalizePhoneNumber(phone2); return isPhoneNumberMatchCanonicalized(canonicalPhone1, canonicalPhone2); } bool SmsHelper::isShortCode(const QString& phoneNumber, const SmsHelper::CountryCode& country) { // Regardless of which country this number belongs to, a number of length less than 6 is a "short code" if (phoneNumber.length() <= 6) { return true; } if (country == CountryCode::Australia && phoneNumber.length() == 8 && phoneNumber.startsWith(QStringLiteral("19"))) { return true; } if (country == CountryCode::CzechRepublic && phoneNumber.length() <= 9) { // This entry of the Wikipedia article is fairly poorly written, so it is not clear whether a // short code with length 7 should start with a 9. Leave it like this for now, upgrade as // we get more information return true; } return false; } SmsHelper::CountryCode SmsHelper::determineCountryCode(const QString& canonicalNumber) { // This is going to fall apart if someone has not entered a country code into their contact book // or if Android decides it can't be bothered to report the country code, but probably we will // be fine anyway if (canonicalNumber.startsWith(QStringLiteral("41"))) { return CountryCode::Australia; } if (canonicalNumber.startsWith(QStringLiteral("420"))) { return CountryCode::CzechRepublic; } // The only countries I care about for the current implementation are Australia and CzechRepublic // If we need to deal with further countries, we should probably find a library return CountryCode::Other; } QString SmsHelper::canonicalizePhoneNumber(const QString& phoneNumber) { QString toReturn(phoneNumber); toReturn = toReturn.remove(QStringLiteral(" ")); toReturn = toReturn.remove(QStringLiteral("-")); toReturn = toReturn.remove(QStringLiteral("(")); toReturn = toReturn.remove(QStringLiteral(")")); toReturn = toReturn.remove(QStringLiteral("+")); toReturn = toReturn.remove(QRegularExpression(QStringLiteral("^0*"))); // Strip leading zeroes if (toReturn.length() == 0) { // If we have stripped away everything, assume this is a special number (and already canonicalized) return phoneNumber; } return toReturn; } + +KPeople::PersonData* SmsHelper::lookupPersonByAddress(const QString& address) +{ + KPeople::PersonsModel m_people; + + const QString& canonicalAddress = SmsHelper::canonicalizePhoneNumber(address); + int rowIndex = 0; + for (rowIndex = 0; rowIndex < m_people.rowCount(); rowIndex++) { + const QString& uri = m_people.get(rowIndex, KPeople::PersonsModel::PersonUriRole).toString(); + KPeople::PersonData* person = new KPeople::PersonData(uri); + + const QStringList& allEmails = person->allEmails(); + for (const QString& email : allEmails) { + // Although we are nominally an SMS messaging app, it is possible to send messages to phone numbers using email -> sms bridges + if (address == email) { + return person; + } + } + + // TODO: Either upgrade KPeople with an allPhoneNumbers method + const QVariantList allPhoneNumbers = person->contactCustomProperty(QStringLiteral("all-phoneNumber")).toList(); + for (const QVariant& rawPhoneNumber : allPhoneNumbers) { + const QString& phoneNumber = SmsHelper::canonicalizePhoneNumber(rawPhoneNumber.toString()); + bool matchingPhoneNumber = SmsHelper::isPhoneNumberMatchCanonicalized(canonicalAddress, phoneNumber); + + if (matchingPhoneNumber) { + //qCDebug(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) << "Matched" << address << "to" << person->name(); + return person; + } + } + + delete person; + } + + return nullptr; +} + +QIcon SmsHelper::combineIcons(const QList& icons) { + QIcon icon; + if (icons.size() == 0) { + // We have no icon :( + // Once we are using the generic icon from KPeople for unknown contacts, this should never happen + } else if (icons.size() == 1) { + icon = icons.first(); + } else { + // Cook an icon by combining the available icons + // Barring better information, use the size of the first icon as the size for the final icon + QSize size = icons.first().size(); + QPixmap canvas(size); + canvas.fill(Qt::transparent); + QPainter painter(&canvas); + + QSize halfSize = size / 2; + + QRect topLeftQuadrant(QPoint(0, 0), halfSize); + QRect topRightQuadrant(topLeftQuadrant.topRight(), halfSize); + QRect bottomLeftQuadrant(topLeftQuadrant.bottomLeft(), halfSize); + QRect bottomRightQuadrant(topLeftQuadrant.bottomRight(), halfSize); + + if (icons.size() == 2) { + painter.drawPixmap(topLeftQuadrant, icons[0]); + painter.drawPixmap(bottomRightQuadrant, icons[1]); + } else if (icons.size() == 3) { + QRect topMiddle(QPoint(halfSize.width() / 2, 0), halfSize); + painter.drawPixmap(topMiddle, icons[0]); + painter.drawPixmap(bottomLeftQuadrant, icons[1]); + painter.drawPixmap(bottomRightQuadrant, icons[2]); + } else { + // Four or more + painter.drawPixmap(topLeftQuadrant, icons[0]); + painter.drawPixmap(topRightQuadrant, icons[1]); + painter.drawPixmap(bottomLeftQuadrant, icons[2]); + painter.drawPixmap(bottomRightQuadrant, icons[3]); + } + + icon = canvas; + } + return icon; +} + +QString SmsHelper::getTitleForAddresses(const QList& addresses) { + QStringList titleParts; + for (const ConversationAddress& address : addresses) { + QScopedPointer personData(SmsHelper::lookupPersonByAddress(address.address())); + + if (personData) { + titleParts.append(personData->name()); + } else { + titleParts.append(address.address()); + } + } + + // It might be nice to alphabetize before combining so that the names don't move around randomly + // (based on how the data came to us from Android) + return titleParts.join(QLatin1String(", ")); +} + +QIcon SmsHelper::getIconForAddresses(const QList& addresses) { + QList icons; + for (const ConversationAddress& address : addresses) { + QScopedPointer personData(SmsHelper::lookupPersonByAddress(address.address())); + + if (personData) { + icons.append(personData->photo()); + } else { + // The contact is not known to KPeople + // TODO: Use generic icon from KPeople + } + } + + // It might be nice to alphabetize by contact before combining so that the pictures don't move + // around randomly (based on how the data came to us from Android) + return combineIcons(icons); +} diff --git a/smsapp/smshelper.h b/smsapp/smshelper.h index 7f4f4be1..ca0d598a 100644 --- a/smsapp/smshelper.h +++ b/smsapp/smshelper.h @@ -1,71 +1,107 @@ /** * Copyright (C) 2019 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #ifndef SMSHELPER_H #define SMSHELPER_H +#include #include +#include + +#include "interfaces/conversationmessage.h" + #include "kdeconnectsms_export.h" Q_DECLARE_LOGGING_CATEGORY(KDECONNECT_SMS_SMSHELPER) class KDECONNECTSMSAPPLIB_EXPORT SmsHelper { public: enum CountryCode { Australia, CzechRepublic, Other, // I only care about a few country codes }; /** * Return true to indicate the two phone numbers should be considered the same, false otherwise */ static bool isPhoneNumberMatch(const QString& phone1, const QString& phone2); /** * Return true to indicate the two phone numbers should be considered the same, false otherwise * Requires canonicalized phone numbers as inputs */ static bool isPhoneNumberMatchCanonicalized(const QString& canonicalPhone1, const QString& canonicalPhone2); /** * See inline comments for how short codes are determined * All information from https://en.wikipedia.org/wiki/Short_code */ static bool isShortCode(const QString& canonicalNumber, const CountryCode& country); /** * Try to guess the country code from the passed number */ static CountryCode determineCountryCode(const QString& canonicalNumber); /** * Simplify a phone number to a known form */ static QString canonicalizePhoneNumber(const QString& phoneNumber); + /** + * Get the data for a particular person given their contact address + */ + static KPeople::PersonData* lookupPersonByAddress(const QString& address); + + /** + * Make an icon which combines the many icons + * + * This mimics what Android does: + * If there is only one icon, use that one + * If there are two icons, put one in the top-left corner and one in the bottom right + * If there are three, put one in the middle of the top and the remaining two in the bottom + * If there are four or more, put one in each corner (If more than four, some will be left out) + */ + static QIcon combineIcons(const QList& icons); + + /** + * Get a combination of all the addresses as a comma-separated list of: + * - The KPeople contact's name (if known) + * - The address (if the contact is not known) + */ + static QString getTitleForAddresses(const QList& addresses); + + /** + * Get a combined icon for all contacts by finding: + * - The KPeople contact's icon (if known) + * - A generic icon + * and then using SmsHelper::combineIcons + */ + static QIcon getIconForAddresses(const QList& addresses); + private: SmsHelper(){}; ~SmsHelper(){}; }; #endif // SMSHELPER_H