/** * SPDX-FileCopyrightText: 2019 Simon Redman * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ #include "smshelper.h" #include #include #include #include #include #include #include #include #include #include #include #include #include "interfaces/conversationmessage.h" #include "smsapp/gsmasciimap.h" #include "smshelper_debug.h" QObject* SmsHelper::singletonProvider(QQmlEngine *engine, QJSEngine *scriptEngine) { Q_UNUSED(engine); Q_UNUSED(scriptEngine); return new SmsHelper(); } bool SmsHelper::isPhoneNumberMatchCanonicalized(const QString& canonicalPhone1, const QString& canonicalPhone2) { if (canonicalPhone1.isEmpty() || canonicalPhone2.isEmpty()) { // The empty string is not a valid phone number so does not match anything return false; } // 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; } if (country == CountryCode::Australia && phoneNumber.length() == 8 && phoneNumber.startsWith(QStringLiteral("19"))) { 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 if (canonicalNumber.startsWith(QStringLiteral("41"))) { return CountryCode::Australia; } if (canonicalNumber.startsWith(QStringLiteral("420"))) { 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) { static const QRegularExpression leadingZeroes(QStringLiteral("^0*")); QString toReturn(phoneNumber); toReturn = toReturn.remove(QStringLiteral(" ")); toReturn = toReturn.remove(QStringLiteral("-")); toReturn = toReturn.remove(QStringLiteral("(")); toReturn = toReturn.remove(QStringLiteral(")")); toReturn = toReturn.remove(QStringLiteral("+")); toReturn = toReturn.remove(leadingZeroes); if (toReturn.isEmpty()) { // If we have stripped away everything, assume this is a special number (and already canonicalized) return phoneNumber; } return toReturn; } bool SmsHelper::isAddressValid(const QString& address) { QString canonicalizedNumber = canonicalizePhoneNumber(address); // This regular expression matches a wide range of international Phone numbers, minimum of 3 digits and maximum upto 15 digits static const QRegularExpression validNumberPattern(QStringLiteral("^(\\d{3,15})$")); if (validNumberPattern.match(canonicalizedNumber).hasMatch()) { return true; } else { static const QRegularExpression emailPattern(QStringLiteral("^[\\w\\.]*@[\\w\\.]*$")); if (emailPattern.match(address).hasMatch()) { return true; } } return false; } 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 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: KPeople::PersonsModel m_people; QHash> m_personDataCache; }; QList> SmsHelper::getAllPersons() { static PersonsCache s_cache; QList> personDataList; for(int rowIndex = 0; rowIndex < s_cache.count(); rowIndex++) { const auto person = s_cache.personAt(rowIndex); personDataList.append(person); } return personDataList; } QSharedPointer SmsHelper::lookupPersonByAddress(const QString& address) { const QString& canonicalAddress = SmsHelper::canonicalizePhoneNumber(address); QList> personDataList = getAllPersons(); for (const auto& person : personDataList) { const QStringList& allEmails = person->allEmails(); 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& 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& addresses) { QStringList titleParts; for (const ConversationAddress& address : addresses) { const auto personData = SmsHelper::lookupPersonByAddress(address.address()); 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& addresses) { QList icons; for (const ConversationAddress& address : addresses) { const auto personData = SmsHelper::lookupPersonByAddress(address.address()); static const QIcon defaultIcon = QIcon::fromTheme(QStringLiteral("im-user")); static const QPixmap defaultAvatar = defaultIcon.pixmap(defaultIcon.actualSize(QSize(32, 32))); QPixmap avatar; if (personData) { const QVariant pic = personData->contactCustomProperty(QStringLiteral("picture")); if (pic.canConvert()) { avatar = QPixmap::fromImage(pic.value()); } if (avatar.isNull()) { icons.append(defaultAvatar); } else { icons.append(avatar); } } else { icons.append(defaultAvatar); } } // 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); } void SmsHelper::copyToClipboard(const QString& text) { QGuiApplication::clipboard()->setText(text); } 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; } quint64 SmsHelper::totalMessageSize(const QList& urls, const QString& text) { quint64 totalSize = text.size(); for (QUrl url : urls) { QFileInfo fileInfo(url.toLocalFile()); totalSize += fileInfo.size(); } return totalSize; }