File indexing completed on 2023-05-30 09:17:32

0001 /**
0002  * SPDX-FileCopyrightText: 2019 Simon Redman <simon@ergotech.com>
0003  *
0004  * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0005  */
0006 
0007 #include "smshelper.h"
0008 
0009 #include <QClipboard>
0010 #include <QFileInfo>
0011 #include <QGuiApplication>
0012 #include <QHash>
0013 #include <QIcon>
0014 #include <QMimeDatabase>
0015 #include <QMimeType>
0016 #include <QPainter>
0017 #include <QRegularExpression>
0018 #include <QStandardPaths>
0019 #include <QString>
0020 #include <QtDebug>
0021 
0022 #include <KPeople/PersonData>
0023 #include <KPeople/PersonsModel>
0024 
0025 #include "interfaces/conversationmessage.h"
0026 #include "smsapp/gsmasciimap.h"
0027 #include "smshelper_debug.h"
0028 
0029 QObject *SmsHelper::singletonProvider(QQmlEngine *engine, QJSEngine *scriptEngine)
0030 {
0031     Q_UNUSED(engine);
0032     Q_UNUSED(scriptEngine);
0033 
0034     return new SmsHelper();
0035 }
0036 
0037 bool SmsHelper::isPhoneNumberMatchCanonicalized(const QString &canonicalPhone1, const QString &canonicalPhone2)
0038 {
0039     if (canonicalPhone1.isEmpty() || canonicalPhone2.isEmpty()) {
0040         // The empty string is not a valid phone number so does not match anything
0041         return false;
0042     }
0043 
0044     // To decide if a phone number matches:
0045     // 1. Are they similar lengths? If two numbers are very different, probably one is junk data and should be ignored
0046     // 2. Is one a superset of the other? Phone number digits get more specific the further towards the end of the string,
0047     //    so if one phone number ends with the other, it is probably just a more-complete version of the same thing
0048     const QString &longerNumber = canonicalPhone1.length() >= canonicalPhone2.length() ? canonicalPhone1 : canonicalPhone2;
0049     const QString &shorterNumber = canonicalPhone1.length() < canonicalPhone2.length() ? canonicalPhone1 : canonicalPhone2;
0050 
0051     const CountryCode &country = determineCountryCode(longerNumber);
0052 
0053     const bool shorterNumberIsShortCode = isShortCode(shorterNumber, country);
0054     const bool longerNumberIsShortCode = isShortCode(longerNumber, country);
0055 
0056     if ((shorterNumberIsShortCode && !longerNumberIsShortCode) || (!shorterNumberIsShortCode && longerNumberIsShortCode)) {
0057         // If only one of the numbers is a short code, they clearly do not match
0058         return false;
0059     }
0060 
0061     bool matchingPhoneNumber = longerNumber.endsWith(shorterNumber);
0062 
0063     return matchingPhoneNumber;
0064 }
0065 
0066 bool SmsHelper::isPhoneNumberMatch(const QString &phone1, const QString &phone2)
0067 {
0068     const QString &canonicalPhone1 = canonicalizePhoneNumber(phone1);
0069     const QString &canonicalPhone2 = canonicalizePhoneNumber(phone2);
0070 
0071     return isPhoneNumberMatchCanonicalized(canonicalPhone1, canonicalPhone2);
0072 }
0073 
0074 bool SmsHelper::isShortCode(const QString &phoneNumber, const SmsHelper::CountryCode &country)
0075 {
0076     // Regardless of which country this number belongs to, a number of length less than 6 is a "short code"
0077     if (phoneNumber.length() <= 6) {
0078         return true;
0079     }
0080     if (country == CountryCode::Australia && phoneNumber.length() == 8 && phoneNumber.startsWith(QStringLiteral("19"))) {
0081         return true;
0082     }
0083     if (country == CountryCode::CzechRepublic && phoneNumber.length() <= 9) {
0084         // This entry of the Wikipedia article is fairly poorly written, so it is not clear whether a
0085         // short code with length 7 should start with a 9. Leave it like this for now, upgrade as
0086         // we get more information
0087         return true;
0088     }
0089     return false;
0090 }
0091 
0092 SmsHelper::CountryCode SmsHelper::determineCountryCode(const QString &canonicalNumber)
0093 {
0094     // This is going to fall apart if someone has not entered a country code into their contact book
0095     // or if Android decides it can't be bothered to report the country code, but probably we will
0096     // be fine anyway
0097     if (canonicalNumber.startsWith(QStringLiteral("41"))) {
0098         return CountryCode::Australia;
0099     }
0100     if (canonicalNumber.startsWith(QStringLiteral("420"))) {
0101         return CountryCode::CzechRepublic;
0102     }
0103 
0104     // The only countries I care about for the current implementation are Australia and CzechRepublic
0105     // If we need to deal with further countries, we should probably find a library
0106     return CountryCode::Other;
0107 }
0108 
0109 QString SmsHelper::canonicalizePhoneNumber(const QString &phoneNumber)
0110 {
0111     static const QRegularExpression leadingZeroes(QStringLiteral("^0*"));
0112 
0113     QString toReturn(phoneNumber);
0114     toReturn = toReturn.remove(QStringLiteral(" "));
0115     toReturn = toReturn.remove(QStringLiteral("-"));
0116     toReturn = toReturn.remove(QStringLiteral("("));
0117     toReturn = toReturn.remove(QStringLiteral(")"));
0118     toReturn = toReturn.remove(QStringLiteral("+"));
0119     toReturn = toReturn.remove(leadingZeroes);
0120 
0121     if (toReturn.isEmpty()) {
0122         // If we have stripped away everything, assume this is a special number (and already canonicalized)
0123         return phoneNumber;
0124     }
0125     return toReturn;
0126 }
0127 
0128 bool SmsHelper::isAddressValid(const QString &address)
0129 {
0130     QString canonicalizedNumber = canonicalizePhoneNumber(address);
0131 
0132     // This regular expression matches a wide range of international Phone numbers, minimum of 3 digits and maximum upto 15 digits
0133     static const QRegularExpression validNumberPattern(QStringLiteral("^(\\d{3,15})$"));
0134     if (validNumberPattern.match(canonicalizedNumber).hasMatch()) {
0135         return true;
0136     } else {
0137         static const QRegularExpression emailPattern(QStringLiteral("^[\\w\\.]*@[\\w\\.]*$"));
0138         if (emailPattern.match(address).hasMatch()) {
0139             return true;
0140         }
0141     }
0142     return false;
0143 }
0144 
0145 class PersonsCache : public QObject
0146 {
0147 public:
0148     PersonsCache()
0149     {
0150         connect(&m_people, &QAbstractItemModel::rowsRemoved, this, [this](const QModelIndex &parent, int first, int last) {
0151             if (parent.isValid())
0152                 return;
0153             for (int i = first; i <= last; ++i) {
0154                 const QString &uri = m_people.get(i, KPeople::PersonsModel::PersonUriRole).toString();
0155                 m_personDataCache.remove(uri);
0156             }
0157         });
0158     }
0159 
0160     QSharedPointer<KPeople::PersonData> personAt(int rowIndex)
0161     {
0162         const QString &uri = m_people.get(rowIndex, KPeople::PersonsModel::PersonUriRole).toString();
0163         auto &person = m_personDataCache[uri];
0164         if (!person)
0165             person.reset(new KPeople::PersonData(uri));
0166         return person;
0167     }
0168 
0169     int count() const
0170     {
0171         return m_people.rowCount();
0172     }
0173 
0174 private:
0175     KPeople::PersonsModel m_people;
0176     QHash<QString, QSharedPointer<KPeople::PersonData>> m_personDataCache;
0177 };
0178 
0179 QList<QSharedPointer<KPeople::PersonData>> SmsHelper::getAllPersons()
0180 {
0181     static PersonsCache s_cache;
0182     QList<QSharedPointer<KPeople::PersonData>> personDataList;
0183 
0184     for (int rowIndex = 0; rowIndex < s_cache.count(); rowIndex++) {
0185         const auto person = s_cache.personAt(rowIndex);
0186         personDataList.append(person);
0187     }
0188     return personDataList;
0189 }
0190 
0191 QSharedPointer<KPeople::PersonData> SmsHelper::lookupPersonByAddress(const QString &address)
0192 {
0193     const QString &canonicalAddress = SmsHelper::canonicalizePhoneNumber(address);
0194     QList<QSharedPointer<KPeople::PersonData>> personDataList = getAllPersons();
0195 
0196     for (const auto &person : personDataList) {
0197         const QStringList &allEmails = person->allEmails();
0198 
0199         for (const QString &email : allEmails) {
0200             // Although we are nominally an SMS messaging app, it is possible to send messages to phone numbers using email -> sms bridges
0201             if (address == email) {
0202                 return person;
0203             }
0204         }
0205 
0206         // TODO: Either upgrade KPeople with an allPhoneNumbers method
0207         const QVariantList allPhoneNumbers = person->contactCustomProperty(QStringLiteral("all-phoneNumber")).toList();
0208         for (const QVariant &rawPhoneNumber : allPhoneNumbers) {
0209             const QString &phoneNumber = SmsHelper::canonicalizePhoneNumber(rawPhoneNumber.toString());
0210             bool matchingPhoneNumber = SmsHelper::isPhoneNumberMatchCanonicalized(canonicalAddress, phoneNumber);
0211 
0212             if (matchingPhoneNumber) {
0213                 // qCDebug(KDECONNECT_SMS_CONVERSATIONS_LIST_MODEL) << "Matched" << address << "to" << person->name();
0214                 return person;
0215             }
0216         }
0217     }
0218 
0219     return nullptr;
0220 }
0221 
0222 QIcon SmsHelper::combineIcons(const QList<QPixmap> &icons)
0223 {
0224     QIcon icon;
0225     if (icons.size() == 0) {
0226         // We have no icon :(
0227         // Once we are using the generic icon from KPeople for unknown contacts, this should never happen
0228     } else if (icons.size() == 1) {
0229         icon = icons.first();
0230     } else {
0231         // Cook an icon by combining the available icons
0232         // Barring better information, use the size of the first icon as the size for the final icon
0233         QSize size = icons.first().size();
0234         QPixmap canvas(size);
0235         canvas.fill(Qt::transparent);
0236         QPainter painter(&canvas);
0237 
0238         QSize halfSize = size / 2;
0239 
0240         QRect topLeftQuadrant(QPoint(0, 0), halfSize);
0241         QRect topRightQuadrant(topLeftQuadrant.topRight(), halfSize);
0242         QRect bottomLeftQuadrant(topLeftQuadrant.bottomLeft(), halfSize);
0243         QRect bottomRightQuadrant(topLeftQuadrant.bottomRight(), halfSize);
0244 
0245         if (icons.size() == 2) {
0246             painter.drawPixmap(topLeftQuadrant, icons[0]);
0247             painter.drawPixmap(bottomRightQuadrant, icons[1]);
0248         } else if (icons.size() == 3) {
0249             QRect topMiddle(QPoint(halfSize.width() / 2, 0), halfSize);
0250             painter.drawPixmap(topMiddle, icons[0]);
0251             painter.drawPixmap(bottomLeftQuadrant, icons[1]);
0252             painter.drawPixmap(bottomRightQuadrant, icons[2]);
0253         } else {
0254             // Four or more
0255             painter.drawPixmap(topLeftQuadrant, icons[0]);
0256             painter.drawPixmap(topRightQuadrant, icons[1]);
0257             painter.drawPixmap(bottomLeftQuadrant, icons[2]);
0258             painter.drawPixmap(bottomRightQuadrant, icons[3]);
0259         }
0260 
0261         icon = canvas;
0262     }
0263     return icon;
0264 }
0265 
0266 QString SmsHelper::getTitleForAddresses(const QList<ConversationAddress> &addresses)
0267 {
0268     QStringList titleParts;
0269     for (const ConversationAddress &address : addresses) {
0270         const auto personData = SmsHelper::lookupPersonByAddress(address.address());
0271 
0272         if (personData) {
0273             titleParts.append(personData->name());
0274         } else {
0275             titleParts.append(address.address());
0276         }
0277     }
0278 
0279     // It might be nice to alphabetize before combining so that the names don't move around randomly
0280     // (based on how the data came to us from Android)
0281     return titleParts.join(QLatin1String(", "));
0282 }
0283 
0284 QIcon SmsHelper::getIconForAddresses(const QList<ConversationAddress> &addresses)
0285 {
0286     QList<QPixmap> icons;
0287     for (const ConversationAddress &address : addresses) {
0288         const auto personData = SmsHelper::lookupPersonByAddress(address.address());
0289         static const QIcon defaultIcon = QIcon::fromTheme(QStringLiteral("im-user"));
0290         static const QPixmap defaultAvatar = defaultIcon.pixmap(defaultIcon.actualSize(QSize(32, 32)));
0291         QPixmap avatar;
0292         if (personData) {
0293             const QVariant pic = personData->contactCustomProperty(QStringLiteral("picture"));
0294             if (pic.canConvert<QImage>()) {
0295                 avatar = QPixmap::fromImage(pic.value<QImage>());
0296             }
0297             if (avatar.isNull()) {
0298                 icons.append(defaultAvatar);
0299             } else {
0300                 icons.append(avatar);
0301             }
0302         } else {
0303             icons.append(defaultAvatar);
0304         }
0305     }
0306 
0307     // It might be nice to alphabetize by contact before combining so that the pictures don't move
0308     // around randomly (based on how the data came to us from Android)
0309     return combineIcons(icons);
0310 }
0311 
0312 void SmsHelper::copyToClipboard(const QString &text)
0313 {
0314     QGuiApplication::clipboard()->setText(text);
0315 }
0316 
0317 SmsCharCount SmsHelper::getCharCount(const QString &message)
0318 {
0319     const int remainingWhenEmpty = 160;
0320     const int septetsInSingleSms = 160;
0321     const int septetsInSingleConcatSms = 153;
0322     const int charsInSingleUcs2Sms = 70;
0323     const int charsInSingleConcatUcs2Sms = 67;
0324 
0325     SmsCharCount count;
0326     bool enc7bit = true; // 7-bit is used when true, UCS-2 if false
0327     quint32 septets = 0; // GSM encoding character count (characters in extension are counted as 2 chars)
0328     int length = message.length();
0329 
0330     // Count characters and detect encoding
0331     for (int i = 0; i < length; i++) {
0332         QChar ch = message[i];
0333 
0334         if (isInGsmAlphabet(ch)) {
0335             septets++;
0336         } else if (isInGsmAlphabetExtension(ch)) {
0337             septets += 2;
0338         } else {
0339             enc7bit = false;
0340             break;
0341         }
0342     }
0343 
0344     if (length == 0) {
0345         count.bitsPerChar = 7;
0346         count.octets = 0;
0347         count.remaining = remainingWhenEmpty;
0348         count.messages = 1;
0349     } else if (enc7bit) {
0350         count.bitsPerChar = 7;
0351         count.octets = (septets * 7 + 6) / 8;
0352         if (septets > septetsInSingleSms) {
0353             count.messages = (septetsInSingleConcatSms - 1 + septets) / septetsInSingleConcatSms;
0354             count.remaining = (septetsInSingleConcatSms * count.messages - septets) % septetsInSingleConcatSms;
0355         } else {
0356             count.messages = 1;
0357             count.remaining = (septetsInSingleSms - septets) % septetsInSingleSms;
0358         }
0359     } else {
0360         count.bitsPerChar = 16;
0361         count.octets = length * 2; // QString should be in UTF-16
0362         if (length > charsInSingleUcs2Sms) {
0363             count.messages = (charsInSingleConcatUcs2Sms - 1 + length) / charsInSingleConcatUcs2Sms;
0364             count.remaining = (charsInSingleConcatUcs2Sms * count.messages - length) % charsInSingleConcatUcs2Sms;
0365         } else {
0366             count.messages = 1;
0367             count.remaining = (charsInSingleUcs2Sms - length) % charsInSingleUcs2Sms;
0368         }
0369     }
0370 
0371     return count;
0372 }
0373 
0374 bool SmsHelper::isInGsmAlphabet(const QChar &ch)
0375 {
0376     wchar_t unicode = ch.unicode();
0377 
0378     if ((unicode & ~0x7f) == 0) { // If the character is ASCII
0379         // use map
0380         return gsm_ascii_map[unicode];
0381     } else {
0382         switch (unicode) {
0383         case 0xa1: // “¡”
0384         case 0xa7: // “§”
0385         case 0xbf: // “¿”
0386         case 0xa4: // “¤”
0387         case 0xa3: // “£”
0388         case 0xa5: // “¥”
0389         case 0xe0: // “à”
0390         case 0xe5: // “å”
0391         case 0xc5: // “Å”
0392         case 0xe4: // “ä”
0393         case 0xc4: // “Ä”
0394         case 0xe6: // “æ”
0395         case 0xc6: // “Æ”
0396         case 0xc7: // “Ç”
0397         case 0xe9: // “é”
0398         case 0xc9: // “É”
0399         case 0xe8: // “è”
0400         case 0xec: // “ì”
0401         case 0xf1: // “ñ”
0402         case 0xd1: // “Ñ”
0403         case 0xf2: // “ò”
0404         case 0xf6: // “ö”
0405         case 0xd6: // “Ö”
0406         case 0xf8: // “ø”
0407         case 0xd8: // “Ø”
0408         case 0xdf: // “ß”
0409         case 0xf9: // “ù”
0410         case 0xfc: // “ü”
0411         case 0xdc: // “Ü”
0412         case 0x393: // “Γ”
0413         case 0x394: // “Δ”
0414         case 0x398: // “Θ”
0415         case 0x39b: // “Λ”
0416         case 0x39e: // “Ξ”
0417         case 0x3a0: // “Π”
0418         case 0x3a3: // “Σ”
0419         case 0x3a6: // “Φ”
0420         case 0x3a8: // “Ψ”
0421         case 0x3a9: // “Ω”
0422             return true;
0423         }
0424     }
0425     return false;
0426 }
0427 
0428 bool SmsHelper::isInGsmAlphabetExtension(const QChar &ch)
0429 {
0430     wchar_t unicode = ch.unicode();
0431     switch (unicode) {
0432     case '{':
0433     case '}':
0434     case '|':
0435     case '\\':
0436     case '^':
0437     case '[':
0438     case ']':
0439     case '~':
0440     case 0x20ac: // Euro sign
0441         return true;
0442     }
0443     return false;
0444 }
0445 
0446 quint64 SmsHelper::totalMessageSize(const QList<QUrl> &urls, const QString &text)
0447 {
0448     quint64 totalSize = text.size();
0449     for (QUrl url : urls) {
0450         QFileInfo fileInfo(url.toLocalFile());
0451         totalSize += fileInfo.size();
0452     }
0453 
0454     return totalSize;
0455 }
0456 
0457 QIcon SmsHelper::getThumbnailForAttachment(const Attachment &attachment)
0458 {
0459     static const QMimeDatabase mimeDatabase;
0460     const QByteArray rawData = QByteArray::fromBase64(attachment.base64EncodedFile().toUtf8());
0461 
0462     if (attachment.mimeType().startsWith(QStringLiteral("image")) || attachment.mimeType().startsWith(QStringLiteral("video"))) {
0463         QPixmap preview;
0464         preview.loadFromData(rawData);
0465         return QIcon(preview);
0466     } else {
0467         const QMimeType mimeType = mimeDatabase.mimeTypeForData(rawData);
0468         const QIcon mimeIcon = QIcon::fromTheme(mimeType.iconName());
0469         if (mimeIcon.isNull()) {
0470             // I am not sure if QIcon::isNull will actually tell us what we care about but I don't
0471             // know how to trigger the case where we need to use genericIconName instead of iconName
0472             return QIcon::fromTheme(mimeType.genericIconName());
0473         } else {
0474             return mimeIcon;
0475         }
0476     }
0477 }