diff --git a/smsapp/CMakeLists.txt b/smsapp/CMakeLists.txt index 873d75e1..ba4a0c6f 100644 --- a/smsapp/CMakeLists.txt +++ b/smsapp/CMakeLists.txt @@ -1,52 +1,53 @@ qt5_add_resources(KCSMS_SRCS resources.qrc) find_package(KF5People) add_library(kdeconnectsmshelper smshelper.cpp + gsmasciimap.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 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/conversationmodel.cpp b/smsapp/conversationmodel.cpp index 20b197a8..80538eda 100644 --- a/smsapp/conversationmodel.cpp +++ b/smsapp/conversationmodel.cpp @@ -1,194 +1,212 @@ /** * 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 #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))); } void ConversationModel::setOtherPartyAddress(const QString& address) { m_otherPartyAddress = address; } 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) { //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); } 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); } QString ConversationModel::getTitleForAddresses(const QList& addresses) { return SmsHelper::getTitleForAddresses(addresses); } void ConversationModel::copyToClipboard(const QString& message) const { // TODO: Remove this method as part of abstracting GUI elements to library QGuiApplication::clipboard()->setText(message); } 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()) { 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 15a69a64..8b12cb14 100644 --- a/smsapp/conversationmodel.h +++ b/smsapp/conversationmodel.h @@ -1,100 +1,102 @@ /** * 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) 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); Q_INVOKABLE void sendReplyToConversation(const QString& message); Q_INVOKABLE void sendMessageWithoutConversation(const QString& message, const QString& address); Q_INVOKABLE void requestMoreMessages(const quint32& howMany = 10); /** * Convert a list of names into a single string suitable for display * TODO: This is here because I don't know how to make the QML call the smshelper directly * but that is what should be happening! */ Q_INVOKABLE QString getTitleForAddresses(const QList& addresses); /** * This is the action invoked by the right-click menu to copy to the clipboard * QML can't do this directly but this should be part of smshelper * TODO: Move to smshelper (or maybe make part of kirigami-addons chat library?) */ Q_INVOKABLE void copyToClipboard(const QString& message) const; + + Q_INVOKABLE QString getCharCountInfo(const QString& message) const; Q_SIGNALS: void loadingFinished(); 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; QSet knownMessageIDs; // Set of known Message uIDs }; #endif // CONVERSATIONMODEL_H diff --git a/smsapp/gsmasciimap.cpp b/smsapp/gsmasciimap.cpp new file mode 100644 index 00000000..21ea608c --- /dev/null +++ b/smsapp/gsmasciimap.cpp @@ -0,0 +1,154 @@ +/** + * Copyright (C) 2020 Jiří Wolker + * + * 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 . + */ + +/** + * Map used to determine that ASCII character is in GSM 03.38 7-bit alphabet. + * + * Only allowed control characters are CR and LF but GSM alphabet has more of them. + */ +bool gsm_ascii_map[] = { + false, // 0x0, some control char + false, // 0x1, some control char + false, // 0x2, some control char + false, // 0x3, some control char + false, // 0x4, some control char + false, // 0x5, some control char + false, // 0x6, some control char + false, // 0x7, some control char + false, // 0x8, some control char + false, // 0x9, some control char + true, // 0xA, LF + false, // 0xB, some control char + false, // 0xC, some control char + true, // 0xD, CR + false, // 0xE, some control char + false, // 0xF, some control char + false, // 0x10, some control char + false, // 0x11, some control char + false, // 0x12, some control char + false, // 0x13, some control char + false, // 0x14, some control char + false, // 0x15, some control char + false, // 0x16, some control char + false, // 0x17, some control char + false, // 0x18, some control char + false, // 0x19, some control char + false, // 0x1A, some control char + false, // 0x1B, some control char + false, // 0x1C, some control char + false, // 0x1D, some control char + false, // 0x1E, some control char + false, // 0x1F, some control char + true, // 20, space + true, // 21, ! + true, // 22, " + true, // 23, # + true, // 24, $ + true, // 25, % + true, // 26, & + true, // 27, ' + true, // 28, ( + true, // 29, ) + true, // 2A, * + true, // 2B, + + true, // 2C, , + true, // 2D, - + true, // 2E, . + true, // 2F, / + true, // 30, 0 + true, // 31, 1 + true, // 32, 2 + true, // 33, 3 + true, // 34, 4 + true, // 35, 5 + true, // 36, 6 + true, // 37, 7 + true, // 38, 8 + true, // 39, 9 + true, // 3A, : + true, // 3B, ; + true, // 3C, < + true, // 3D, = + true, // 3E, > + true, // 3F, ? + true, // 40, @ + true, // 41, A + true, // 42, B + true, // 43, C + true, // 44, D + true, // 45, E + true, // 46, F + true, // 47, G + true, // 48, H + true, // 49, I + true, // 4A, J + true, // 4B, K + true, // 4C, L + true, // 4D, M + true, // 4E, N + true, // 4F, O + true, // 50, P + true, // 51, Q + true, // 52, R + true, // 53, S + true, // 54, T + true, // 55, U + true, // 56, V + true, // 57, W + true, // 58, X + true, // 59, Y + true, // 5A, Z + false, // 5B, [ + false, // 5C, backslash + false, // 5D, ] + false, // 5E, ^ + true, // 5F, _ + false, // 60, ` + true, // 61, a + true, // 62, b + true, // 63, c + true, // 64, d + true, // 65, e + true, // 66, f + true, // 67, g + true, // 68, h + true, // 69, i + true, // 6A, j + true, // 6B, k + true, // 6C, l + true, // 6D, m + true, // 6E, n + true, // 6F, o + true, // 70, p + true, // 71, q + true, // 72, r + true, // 73, s + true, // 74, t + true, // 75, u + true, // 76, v + true, // 77, w + true, // 78, x + true, // 79, y + true, // 7A, z + false, // 7B, { + false, // 7C, | + false, // 7D, } + false, // 7E, ~ +}; diff --git a/smsapp/gsmasciimap.h b/smsapp/gsmasciimap.h new file mode 100644 index 00000000..1d969428 --- /dev/null +++ b/smsapp/gsmasciimap.h @@ -0,0 +1,27 @@ +/** + * Copyright (C) 2020 Jiří Wolker + * + * 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 GSMASCIIMAP_H +#define GSMASCIIMAP_H + +extern bool gsm_ascii_map[128]; + +#endif // GSMASCIIMAP_H + diff --git a/smsapp/qml/ConversationDisplay.qml b/smsapp/qml/ConversationDisplay.qml index 31abc3ae..4b9ba03a 100644 --- a/smsapp/qml/ConversationDisplay.qml +++ b/smsapp/qml/ConversationDisplay.qml @@ -1,261 +1,270 @@ /** * 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 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 onLoadingFinished: { page.isInitalized = true } } property var addresses title: conversationModel.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 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: { conversationModel.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: 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 ? 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 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 + + ColumnLayout { + 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 + // 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) - } else { - conversationModel.sendReplyToConversation(messageField.text) - } - messageField.text = "" + // send the message + if (page.conversationId == page.invalidId) { + conversationModel.sendMessageWithoutConversation(messageField.text, page.otherParty) + } else { + conversationModel.sendReplyToConversation(messageField.text) + } + messageField.text = "" - // re-enable the button - sendButton.enabled = true + // re-enable the button + sendButton.enabled = true + } + } + + + Label { + id: "charCount" + text: conversationModel.getCharCountInfo(messageField.text) + visible: text.length > 0 } } } } } diff --git a/smsapp/smscharcount.h b/smsapp/smscharcount.h new file mode 100644 index 00000000..3a0cf1a8 --- /dev/null +++ b/smsapp/smscharcount.h @@ -0,0 +1,50 @@ +/** + * Copyright (C) 2020 Jiří Wolker + * + * 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 CHARCOUNT_H +#define CHARCOUNT_H + +class SmsCharCount { +public: + /** + * Number of octets in current message. + */ + qint32 octets; + + /** + * Bits per character (7, 8 or 16). + */ + qint32 bitsPerChar; + + /** + * Number of chars remaining in current SMS. + */ + qint32 remaining; + + /** + * Count of SMSes in concatenated SMS. + */ + qint32 messages; + + SmsCharCount(){}; + ~SmsCharCount(){}; +}; + +#endif // CHARCOUNT_H diff --git a/smsapp/smshelper.cpp b/smsapp/smshelper.cpp index a5234dbb..ea0e0e61 100644 --- a/smsapp/smshelper.cpp +++ b/smsapp/smshelper.cpp @@ -1,276 +1,413 @@ /** * 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 #include "interfaces/conversationmessage.h" +#include "smsapp/gsmasciimap.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; } class PersonsCache : public QObject { public: PersonsCache() { connect(&m_people, &QAbstractItemModel::rowsRemoved, this, [this] (const QModelIndex &parent, int first, int last) { if (parent.isValid()) return; for (int i=first; i<=last; ++i) { const QString& uri = m_people.get(i, KPeople::PersonsModel::PersonUriRole).toString(); m_personDataCache.remove(uri); } }); } QSharedPointer personAt(int rowIndex) { const QString& uri = m_people.get(rowIndex, KPeople::PersonsModel::PersonUriRole).toString(); auto& person = m_personDataCache[uri]; if (!person) person.reset(new KPeople::PersonData(uri)); return person; } int count() const { return m_people.rowCount(); } private: KPeople::PersonsModel m_people; QHash> m_personDataCache; }; QList> SmsHelper::getAllPersons() { static PersonsCache s_cache; QList> personDataList; for(int rowIndex = 0; rowIndex < s_cache.count(); rowIndex++) { const auto person = s_cache.personAt(rowIndex); personDataList.append(person); } return personDataList; } QSharedPointer SmsHelper::lookupPersonByAddress(const QString& address) { const QString& canonicalAddress = SmsHelper::canonicalizePhoneNumber(address); QList> personDataList = getAllPersons(); for (const auto& person : personDataList) { 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; } } } 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) { const auto 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) { const auto personData = SmsHelper::lookupPersonByAddress(address.address()); if (personData) { icons.append(personData->photo()); } else { static QString dummyAvatar = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("kf5/kpeople/dummy_avatar.png")); icons.append(QPixmap(dummyAvatar)); } } // 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); } + +SmsCharCount SmsHelper::getCharCount(const QString& message) +{ + const int remainingWhenEmpty = 160; + const int septetsInSingleSms = 160; + const int septetsInSingleConcatSms = 153; + const int charsInSingleUcs2Sms = 70; + const int charsInSingleConcatUcs2Sms = 67; + + SmsCharCount count; + bool enc7bit = true; // 7-bit is used when true, UCS-2 if false + quint32 septets = 0; // GSM encoding character count (characters in extension are counted as 2 chars) + int length = message.length(); + + // Count characters and detect encoding + for (int i = 0; i < length; i++) { + QChar ch = message[i]; + + if (isInGsmAlphabet(ch)) { + septets++; + } + else if (isInGsmAlphabetExtension(ch)) { + septets += 2; + } + else { + enc7bit = false; + break; + } + } + + if (length == 0) { + count.bitsPerChar = 7; + count.octets = 0; + count.remaining = remainingWhenEmpty; + count.messages = 1; + } + else if (enc7bit) { + count.bitsPerChar = 7; + count.octets = (septets * 7 + 6) / 8; + if (septets > septetsInSingleSms) { + count.messages = (septetsInSingleConcatSms - 1 + septets) / septetsInSingleConcatSms; + count.remaining = (septetsInSingleConcatSms * count.messages - septets) % septetsInSingleConcatSms; + } + else { + count.messages = 1; + count.remaining = (septetsInSingleSms - septets) % septetsInSingleSms; + } + } + else { + count.bitsPerChar = 16; + count.octets = length * 2; // QString should be in UTF-16 + if (length > charsInSingleUcs2Sms) { + count.messages = (charsInSingleConcatUcs2Sms - 1 + length) / charsInSingleConcatUcs2Sms; + count.remaining = (charsInSingleConcatUcs2Sms * count.messages - length) % charsInSingleConcatUcs2Sms; + } + else { + count.messages = 1; + count.remaining = (charsInSingleUcs2Sms - length) % charsInSingleUcs2Sms; + } + } + + return count; +} + +bool SmsHelper::isInGsmAlphabet(const QChar& ch) +{ + wchar_t unicode = ch.unicode(); + + if ((unicode & ~0x7f) == 0) { // If the character is ASCII + // use map + return gsm_ascii_map[unicode]; + } + else { + switch (unicode) { + case 0xa1: // “¡” + case 0xa7: // “§” + case 0xbf: // “¿” + case 0xa4: // “¤” + case 0xa3: // “£” + case 0xa5: // “¥” + case 0xe0: // “à” + case 0xe5: // “å” + case 0xc5: // “Å” + case 0xe4: // “ä” + case 0xc4: // “Ä” + case 0xe6: // “æ” + case 0xc6: // “Æ” + case 0xc7: // “Ç” + case 0xe9: // “é” + case 0xc9: // “É” + case 0xe8: // “è” + case 0xec: // “ì” + case 0xf1: // “ñ” + case 0xd1: // “Ñ” + case 0xf2: // “ò” + case 0xf5: // “ö” + case 0xd6: // “Ö” + case 0xf8: // “ø” + case 0xd8: // “Ø” + case 0xdf: // “ß” + case 0xf9: // “ù” + case 0xfc: // “ü” + case 0xdc: // “Ü” + case 0x393: // “Γ” + case 0x394: // “Δ” + case 0x398: // “Θ” + case 0x39b: // “Λ” + case 0x39e: // “Ξ” + case 0x3a0: // “Π” + case 0x3a3: // “Σ” + case 0x3a6: // “Φ” + case 0x3a8: // “Ψ” + case 0x3a9: // “Ω” + return true; + } + } + return false; +} + +bool SmsHelper::isInGsmAlphabetExtension(const QChar& ch) +{ + wchar_t unicode = ch.unicode(); + switch (unicode) { + case '{': + case '}': + case '|': + case '\\': + case '^': + case '[': + case ']': + case '~': + case 0x20ac: // Euro sign + return true; + } + return false; +} diff --git a/smsapp/smshelper.h b/smsapp/smshelper.h index 8cf03c2e..16c2d6b2 100644 --- a/smsapp/smshelper.h +++ b/smsapp/smshelper.h @@ -1,115 +1,126 @@ /** * 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 #include "interfaces/conversationmessage.h" #include "kdeconnectsms_export.h" +#include "smsapp/smscharcount.h" Q_DECLARE_LOGGING_CATEGORY(KDECONNECT_SMS_SMSHELPER) class PersonsCache; 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 QSharedPointer 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); /** * Get the data for all persons currently stored on device */ static QList> getAllPersons(); - + + /** + * Get SMS character count status of SMS. It contains number of remaining characters + * in current SMS (automatically selects 7-bit, 8-bit or 16-bit mode), octet count and + * number of messages in concatenated SMS. + */ + static SmsCharCount getCharCount(const QString& message); + + static bool isInGsmAlphabet(const QChar& ch); + static bool isInGsmAlphabetExtension(const QChar& ch); + private: SmsHelper(){}; ~SmsHelper(){}; }; #endif // SMSHELPER_H