File indexing completed on 2024-04-21 04:56:57

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