Show remaining character count in SMS app

## 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
This commit is contained in:
Jiří Wolker 2020-03-22 18:47:12 +00:00 committed by Simon Redman
parent f211d7e401
commit 365791dc59
9 changed files with 443 additions and 34 deletions

View file

@ -4,6 +4,7 @@ find_package(KF5People)
add_library(kdeconnectsmshelper add_library(kdeconnectsmshelper
smshelper.cpp smshelper.cpp
gsmasciimap.cpp
) )
set_target_properties(kdeconnectsmshelper PROPERTIES set_target_properties(kdeconnectsmshelper PROPERTIES

View file

@ -192,3 +192,21 @@ void ConversationModel::handleConversationLoaded(qint64 threadID, quint64 numMes
// so we should not be showing a loading indicator // so we should not be showing a loading indicator
Q_EMIT loadingFinished(); 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();
}
}

View file

@ -78,6 +78,8 @@ public:
* TODO: Move to smshelper (or maybe make part of kirigami-addons chat library?) * TODO: Move to smshelper (or maybe make part of kirigami-addons chat library?)
*/ */
Q_INVOKABLE void copyToClipboard(const QString& message) const; Q_INVOKABLE void copyToClipboard(const QString& message) const;
Q_INVOKABLE QString getCharCountInfo(const QString& message) const;
Q_SIGNALS: Q_SIGNALS:
void loadingFinished(); void loadingFinished();

154
smsapp/gsmasciimap.cpp Normal file
View file

@ -0,0 +1,154 @@
/**
* Copyright (C) 2020 Jiří Wolker <woljiri@gmail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* 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, ~
};

27
smsapp/gsmasciimap.h Normal file
View file

@ -0,0 +1,27 @@
/**
* Copyright (C) 2020 Jiří Wolker <woljiri@gmail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
#ifndef GSMASCIIMAP_H
#define GSMASCIIMAP_H
extern bool gsm_ascii_map[128];
#endif // GSMASCIIMAP_H

View file

@ -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 { // disable the button to prevent sending
id: sendButton // the same message several times
Layout.preferredWidth: Kirigami.Units.gridUnit * 2 sendButton.enabled = false
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
padding: 0 // send the message
Kirigami.Icon { if (page.conversationId == page.invalidId) {
source: "document-send" conversationModel.sendMessageWithoutConversation(messageField.text, page.otherParty)
enabled: sendButton.enabled } else {
isMask: true conversationModel.sendReplyToConversation(messageField.text)
smooth: true }
anchors.centerIn: parent messageField.text = ""
width: Kirigami.Units.gridUnit * 1.5
height: width // re-enable the button
sendButton.enabled = true
}
} }
onClicked: {
// don't send empty messages
if (!messageField.text.length) { Label {
return id: "charCount"
} text: conversationModel.getCharCountInfo(messageField.text)
visible: text.length > 0
// 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
} }
} }
} }

50
smsapp/smscharcount.h Normal file
View file

@ -0,0 +1,50 @@
/**
* Copyright (C) 2020 Jiří Wolker <woljiri@gmail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
#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

View file

@ -32,6 +32,7 @@
#include <KPeople/PersonsModel> #include <KPeople/PersonsModel>
#include "interfaces/conversationmessage.h" #include "interfaces/conversationmessage.h"
#include "smsapp/gsmasciimap.h"
Q_LOGGING_CATEGORY(KDECONNECT_SMS_SMSHELPER, "kdeconnect.sms.smshelper") Q_LOGGING_CATEGORY(KDECONNECT_SMS_SMSHELPER, "kdeconnect.sms.smshelper")
@ -274,3 +275,139 @@ QIcon SmsHelper::getIconForAddresses(const QList<ConversationAddress>& addresses
// around randomly (based on how the data came to us from Android) // around randomly (based on how the data came to us from Android)
return combineIcons(icons); 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;
}

View file

@ -30,6 +30,7 @@
#include "interfaces/conversationmessage.h" #include "interfaces/conversationmessage.h"
#include "kdeconnectsms_export.h" #include "kdeconnectsms_export.h"
#include "smsapp/smscharcount.h"
Q_DECLARE_LOGGING_CATEGORY(KDECONNECT_SMS_SMSHELPER) Q_DECLARE_LOGGING_CATEGORY(KDECONNECT_SMS_SMSHELPER)
@ -106,7 +107,17 @@ public:
* Get the data for all persons currently stored on device * Get the data for all persons currently stored on device
*/ */
static QList<QSharedPointer<KPeople::PersonData>> getAllPersons(); static QList<QSharedPointer<KPeople::PersonData>> 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: private:
SmsHelper(){}; SmsHelper(){};
~SmsHelper(){}; ~SmsHelper(){};