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"