smshelper.cpp 10.6 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
/**
 * Copyright (C) 2019 Simon Redman <simon@ergotech.com>
 *
 * 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 <https://www.gnu.org/licenses/>.
 */

#include "smshelper.h"

23 24
#include <QIcon>
#include <QPainter>
25
#include <QRegularExpression>
26
#include <QString>
27
#include <QLoggingCategory>
28
#include <QStandardPaths>
29
#include <QHash>
30

Nicolas Fella's avatar
Nicolas Fella committed
31 32
#include <KPeople/PersonData>
#include <KPeople/PersonsModel>
33 34 35

#include "interfaces/conversationmessage.h"

36 37 38 39
Q_LOGGING_CATEGORY(KDECONNECT_SMS_SMSHELPER, "kdeconnect.sms.smshelper")

bool SmsHelper::isPhoneNumberMatchCanonicalized(const QString& canonicalPhone1, const QString& canonicalPhone2)
{
40 41 42 43 44
    if (canonicalPhone1.isEmpty() || canonicalPhone2.isEmpty()) {
        // The empty string is not a valid phone number so does not match anything
        return false;
    }

45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80
    // 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;
    }
81
    if (country == CountryCode::Australia && phoneNumber.length() == 8 && phoneNumber.startsWith(QStringLiteral("19"))) {
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97
        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
98
    if (canonicalNumber.startsWith(QStringLiteral("41"))) {
99 100
        return CountryCode::Australia;
    }
101
    if (canonicalNumber.startsWith(QStringLiteral("420"))) {
102 103 104 105 106 107 108 109 110 111 112
        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)
{
    QString toReturn(phoneNumber);
113 114 115 116 117 118
    toReturn = toReturn.remove(QStringLiteral(" "));
    toReturn = toReturn.remove(QStringLiteral("-"));
    toReturn = toReturn.remove(QStringLiteral("("));
    toReturn = toReturn.remove(QStringLiteral(")"));
    toReturn = toReturn.remove(QStringLiteral("+"));
    toReturn = toReturn.remove(QRegularExpression(QStringLiteral("^0*"))); // Strip leading zeroes
119 120 121 122 123 124 125

    if (toReturn.length() == 0) {
        // If we have stripped away everything, assume this is a special number (and already canonicalized)
        return phoneNumber;
    }
    return toReturn;
}
126

127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152
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<KPeople::PersonData> 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:
153
    KPeople::PersonsModel m_people;
154 155 156 157 158 159
    QHash<QString, QSharedPointer<KPeople::PersonData>> m_personDataCache;
};

QSharedPointer<KPeople::PersonData> SmsHelper::lookupPersonByAddress(const QString& address)
{
    static PersonsCache s_cache;
160 161 162

    const QString& canonicalAddress = SmsHelper::canonicalizePhoneNumber(address);
    int rowIndex = 0;
163 164
    for (rowIndex = 0; rowIndex < s_cache.count(); rowIndex++) {
        const auto person = s_cache.personAt(rowIndex);
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235

        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<QPixmap>& 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<ConversationAddress>& addresses) {
    QStringList titleParts;
    for (const ConversationAddress& address : addresses) {
236
        const auto personData = SmsHelper::lookupPersonByAddress(address.address());
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252

        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<ConversationAddress>& addresses) {
    QList<QPixmap> icons;
    for (const ConversationAddress& address : addresses) {
253
        const auto personData = SmsHelper::lookupPersonByAddress(address.address());
254 255 256 257

        if (personData) {
            icons.append(personData->photo());
        } else {
258 259
            static QString dummyAvatar = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("kf5/kpeople/dummy_avatar.png"));
            icons.append(QPixmap(dummyAvatar));
260 261 262 263 264 265 266
        }
    }

    // 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);
}