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 }