diff --git a/smsapp/qml/ConversationList.qml b/smsapp/qml/ConversationList.qml index 1d660333..8cfa1376 100644 --- a/smsapp/qml/ConversationList.qml +++ b/smsapp/qml/ConversationList.qml @@ -1,285 +1,278 @@ /** * Copyright (C) 2018 Aleix Pol Gonzalez * Copyright (C) 2018 Nicolas Fella * Copyright (C) 2018 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import QtQuick 2.5 import QtQuick.Controls 2.1 import QtQuick.Layouts 1.1 import org.kde.people 1.0 import org.kde.kirigami 2.6 as Kirigami import org.kde.kdeconnect 1.0 import org.kde.kdeconnect.sms 1.0 Kirigami.ScrollablePage { id: page ToolTip { id: noDevicesWarning visible: !page.deviceConnected timeout: -1 text: "⚠️ " + i18nd("kdeconnect-sms", "No devices available") + " ⚠️" MouseArea { // Detect mouseover and show another tooltip with more information anchors.fill: parent hoverEnabled: true ToolTip.visible: containsMouse ToolTip.delay: Qt.styleHints.mousePressAndHoldInterval // TODO: Wrap text if line is too long for the screen ToolTip.text: i18nd("kdeconnect-sms", "No new messages can be sent or received, but you can browse cached content") } } contextualActions: [ Kirigami.Action { text: i18nd("kdeconnect-sms", "Refresh") icon.name: "view-refresh" enabled: devicesCombo.count > 0 onTriggered: { conversationListModel.refresh() } } ] ColumnLayout { id: loadingMessage visible: deviceConnected && view.count == 0 anchors.centerIn: parent BusyIndicator { running: loadingMessage.visible Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter } Label { text: "Loading conversations from device. If this takes a long time, please wake up your device and then click refresh." Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter Layout.preferredWidth: page.width / 2 horizontalAlignment: Text.AlignHCenter wrapMode: Text.Wrap } Label { text: "Tip: If you plug in your device, it should not go into doze mode and should load quickly." Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter Layout.preferredWidth: page.width / 2 horizontalAlignment: Text.AlignHCenter wrapMode: Text.Wrap } } property string initialMessage property string initialDevice header: Kirigami.InlineMessage { Layout.fillWidth: true visible: page.initialMessage.length > 0 text: i18nd("kdeconnect-sms", "Choose recipient") actions: [ Kirigami.Action { iconName: "dialog-cancel" text: i18nd("kdeconnect-sms", "Cancel") onTriggered: initialMessage = "" } ] } footer: ComboBox { id: devicesCombo enabled: count > 0 displayText: !enabled ? i18nd("kdeconnect-sms", "No devices available") : undefined model: DevicesSortProxyModel { id: devicesModel //TODO: make it possible to filter if they can do sms sourceModel: DevicesModel { displayFilter: DevicesModel.Paired | DevicesModel.Reachable } onRowsInserted: if (devicesCombo.currentIndex < 0) { if (page.initialDevice) devicesCombo.currentIndex = devicesModel.rowForDevice(page.initialDevice); else devicesCombo.currentIndex = 0 } } textRole: "display" } readonly property bool deviceConnected: devicesCombo.enabled readonly property QtObject device: devicesCombo.currentIndex >= 0 ? devicesModel.data(devicesModel.index(devicesCombo.currentIndex, 0), DevicesModel.DeviceRole) : null readonly property alias lastDeviceId: conversationListModel.deviceId Component { id: chatView ConversationDisplay { deviceId: page.lastDeviceId deviceConnected: page.deviceConnected } } ListView { id: view currentIndex: 0 model: QSortFilterProxyModel { sortOrder: Qt.DescendingOrder filterCaseSensitivity: Qt.CaseInsensitive sourceModel: ConversationListModel { id: conversationListModel deviceId: device ? device.id() : "" } } header: TextField { /** * Used as the filter of the list of messages */ id: filter placeholderText: i18nd("kdeconnect-sms", "Filter...") - width: parent.width - height: addButton.height + width: parent.width - newButton.width z: 10 onTextChanged: { if (filter.text != "") { if (conversationListModel.isPhoneNumberValid(filter.text)) { - addButton.visible = true view.model.setConversationsFilterRole(ConversationListModel.SenderRole) } else { - addButton.visible = false - filter.width = view.width view.model.setConversationsFilterRole(Qt.DisplayRole) } } else { view.model.setConversationsFilterRole(ConversationListModel.ConversationIdRole) - filter.width = view.width } view.model.setFilterFixedString(filter.text) view.currentIndex = 0 } onAccepted: { view.currentItem.startChat() } Keys.onReturnPressed: { event.accepted = true filter.onAccepted() } Keys.onEscapePressed: { event.accepted = filter.text != "" filter.text = "" } Shortcut { sequence: "Ctrl+F" onActivated: filter.forceActiveFocus() } } headerPositioning: ListView.OverlayHeader Keys.forwardTo: [headerItem] Button { - id: addButton - text: i18nd("kdeconnect-sms", "Add") + id: newButton + text: i18nd("kdeconnect-sms", "New") anchors.right: parent.right height: view.headerItem.height - visible: false + visible: true onClicked: { // We have to disable the filter temporarily in order to avoid getting key inputs accidently while processing the request view.headerItem.enabled = false // If the address entered by the user already exists then ignore adding new contact - if (!view.model.isPhoneNumberExists(view.headerItem.text)) { + if (!view.model.isPhoneNumberExists(view.headerItem.text) && conversationListModel.isPhoneNumberValid(view.headerItem.text)) { conversationListModel.createConversationForAddress(view.headerItem.text) } view.headerItem.enabled = true - addButton.visible = false - view.headerItem.width = view.width } Keys.onReturnPressed: { event.clicked = true - addButton.onClicked() + newButton.onClicked() } Shortcut { - sequence: "Ctrl+A" - onActivated: addButton.forceActiveFocus() + sequence: "Ctrl+N" + onActivated: newButton.forceActiveFocus() } } delegate: Kirigami.AbstractListItem { id: listItem contentItem: RowLayout { Kirigami.Icon { id: iconItem source: decoration readonly property int size: Kirigami.Units.iconSizes.smallMedium Layout.minimumHeight: size Layout.maximumHeight: size Layout.minimumWidth: size selected: listItem.highlighted || listItem.checked || (listItem.pressed && listItem.supportsMouseEvents) } ColumnLayout { Label { Layout.fillWidth: true font.weight: Font.Bold text: display maximumLineCount: 1 elide: Text.ElideRight textFormat: Text.PlainText } Label { Layout.fillWidth: true text: toolTip maximumLineCount: 1 elide: Text.ElideRight textFormat: Text.PlainText } } } function startChat() { applicationWindow().pageStack.push(chatView, { addresses: addresses, conversationId: model.conversationId, isMultitarget: isMultitarget, initialMessage: page.initialMessage, device: device, otherParty: sender}) initialMessage = "" } onClicked: { startChat(); view.currentIndex = index } // Keep the currently-open chat highlighted even if this element is not focused highlighted: chatView.conversationId == model.conversationId } Component.onCompleted: { currentIndex = -1 focus = true } } } diff --git a/smsapp/smshelper.cpp b/smsapp/smshelper.cpp index 4de18c90..c62a1ad1 100644 --- a/smsapp/smshelper.cpp +++ b/smsapp/smshelper.cpp @@ -1,424 +1,424 @@ /** * Copyright (C) 2019 Simon Redman * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "smshelper.h" #include #include #include #include #include #include #include #include #include #include "interfaces/conversationmessage.h" #include "smsapp/gsmasciimap.h" Q_LOGGING_CATEGORY(KDECONNECT_SMS_SMSHELPER, "kdeconnect.sms.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::isPhoneNumberValid(const QString& phoneNumber) { - QString canonicalizedPhoneNumber(phoneNumber); + QString canonicalizedNumber = canonicalizePhoneNumber(phoneNumber); // This regular expression matches a wide range of international Phone numbers, minimum of 3 digits and maximum upto 15 digits QRegularExpression validNumberPattern(QStringLiteral("^(\\d{3,15})$")); - return validNumberPattern.match(canonicalizedPhoneNumber).hasMatch(); + return validNumberPattern.match(canonicalizedNumber).hasMatch(); } 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()); if (personData) { icons.append(personData->photo()); } else { static QString dummyAvatar = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("kf5/kpeople/dummy_avatar.png")); icons.append(QPixmap(dummyAvatar)); } } // 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); } 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; }