From 365791dc599f9ba2f5f999a3f2b7527793f7ad54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Wolker?= Date: Sun, 22 Mar 2020 18:47:12 +0000 Subject: [PATCH] Show remaining character count in SMS app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This adds character counter below the “Send” button in SMS conversation. It uses format XXX/Y where XXX is number of characters that can be added without splitting the SMS into multiple messages (see article Concatenated SMS on Wikipedia). Y is number of messages in in current concatenated SMS. The counter is not visible when insertion of 10 7-bit or 16-bit (depends on SMS encoding) does not create concatenated SMS. SMS encoding is automatically guessed. 8-bit encodings are not supported. If the message contains characters that are not supported by GSM 7-bit encoding, counter automatically switches to UCS-2. ## Test Plan Try entering some text that is longer than 150 characters in [GSM 03.38 encoding][1] or 60 characters in UCS-2. Number of remaining characters should be visible below the “Send” button. The character counter should show `0` at exactly 160 or 70 characters. Inserting one character should switch the counter to [Concatenated SMS][2] mode when number of messages is shown. It should show exactly same number as SMS app in Android. ## Screenshots These images are in APNG. ![grab.apng](/uploads/21ae23f2fa75c7aca487e61ddce94644/grab.apng) ![grab-cz.apng](/uploads/785e670a8598c5a65a4209f17e75f578/grab-cz.apng) [1]: https://en.wikipedia.org/w/index.php?oldid=932080074#GSM_7-bit_default_alphabet_and_extension_table_of_3GPP_TS_23.038_/_GSM_03.38 [2]: https://en.wikipedia.org/w/index.php?oldid=943185255#Message_size --- smsapp/CMakeLists.txt | 1 + smsapp/conversationmodel.cpp | 18 ++++ smsapp/conversationmodel.h | 2 + smsapp/gsmasciimap.cpp | 154 +++++++++++++++++++++++++++++ smsapp/gsmasciimap.h | 27 +++++ smsapp/qml/ConversationDisplay.qml | 75 +++++++------- smsapp/smscharcount.h | 50 ++++++++++ smsapp/smshelper.cpp | 137 +++++++++++++++++++++++++ smsapp/smshelper.h | 13 ++- 9 files changed, 443 insertions(+), 34 deletions(-) create mode 100644 smsapp/gsmasciimap.cpp create mode 100644 smsapp/gsmasciimap.h create mode 100644 smsapp/smscharcount.h diff --git a/smsapp/CMakeLists.txt b/smsapp/CMakeLists.txt index 873d75e10..ba4a0c6f5 100644 --- a/smsapp/CMakeLists.txt +++ b/smsapp/CMakeLists.txt @@ -4,6 +4,7 @@ find_package(KF5People) add_library(kdeconnectsmshelper smshelper.cpp + gsmasciimap.cpp ) set_target_properties(kdeconnectsmshelper PROPERTIES diff --git a/smsapp/conversationmodel.cpp b/smsapp/conversationmodel.cpp index 20b197a8a..80538eda1 100644 --- a/smsapp/conversationmodel.cpp +++ b/smsapp/conversationmodel.cpp @@ -192,3 +192,21 @@ void ConversationModel::handleConversationLoaded(qint64 threadID, quint64 numMes // 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 15a69a640..8b12cb14a 100644 --- a/smsapp/conversationmodel.h +++ b/smsapp/conversationmodel.h @@ -78,6 +78,8 @@ public: * 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(); diff --git a/smsapp/gsmasciimap.cpp b/smsapp/gsmasciimap.cpp new file mode 100644 index 000000000..21ea608c1 --- /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 000000000..1d969428d --- /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 31abc3ae4..4b9ba03a0 100644 --- a/smsapp/qml/ConversationDisplay.qml +++ b/smsapp/qml/ConversationDisplay.qml @@ -219,41 +219,50 @@ Kirigami.ScrollablePage } } } + + 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 + } - 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 + // 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 = "" + + // re-enable the button + sendButton.enabled = true + } } - 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) - } else { - conversationModel.sendReplyToConversation(messageField.text) - } - messageField.text = "" - - // 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 000000000..3a0cf1a87 --- /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 a5234dbb1..ea0e0e616 100644 --- a/smsapp/smshelper.cpp +++ b/smsapp/smshelper.cpp @@ -32,6 +32,7 @@ #include #include "interfaces/conversationmessage.h" +#include "smsapp/gsmasciimap.h" Q_LOGGING_CATEGORY(KDECONNECT_SMS_SMSHELPER, "kdeconnect.sms.smshelper") @@ -274,3 +275,139 @@ QIcon SmsHelper::getIconForAddresses(const QList& addresses // 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 8cf03c2ed..16c2d6b21 100644 --- a/smsapp/smshelper.h +++ b/smsapp/smshelper.h @@ -30,6 +30,7 @@ #include "interfaces/conversationmessage.h" #include "kdeconnectsms_export.h" +#include "smsapp/smscharcount.h" Q_DECLARE_LOGGING_CATEGORY(KDECONNECT_SMS_SMSHELPER) @@ -106,7 +107,17 @@ public: * 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(){};