2019-06-02 15:52:54 +01:00
|
|
|
/**
|
|
|
|
* Copyright (C) 2019 Simon Redman <simon@ergotech.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/>.
|
|
|
|
*/
|
|
|
|
|
|
|
|
#include "smshelper.h"
|
|
|
|
|
2019-07-19 18:33:15 +01:00
|
|
|
#include <QIcon>
|
|
|
|
#include <QPainter>
|
2019-06-02 15:52:54 +01:00
|
|
|
#include <QRegularExpression>
|
2019-07-19 18:33:15 +01:00
|
|
|
#include <QString>
|
2019-06-02 15:52:54 +01:00
|
|
|
#include <QLoggingCategory>
|
2019-07-21 12:28:06 +01:00
|
|
|
#include <QStandardPaths>
|
2019-07-21 12:19:01 +01:00
|
|
|
#include <QHash>
|
2019-06-02 15:52:54 +01:00
|
|
|
|
2019-07-22 12:40:33 +01:00
|
|
|
#include <KPeople/PersonData>
|
|
|
|
#include <KPeople/PersonsModel>
|
2019-07-19 18:33:15 +01:00
|
|
|
|
|
|
|
#include "interfaces/conversationmessage.h"
|
2020-03-22 18:47:12 +00:00
|
|
|
#include "smsapp/gsmasciimap.h"
|
2019-07-19 18:33:15 +01:00
|
|
|
|
2019-06-02 15:52:54 +01:00
|
|
|
Q_LOGGING_CATEGORY(KDECONNECT_SMS_SMSHELPER, "kdeconnect.sms.smshelper")
|
|
|
|
|
|
|
|
bool SmsHelper::isPhoneNumberMatchCanonicalized(const QString& canonicalPhone1, const QString& canonicalPhone2)
|
|
|
|
{
|
2019-06-07 23:39:05 +01:00
|
|
|
if (canonicalPhone1.isEmpty() || canonicalPhone2.isEmpty()) {
|
|
|
|
// The empty string is not a valid phone number so does not match anything
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2019-06-02 15:52:54 +01:00
|
|
|
// 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;
|
|
|
|
}
|
2019-06-10 15:40:28 +01:00
|
|
|
if (country == CountryCode::Australia && phoneNumber.length() == 8 && phoneNumber.startsWith(QStringLiteral("19"))) {
|
2019-06-02 15:52:54 +01:00
|
|
|
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
|
2019-06-10 15:40:28 +01:00
|
|
|
if (canonicalNumber.startsWith(QStringLiteral("41"))) {
|
2019-06-02 15:52:54 +01:00
|
|
|
return CountryCode::Australia;
|
|
|
|
}
|
2019-06-10 15:40:28 +01:00
|
|
|
if (canonicalNumber.startsWith(QStringLiteral("420"))) {
|
2019-06-02 15:52:54 +01:00
|
|
|
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)
|
|
|
|
{
|
2020-04-08 09:23:40 +01:00
|
|
|
static const QRegularExpression leadingZeroes(QStringLiteral("^0*"));
|
|
|
|
|
2019-06-02 15:52:54 +01:00
|
|
|
QString toReturn(phoneNumber);
|
2019-06-10 15:40:28 +01:00
|
|
|
toReturn = toReturn.remove(QStringLiteral(" "));
|
|
|
|
toReturn = toReturn.remove(QStringLiteral("-"));
|
|
|
|
toReturn = toReturn.remove(QStringLiteral("("));
|
|
|
|
toReturn = toReturn.remove(QStringLiteral(")"));
|
|
|
|
toReturn = toReturn.remove(QStringLiteral("+"));
|
2020-04-08 09:23:40 +01:00
|
|
|
toReturn = toReturn.remove(leadingZeroes);
|
2019-06-02 15:52:54 +01:00
|
|
|
|
2020-04-08 09:47:47 +01:00
|
|
|
if (toReturn.isEmpty()) {
|
2019-06-02 15:52:54 +01:00
|
|
|
// If we have stripped away everything, assume this is a special number (and already canonicalized)
|
|
|
|
return phoneNumber;
|
|
|
|
}
|
|
|
|
return toReturn;
|
|
|
|
}
|
2019-07-19 18:33:15 +01:00
|
|
|
|
2019-07-21 12:19:01 +01:00
|
|
|
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<KPeople::PersonData> 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:
|
2019-07-19 18:33:15 +01:00
|
|
|
KPeople::PersonsModel m_people;
|
2019-07-21 12:19:01 +01:00
|
|
|
QHash<QString, QSharedPointer<KPeople::PersonData>> m_personDataCache;
|
|
|
|
};
|
|
|
|
|
2020-03-21 22:57:28 +00:00
|
|
|
QList<QSharedPointer<KPeople::PersonData>> SmsHelper::getAllPersons() {
|
|
|
|
|
2019-07-21 12:19:01 +01:00
|
|
|
static PersonsCache s_cache;
|
2020-03-21 22:57:28 +00:00
|
|
|
QList<QSharedPointer<KPeople::PersonData>> personDataList;
|
2019-07-19 18:33:15 +01:00
|
|
|
|
2020-03-21 22:57:28 +00:00
|
|
|
for(int rowIndex = 0; rowIndex < s_cache.count(); rowIndex++) {
|
2019-07-21 12:19:01 +01:00
|
|
|
const auto person = s_cache.personAt(rowIndex);
|
2020-03-21 22:57:28 +00:00
|
|
|
personDataList.append(person);
|
|
|
|
}
|
|
|
|
return personDataList;
|
|
|
|
}
|
2019-07-19 18:33:15 +01:00
|
|
|
|
2020-03-21 22:57:28 +00:00
|
|
|
QSharedPointer<KPeople::PersonData> SmsHelper::lookupPersonByAddress(const QString& address)
|
|
|
|
{
|
|
|
|
const QString& canonicalAddress = SmsHelper::canonicalizePhoneNumber(address);
|
|
|
|
QList<QSharedPointer<KPeople::PersonData>> personDataList = getAllPersons();
|
|
|
|
|
|
|
|
for (const auto& person : personDataList) {
|
2019-07-19 18:33:15 +01:00
|
|
|
const QStringList& allEmails = person->allEmails();
|
2020-03-21 22:57:28 +00:00
|
|
|
|
2019-07-19 18:33:15 +01:00
|
|
|
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<QPixmap>& 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<ConversationAddress>& addresses) {
|
|
|
|
QStringList titleParts;
|
|
|
|
for (const ConversationAddress& address : addresses) {
|
2019-07-21 12:19:01 +01:00
|
|
|
const auto personData = SmsHelper::lookupPersonByAddress(address.address());
|
2019-07-19 18:33:15 +01:00
|
|
|
|
|
|
|
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<ConversationAddress>& addresses) {
|
|
|
|
QList<QPixmap> icons;
|
|
|
|
for (const ConversationAddress& address : addresses) {
|
2019-07-21 12:19:01 +01:00
|
|
|
const auto personData = SmsHelper::lookupPersonByAddress(address.address());
|
2019-07-19 18:33:15 +01:00
|
|
|
|
|
|
|
if (personData) {
|
|
|
|
icons.append(personData->photo());
|
|
|
|
} else {
|
2019-07-21 12:28:06 +01:00
|
|
|
static QString dummyAvatar = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("kf5/kpeople/dummy_avatar.png"));
|
|
|
|
icons.append(QPixmap(dummyAvatar));
|
2019-07-19 18:33:15 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
}
|
2020-03-22 18:47:12 +00:00
|
|
|
|
|
|
|
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;
|
|
|
|
}
|