diff --git a/interfaces/conversationmessage.cpp b/interfaces/conversationmessage.cpp index 34e5b25d..47234ca2 100644 --- a/interfaces/conversationmessage.cpp +++ b/interfaces/conversationmessage.cpp @@ -1,152 +1,162 @@ /** * 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_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())); } QVariantMap::const_iterator subID_it = args.find(QStringLiteral("sub_id")); m_subID = subID_it == args.end() ? -1 : subID_it->toLongLong(); } ConversationMessage::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, const qint64& subID) : m_eventField(eventField) , m_body(body) , m_addresses(addresses) , m_date(date) , m_type(type) , m_read(read) , m_threadID(threadID) , m_uID(uID) , m_subID(subID) { } ConversationMessage::ConversationMessage(const ConversationMessage& other) : m_eventField(other.m_eventField) , m_body(other.m_body) , 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) , m_subID(other.m_subID) { } ConversationMessage::~ConversationMessage() { } ConversationMessage& ConversationMessage::operator=(const ConversationMessage& other) { this->m_eventField = other.m_eventField; this->m_body = other.m_body; 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; this->m_subID = other.m_subID; 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("addresses"), addresses}, {QStringLiteral("date"), m_date}, {QStringLiteral("type"), m_type}, {QStringLiteral("read"), m_read}, {QStringLiteral("thread_id"), m_threadID}, {QStringLiteral("_id"), m_uID}, {QStringLiteral("sub_id"), m_subID} }; } ConversationAddress::ConversationAddress(QString address) : m_address(address) {} ConversationAddress::ConversationAddress(const ConversationAddress& other) : m_address(other.address()) {} ConversationAddress::~ConversationAddress() {} ConversationAddress& ConversationAddress::operator=(const ConversationAddress& other) { this->m_address = other.m_address; return *this; } +QList ConversationAddress::listfromDBus(const QDBusVariant& var) +{ + QDBusArgument data = var.variant().value(); + QList addresses; + data >> addresses; + return addresses; +} + QVariantMap ConversationAddress::toVariant() const { return { {QStringLiteral("address"), address()}, }; } void ConversationMessage::registerDbusType() { qDBusRegisterMetaType(); qRegisterMetaType(); qDBusRegisterMetaType(); qRegisterMetaType(); + qDBusRegisterMetaType>(); + qRegisterMetaType>(); } diff --git a/interfaces/conversationmessage.h b/interfaces/conversationmessage.h index d0ce7852..bbf3060a 100644 --- a/interfaces/conversationmessage.h +++ b/interfaces/conversationmessage.h @@ -1,232 +1,233 @@ /** * 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 QList& addresses, const qint64& date, const qint32& type, const qint32& read, const qint64& threadID, const qint32& uID, const qint64& subID); 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; } 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; } qint64 subID() const { return m_subID; } 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; /** * 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 */ 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; /** * Value which determines SIM id (optional) */ qint64 m_subID; }; class KDECONNECTINTERFACES_EXPORT ConversationAddress { public: ConversationAddress(QString address = QStringLiteral()); ConversationAddress(const ConversationAddress& other); ~ConversationAddress(); ConversationAddress& operator=(const ConversationAddress& other); + static QList listfromDBus(const QDBusVariant&); 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() << message.subID(); 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; qint64 m_subID; argument.beginStructure(); argument >> event; argument >> body; argument >> addresses; argument >> date; argument >> type; argument >> read; argument >> threadID; argument >> uID; argument >> m_subID; argument.endStructure(); message = ConversationMessage(event, body, addresses, date, type, read, threadID, uID, m_subID); 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); +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 90970304..1be3b02d 100644 --- a/plugins/sms/conversationsdbusinterface.cpp +++ b/plugins/sms/conversationsdbusinterface.cpp @@ -1,224 +1,225 @@ /** * 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 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(QDBusVariant(QVariant::fromValue(message))); } else if (latestMessage) { Q_EMIT conversationUpdated(QDBusVariant(QVariant::fromValue(message))); } } // It feels bad to go through the set of updated conversations again, // but also there are not many times that updatedConversationIDs will be more than one for (qint64 conversationID : updatedConversationIDs) { quint64 numMessages = m_known_messages[conversationID].size(); Q_EMIT conversationLoaded(conversationID, numMessages); } 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()) { qCWarning(KDECONNECT_CONVERSATIONS) << "Got a conversationID for a conversation with no messages!"; return; } 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, messagesList.first().subID()); } -void ConversationsDbusInterface::sendWithoutConversation(const QString& address, const QString& message) { - m_smsInterface.sendSms(address, message); +void ConversationsDbusInterface::sendWithoutConversation(const QDBusVariant& addressList, const QString& message) { + QList addresses = ConversationAddress::listfromDBus(addressList); + 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 cad37f2c..0da7b06f 100644 --- a/plugins/sms/conversationsdbusinterface.h +++ b/plugins/sms/conversationsdbusinterface.h @@ -1,162 +1,162 @@ /** * 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 a new message to the contact having no previous coversation with */ - void sendWithoutConversation(const QString& address, const QString& message); + void sendWithoutConversation(const QDBusVariant& addressList, 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 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 QDBusVariant& msg); /** * Emitted whenever we have handled a response from the phone indicating the total number of * (locally-known) messages in the given conversation */ Q_SCRIPTABLE void conversationLoaded(qint64 conversationID, quint64 messageCount); private /*methods*/: QString newId(); //Generates successive identifiers 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/smsapp/conversationmodel.cpp b/smsapp/conversationmodel.cpp index 6583edad..18933919 100644 --- a/smsapp/conversationmodel.cpp +++ b/smsapp/conversationmodel.cpp @@ -1,199 +1,202 @@ /** * 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(QDBusVariant)), this, SLOT(handleConversationUpdate(QDBusVariant))); disconnect(m_conversationsInterface, SIGNAL(conversationLoaded(qint64, quint64)), this, SLOT(handleConversationLoaded(qint64, quint64))); disconnect(m_conversationsInterface, SIGNAL(conversationCreated(QDBusVariant)), this, SLOT(handleConversationCreated(QDBusVariant))); delete m_conversationsInterface; } m_deviceId = deviceId; m_conversationsInterface = new DeviceConversationsDbusInterface(deviceId, this); connect(m_conversationsInterface, SIGNAL(conversationUpdated(QDBusVariant)), this, SLOT(handleConversationUpdate(QDBusVariant))); connect(m_conversationsInterface, SIGNAL(conversationLoaded(qint64, quint64)), this, SLOT(handleConversationLoaded(qint64, quint64))); connect(m_conversationsInterface, SIGNAL(conversationCreated(QDBusVariant)), this, SLOT(handleConversationCreated(QDBusVariant))); + + connect(this, SIGNAL(sendMessageWithoutConversation(QDBusVariant, QString)), m_conversationsInterface, SLOT(sendWithoutConversation(QDBusVariant, QString))); } -void ConversationModel::setOtherPartyAddress(const QString& address) { - m_otherPartyAddress = address; +void ConversationModel::setAddressList(const QList& addressList) { + m_addressList = addressList; } 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::sendMessageWithoutConversation(const QString& message, const QString& address) +void ConversationModel::startNewConversation(const QString& message, const QList& addressList) { - //qCDebug(KDECONNECT_SMS_CONVERSATION_MODEL) << "Trying to send" << message << "to contact address with no previous Conversation" << "and receiver's address" << address; - m_conversationsInterface->sendWithoutConversation(address, message); + QVariant addresses; + addresses.setValue(addressList); + Q_EMIT sendMessageWithoutConversation(QDBusVariant(addresses), 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 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.isIncoming() ? 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 QDBusVariant& 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(message, 0); } void ConversationModel::handleConversationCreated(const QDBusVariant& msg) { ConversationMessage message = ConversationMessage::fromDBus(msg); - if (m_threadId == INVALID_THREAD_ID && SmsHelper::isPhoneNumberMatch(m_otherPartyAddress, message.addresses().first().address()) && !message.isMultitarget()) { + if (m_threadId == INVALID_THREAD_ID && SmsHelper::isPhoneNumberMatch(m_addressList[0].address(), message.addresses().first().address()) && !message.isMultitarget()) { m_threadId = message.threadID(); createRowFromMessage(message, 0); } } void ConversationModel::handleConversationLoaded(qint64 threadID, quint64 numMessages) { Q_UNUSED(numMessages) if (threadID != m_threadId) { return; } // If we get this flag, it means that the phone will not be responding with any more messages // so we should not be showing a loading indicator Q_EMIT loadingFinished(); } QString ConversationModel::getCharCountInfo(const QString& message) const { SmsCharCount count = SmsHelper::getCharCount(message); - + if (count.messages > 1) { // Show remaining char count and message count return QString::number(count.remaining) + QLatin1Char('/') + QString::number(count.messages); } if (count.messages == 1 && count.remaining < 10) { // Show only remaining char count return QString::number(count.remaining); } else { // Do not show anything return QString(); } } diff --git a/smsapp/conversationmodel.h b/smsapp/conversationmodel.h index 80ec6ba7..96a6b98c 100644 --- a/smsapp/conversationmodel.h +++ b/smsapp/conversationmodel.h @@ -1,90 +1,91 @@ /** * 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) - Q_PROPERTY(QString otherParty READ otherPartyAddress WRITE setOtherPartyAddress) + Q_PROPERTY(QList addressList READ addressList WRITE setAddressList) 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*/); - QString otherPartyAddress() const { return m_otherPartyAddress; } - void setOtherPartyAddress(const QString& address); + QList addressList() const { return m_addressList; } + void setAddressList(const QList& addressList); Q_INVOKABLE void sendReplyToConversation(const QString& message); - Q_INVOKABLE void sendMessageWithoutConversation(const QString& message, const QString& address); + Q_INVOKABLE void startNewConversation(const QString& message, const QList& addressList); Q_INVOKABLE void requestMoreMessages(const quint32& howMany = 10); Q_INVOKABLE QString getCharCountInfo(const QString& message) const; Q_SIGNALS: void loadingFinished(); + void sendMessageWithoutConversation(const QDBusVariant& addressList, const QString& message); private Q_SLOTS: void handleConversationUpdate(const QDBusVariant &message); void handleConversationLoaded(qint64 threadID, quint64 numMessages); void handleConversationCreated(const QDBusVariant &message); private: void createRowFromMessage(const ConversationMessage &message, int pos); DeviceConversationsDbusInterface* m_conversationsInterface; QString m_deviceId; qint64 m_threadId = INVALID_THREAD_ID; - QString m_otherPartyAddress; + QList m_addressList; QSet knownMessageIDs; // Set of known Message uIDs }; #endif // CONVERSATIONMODEL_H diff --git a/smsapp/qml/ConversationDisplay.qml b/smsapp/qml/ConversationDisplay.qml index 82793646..9b683ff3 100644 --- a/smsapp/qml/ConversationDisplay.qml +++ b/smsapp/qml/ConversationDisplay.qml @@ -1,280 +1,279 @@ /** * 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 as Controls 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 bool deviceConnected property string deviceId // property QtObject device property string conversationId property bool isMultitarget property string initialMessage - property string otherParty property string invalidId: "-1" property bool isInitalized: false property var conversationModel: ConversationModel { deviceId: page.deviceId threadId: page.conversationId - otherParty: page.otherParty + addressList: page.addresses onLoadingFinished: { page.isInitalized = true } } property var addresses title: SmsHelper.getTitleForAddresses(addresses) Component.onCompleted: { if (initialMessage.length > 0) { messageField.text = initialMessage; initialMessage = "" } if (conversationId == invalidId) { isInitalized = true } } /** * 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 name: "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 } spacing: Kirigami.Units.largeSpacing highlightMoveDuration: 0 Controls.BusyIndicator { running: !isInitalized } onContentHeightChanged: { if (viewport.contentHeight <= 0) { return } if (!isInitalized) { // If we aren't initialized, we need to request enough messages to fill the view // In order to do that, request one more message until we have enough if (viewport.contentHeight < viewport.height) { console.debug("Requesting another message to fill the screen") conversationModel.requestMoreMessages(1) } else { // Finish initializing: Scroll to the bottom of the view // View the most-recent message viewport.forceLayout() Qt.callLater(viewport.positionViewAtEnd) isInitalized = true } return } } delegate: ChatMessage { name: model.sender messageBody: model.display sentByMe: model.fromMe dateTime: new Date(model.date) ListView.onAdd: { if (!isInitalized) { return } 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.highlightMoveDuration = -1 viewport.currentIndex = index } } } onMessageCopyRequested: { SmsHelper.copyToClipboard(message) } } onMovementEnded: { if (!isInitalized) { return } // Unset the highlightRangeMode if it was set previously highlightRangeMode = ListView.ApplyRange // If we have scrolled to the last message currently in the view, request some more 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 = 0 // Get more messages conversationModel.requestMoreMessages() } } } footer: Controls.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 Controls.ScrollView { Layout.fillWidth: true Layout.maximumHeight: page.height > 300 ? page.height / 3 : 2 * page.height / 3 contentWidth: page.width - sendButtonArea.width clip: true Controls.ScrollBar.horizontal.policy: Controls.ScrollBar.AlwaysOff Controls.TextArea { anchors.fill: parent id: messageField placeholderText: page.isMultitarget ? i18nd("kdeconnect-sms", "Replying to multitarget messages is not supported") : i18nd("kdeconnect-sms", "Compose message") wrapMode: TextArea.Wrap topPadding: Kirigami.Units.gridUnit * 0.5 bottomPadding: topPadding selectByMouse: true topInset: height * 2 // This removes background (frame) of the TextArea. Setting `background: Item {}` would cause segfault. Keys.onReturnPressed: { if (event.key === Qt.Key_Return) { if (event.modifiers & Qt.ShiftModifier) { messageField.append("") } else { sendButton.onClicked() event.accepted = true } } } } } ColumnLayout { id: sendButtonArea Controls.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 if (page.conversationId == page.invalidId) { - conversationModel.sendMessageWithoutConversation(messageField.text, page.otherParty) + conversationModel.startNewConversation(messageField.text, addresses) } else { conversationModel.sendReplyToConversation(messageField.text) } messageField.text = "" // re-enable the button sendButton.enabled = true } } Controls.Label { id: "charCount" text: conversationModel.getCharCountInfo(messageField.text) visible: text.length > 0 Layout.minimumWidth: Math.max(Layout.minimumWidth, width) // Make this label only grow, never shrink } } } } } diff --git a/smsapp/qml/ConversationList.qml b/smsapp/qml/ConversationList.qml index 092acb0e..6e9f0a6c 100644 --- a/smsapp/qml/ConversationList.qml +++ b/smsapp/qml/ConversationList.qml @@ -1,286 +1,285 @@ /** * 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.6 as Kirigami import org.kde.kdeconnect 1.0 import org.kde.kdeconnect.sms 1.0 Kirigami.ScrollablePage { id: page ToolTip { id: noDevicesWarning visible: !page.deviceConnected timeout: -1 text: "⚠️ " + i18nd("kdeconnect-sms", "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: i18nd("kdeconnect-sms", "No new messages can be sent or received, but you can browse cached content") } } contextualActions: [ Kirigami.Action { text: i18nd("kdeconnect-sms", "Refresh") icon.name: "view-refresh" enabled: devicesCombo.count > 0 onTriggered: { conversationListModel.refresh() } } ] Label { id: searchResultIndiactor visible: deviceConnected && view.count == 0 && view.headerItem.childAt(0, 0).text.length != 0 anchors.centerIn: parent text: i18nd("kdeconnect-sms", "No matched results found : (") horizontalAlignment: Text.AlignHCenter wrapMode: Text.Wrap } ColumnLayout { id: loadingMessage visible: deviceConnected && view.count == 0 && view.headerItem.childAt(0, 0).text.length == 0 anchors.centerIn: parent BusyIndicator { running: loadingMessage.visible Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter } Label { text: "Loading conversations from device. If this takes a long time, please wake up your device and then click refresh." Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter Layout.preferredWidth: page.width / 2 horizontalAlignment: Text.AlignHCenter wrapMode: Text.Wrap } Label { text: "Tip: If you plug in your device, it should not go into doze mode and should load quickly." Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter Layout.preferredWidth: page.width / 2 horizontalAlignment: Text.AlignHCenter wrapMode: Text.Wrap } } property string initialMessage property string initialDevice header: Kirigami.InlineMessage { Layout.fillWidth: true visible: page.initialMessage.length > 0 text: i18nd("kdeconnect-sms", "Choose recipient") actions: [ Kirigami.Action { iconName: "dialog-cancel" text: i18nd("kdeconnect-sms", "Cancel") onTriggered: initialMessage = "" } ] } footer: ComboBox { id: devicesCombo enabled: count > 0 displayText: !enabled ? i18nd("kdeconnect-sms", "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) { if (page.initialDevice) devicesCombo.currentIndex = devicesModel.rowForDevice(page.initialDevice); else 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 filterCaseSensitivity: Qt.CaseInsensitive sourceModel: ConversationListModel { id: conversationListModel deviceId: device ? device.id() : "" } } header: RowLayout { width: parent.width z: 10 Keys.forwardTo: [filter] TextField { /** * Used as the filter of the list of messages */ id: filter placeholderText: i18nd("kdeconnect-sms", "Filter...") Layout.fillWidth: true Layout.fillHeight: true onTextChanged: { if (filter.text != "") { view.model.setConversationsFilterRole(ConversationListModel.AddressesRole) } else { view.model.setConversationsFilterRole(ConversationListModel.ConversationIdRole) } view.model.setFilterFixedString(SmsHelper.canonicalizePhoneNumber(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() } } Button { id: newButton text: i18nd("kdeconnect-sms", "New") visible: true enabled: SmsHelper.isAddressValid(filter.text) && deviceConnected ToolTip.visible: hovered ToolTip.delay: Qt.styleHints.mousePressAndHoldInterval ToolTip.text: i18nd("kdeconnect-sms", "Start new conversation") onClicked: { // We have to disable the filter temporarily in order to avoid getting key inputs accidently while processing the request filter.enabled = false // If the address entered by the user already exists then ignore adding new contact if (!view.model.doesAddressExists(filter.text) && SmsHelper.isAddressValid(filter.text)) { conversationListModel.createConversationForAddress(filter.text) view.currentIndex = 0 } } Shortcut { sequence: "Ctrl+N" onActivated: newButton.onClicked() } } } headerPositioning: ListView.OverlayHeader Keys.forwardTo: [headerItem] delegate: Kirigami.AbstractListItem { id: listItem contentItem: RowLayout { Kirigami.Icon { id: iconItem source: decoration readonly property int size: Kirigami.Units.iconSizes.smallMedium Layout.minimumHeight: size Layout.maximumHeight: size Layout.minimumWidth: size selected: listItem.highlighted || listItem.checked || (listItem.pressed && listItem.supportsMouseEvents) } ColumnLayout { Label { Layout.fillWidth: true font.weight: Font.Bold text: display maximumLineCount: 1 elide: Text.ElideRight textFormat: Text.PlainText } Label { Layout.fillWidth: true text: toolTip maximumLineCount: 1 elide: Text.ElideRight textFormat: Text.PlainText } } } function startChat() { applicationWindow().pageStack.push(chatView, { addresses: addresses, conversationId: model.conversationId, isMultitarget: isMultitarget, initialMessage: page.initialMessage, - device: device, - otherParty: sender}) + 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 } } }