From 9c1d6e43ad99f51fbf0619e1ca7fca4283f122f0 Mon Sep 17 00:00:00 2001 From: Simon Redman Date: Mon, 23 Apr 2018 22:27:38 +0200 Subject: [PATCH] Add contacts-reading plugin (KDE side) Summary: Add a plugin to KDE Connect which supports exporting the Android contacts databases to vcards on the desktop When the devices are connected, the plugin sends a request for all timestamps and IDs When a packet with timestamps and IDs is received, it verifies it has vcards for each ID and that the timestamps match and deletes any vcards for IDs which were not reported. It then sends a request for all vcards which were missing or need updating When a packet with vcards is received they are unconditionally written to disk, possibly overwriting existing vcards Provides one dbus method: contacts/synchronizeRemoteWithLocal which triggers the request for all timestamps and IDs BUG: 367999 Test Plan: Connect the device to the desktop and verify that vcards are created in QStandardPaths::GenericDataLocation / kpeoplevcard". On my system this is ~/.local/share/kpeoplevcard Create a dummy contact on the device and verify it is synchronized (Currently not automatic, have to disconnect and reconnect or use dbus) Modify the dummy contact and verify the modifications are synchronized (Currently not automatic, have to disconnect and reconnect or use dbus) Delete the dummy contact and verify the deletion is synchronized (Currently not automatic, have to disconnect and reconnect or use dbus) Reviewers: #kde_connect, apol Reviewed By: #kde_connect, apol Subscribers: mtijink, #kde_connect, apol Tags: #kde_connect Maniphest Tasks: T8283 Differential Revision: https://phabricator.kde.org/D9691 --- plugins/CMakeLists.txt | 1 + plugins/contacts/CMakeLists.txt | 11 ++ plugins/contacts/README | 3 + plugins/contacts/contactsplugin.cpp | 211 ++++++++++++++++++++++ plugins/contacts/contactsplugin.h | 164 +++++++++++++++++ plugins/contacts/kdeconnect_contacts.json | 33 ++++ 6 files changed, 423 insertions(+) create mode 100644 plugins/contacts/CMakeLists.txt create mode 100644 plugins/contacts/README create mode 100644 plugins/contacts/contactsplugin.cpp create mode 100644 plugins/contacts/contactsplugin.h create mode 100644 plugins/contacts/kdeconnect_contacts.json diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 399b616e2..0c5bfdcb5 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -4,6 +4,7 @@ install(FILES kdeconnect_plugin.desktop DESTINATION ${SERVICETYPES_INSTALL_DIR}) add_subdirectory(ping) add_subdirectory(clipboard) +add_subdirectory(contacts) add_subdirectory(telephony) add_subdirectory(share) add_subdirectory(notifications) diff --git a/plugins/contacts/CMakeLists.txt b/plugins/contacts/CMakeLists.txt new file mode 100644 index 000000000..9e3bbe479 --- /dev/null +++ b/plugins/contacts/CMakeLists.txt @@ -0,0 +1,11 @@ +set(kdeconnect_contacts_SRCS + contactsplugin.cpp +) + +kdeconnect_add_plugin(kdeconnect_contacts JSON kdeconnect_contacts.json SOURCES ${kdeconnect_contacts_SRCS}) + +target_link_libraries(kdeconnect_contacts + kdeconnectcore + Qt5::DBus + KF5::I18n +) \ No newline at end of file diff --git a/plugins/contacts/README b/plugins/contacts/README new file mode 100644 index 000000000..4ed412559 --- /dev/null +++ b/plugins/contacts/README @@ -0,0 +1,3 @@ +This plugin allows communicating with the paired device to access its contacts +book, either by downloading the entire list of contacts or by requesting a +specific contact \ No newline at end of file diff --git a/plugins/contacts/contactsplugin.cpp b/plugins/contacts/contactsplugin.cpp new file mode 100644 index 000000000..8306d7246 --- /dev/null +++ b/plugins/contacts/contactsplugin.cpp @@ -0,0 +1,211 @@ +/** + * Copyright 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 . + */ + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +K_PLUGIN_FACTORY_WITH_JSON(KdeConnectPluginFactory, "kdeconnect_contacts.json", + registerPlugin(); ) + +Q_LOGGING_CATEGORY(KDECONNECT_PLUGIN_CONTACTS, "kdeconnect.plugin.contacts") + +ContactsPlugin::ContactsPlugin (QObject* parent, const QVariantList& args) : + KdeConnectPlugin(parent, args) { + vcardsPath = QString(*vcardsLocation).append("/kdeconnect-").append(device()->id()); + + // Register custom types with dbus + qRegisterMetaType("uID"); + qDBusRegisterMetaType(); + + qRegisterMetaType("uIDList_t"); + qDBusRegisterMetaType(); + + // Create the storage directory if it doesn't exist + if (!QDir().mkpath(vcardsPath)) { + qCWarning(KDECONNECT_PLUGIN_CONTACTS) << "handleResponseVCards:" << "Unable to create VCard directory"; + } + + this->synchronizeRemoteWithLocal(); + + qCDebug(KDECONNECT_PLUGIN_CONTACTS) << "Contacts constructor for device " << device()->name(); +} + +ContactsPlugin::~ContactsPlugin () { + QDBusConnection::sessionBus().unregisterObject(dbusPath(), QDBusConnection::UnregisterTree); +// qCDebug(KDECONNECT_PLUGIN_CONTACTS) << "Contacts plugin destructor for device" << device()->name(); +} + +bool ContactsPlugin::receivePacket (const NetworkPacket& np) { + qCDebug(KDECONNECT_PLUGIN_CONTACTS) << "Packet Received for device " << device()->name(); + qCDebug(KDECONNECT_PLUGIN_CONTACTS) << np.body(); + + if (np.type() == PACKAGE_TYPE_CONTACTS_RESPONSE_UIDS_TIMESTAMPS) { + return this->handleResponseUIDsTimestamps(np); + } else if (np.type() == PACKET_TYPE_CONTACTS_RESPONSE_VCARDS) { + return this->handleResponseVCards(np); + } else { + // Is this check necessary? + qCDebug(KDECONNECT_PLUGIN_CONTACTS) << "Unknown package type received from device: " + << device()->name() << ". Maybe you need to upgrade KDE Connect?"; + return false; + } +} + +void ContactsPlugin::synchronizeRemoteWithLocal () { + this->sendRequest(PACKET_TYPE_CONTACTS_REQUEST_ALL_UIDS_TIMESTAMP); +} + +bool ContactsPlugin::handleResponseUIDsTimestamps (const NetworkPacket& np) { + if (!np.has("uids")) { + qCDebug(KDECONNECT_PLUGIN_CONTACTS) << "handleResponseUIDsTimestamps:" + << "Malformed packet does not have uids key"; + return false; + } + uIDList_t uIDsToUpdate; + QDir vcardsDir(vcardsPath); + + // Get a list of all file info in this directory + // Clean out IDs returned from the remote. Anything leftover should be deleted + QFileInfoList localVCards = vcardsDir.entryInfoList( { "*.vcard", "*.vcf" }); + + const QStringList& uIDs = np.get("uids"); + + // Check local storage for the contacts: + // If the contact is not found in local storage, request its vcard be sent + // If the contact is in local storage but not reported, delete it + // If the contact is in local storage, compare its timestamp. If different, request the contact + for (const QString& ID : uIDs) { + QString filename = vcardsDir.filePath(ID + VCARD_EXTENSION); + QFile vcardFile(filename); + + if (!QFile().exists(filename)) { + // We do not have a vcard for this contact. Request it. + uIDsToUpdate.push_back(ID); + continue; + } + + // Remove this file from the list of known files + QFileInfo fileInfo(vcardFile); + bool success = localVCards.removeOne(fileInfo); + Q_ASSERT(success); // We should have always been able to remove the existing file from our listing + + // Check if the vcard needs to be updated + if (!vcardFile.open(QIODevice::ReadOnly)) { + qCWarning(KDECONNECT_PLUGIN_CONTACTS) << "handleResponseUIDsTimestamps:" + << "Unable to open" << filename << "to read even though it was reported to exist"; + continue; + } + + QTextStream fileReadStream(&vcardFile); + QString line; + while (!fileReadStream.atEnd()) { + fileReadStream >> line; + // TODO: Check that the saved ID is the same as the one we were expecting. This requires parsing the VCard + if (!line.startsWith("X-KDECONNECT-TIMESTAMP:")) { + continue; + } + QStringList parts = line.split(":"); + QString timestamp = parts[1]; + + qint32 remoteTimestamp = np.get(ID); + qint32 localTimestamp = timestamp.toInt(); + + if (!(localTimestamp == remoteTimestamp)) { + uIDsToUpdate.push_back(ID); + } + } + } + + // Delete all locally-known files which were not reported by the remote device + for (const QFileInfo& unknownFile : localVCards) { + QFile toDelete(unknownFile.filePath()); + toDelete.remove(); + } + + this->sendRequestWithIDs(PACKET_TYPE_CONTACTS_REQUEST_VCARDS_BY_UIDS, uIDsToUpdate); + + return true; +} + +bool ContactsPlugin::handleResponseVCards (const NetworkPacket& np) { + if (!np.has("uids")) { + qCDebug(KDECONNECT_PLUGIN_CONTACTS) + << "handleResponseVCards:" << "Malformed packet does not have uids key"; + return false; + } + + QDir vcardsDir(vcardsPath); + const QStringList& uIDs = np.get("uids"); + + // Loop over all IDs, extract the VCard from the packet and write the file + for (const auto& ID : uIDs) { + qCDebug(KDECONNECT_PLUGIN_CONTACTS) << "Got VCard:" << np.get(ID); + QString filename = vcardsDir.filePath(ID + VCARD_EXTENSION); + QFile vcardFile(filename); + bool vcardFileOpened = vcardFile.open(QIODevice::WriteOnly); // Want to smash anything that might have already been there + if (!vcardFileOpened) { + qCWarning(KDECONNECT_PLUGIN_CONTACTS) << "handleResponseVCards:" << "Unable to open" << filename; + continue; + } + + QTextStream fileWriteStream(&vcardFile); + const QString& vcard = np.get(ID); + fileWriteStream << vcard; + } + qCDebug(KDECONNECT_PLUGIN_CONTACTS) << "handleResponseVCards:" << "Got" << uIDs.size() << "VCards"; + Q_EMIT localCacheSynchronized(uIDs); + return true; +} + +bool ContactsPlugin::sendRequest (const QString& packetType) { + NetworkPacket np(packetType); + bool success = sendPacket(np); + qCDebug(KDECONNECT_PLUGIN_CONTACTS) << "sendRequest: Sending " << packetType << success; + + return success; +} + +bool ContactsPlugin::sendRequestWithIDs (const QString& packetType, const uIDList_t& uIDs) { + NetworkPacket np(packetType); + + np.set("uids", uIDs); + bool success = sendPacket(np); + return success; +} + +QString ContactsPlugin::dbusPath () const { + return "/modules/kdeconnect/devices/" + device()->id() + "/contacts"; +} + +#include "contactsplugin.moc" + diff --git a/plugins/contacts/contactsplugin.h b/plugins/contacts/contactsplugin.h new file mode 100644 index 000000000..9f4ee2f6b --- /dev/null +++ b/plugins/contacts/contactsplugin.h @@ -0,0 +1,164 @@ +/** + * Copyright 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 . + */ + +#ifndef CONTACTSPLUGIN_H +#define CONTACTSPLUGIN_H + +#include +#include + +#include + +/** + * Used to request the device send the unique ID and last-changed timestamp of every contact + */ +#define PACKET_TYPE_CONTACTS_REQUEST_ALL_UIDS_TIMESTAMP QStringLiteral("kdeconnect.contacts.request_all_uids_timestamps") + +/** + * Used to request the vcards for the contacts corresponding to a list of UIDs + * + * It shall contain the key "uids", which will have a list of uIDs (long int, as string) + */ +#define PACKET_TYPE_CONTACTS_REQUEST_VCARDS_BY_UIDS QStringLiteral("kdeconnect.contacts.request_vcards_by_uid") + +/** + * Response indicating the package contains a list of all contact uIDs and last-changed timestamps + * + * It shall contain the key "uids", which will mark a list of uIDs (long int, as string) + * then, for each UID, there shall be a field with the key of that UID and the value of the timestamp (int, as string) + * + * For example: + * ( 'uids' : ['1', '3', '15'], + * '1' : '973486597', + * '3' : '973485443', + * '15' : '973492390' ) + * + * The returned IDs can be used in future requests for more information about the contact + */ +#define PACKAGE_TYPE_CONTACTS_RESPONSE_UIDS_TIMESTAMPS QStringLiteral("kdeconnect.contacts.response_uids_timestamps") + +/** + * Response indicating the package contains a list of contact vcards + * + * It shall contain the key "uids", which will mark a list of uIDs (long int, as string) + * then, for each UID, there shall be a field with the key of that UID and the value of the remote's vcard for that contact + * + * For example: + * ( 'uids' : ['1', '3', '15'], + * '1' : 'BEGIN:VCARD\n....\nEND:VCARD', + * '3' : 'BEGIN:VCARD\n....\nEND:VCARD', + * '15' : 'BEGIN:VCARD\n....\nEND:VCARD' ) + */ +#define PACKET_TYPE_CONTACTS_RESPONSE_VCARDS QStringLiteral("kdeconnect.contacts.response_vcards") + +/** + * Where the synchronizer will write vcards and other metadata + * TODO: Per-device folders since each device *will* have different uIDs + */ +Q_GLOBAL_STATIC_WITH_ARGS( + QString, vcardsLocation, + (QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + ("/kpeoplevcard"))) + +#define VCARD_EXTENSION QStringLiteral(".vcf") +#define METADATA_EXTENSION QStringLiteral(".meta") + +typedef QString uID; +Q_DECLARE_METATYPE(uID) + +typedef QStringList uIDList_t; +Q_DECLARE_METATYPE(uIDList_t) + +class Q_DECL_EXPORT ContactsPlugin : public KdeConnectPlugin { + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.kde.kdeconnect.device.contacts") + +public: + explicit ContactsPlugin (QObject *parent, const QVariantList &args); + ~ContactsPlugin () override; + + bool receivePacket (const NetworkPacket& np) override; + void connected () override { + } + + QString dbusPath () const override; + +protected: + /** + * Path where this instance of the plugin stores its synchronized contacts + */ + QString vcardsPath; + +public Q_SLOTS: + + /** + * Query the remote device for all its uIDs and last-changed timestamps, then: + * Delete any contacts which are known locally but not reported by the remote + * Update any contacts which are known locally but have an older timestamp + * Add any contacts which are not known locally but are reported by the remote + */ + Q_SCRIPTABLE + void synchronizeRemoteWithLocal (); + +public: +Q_SIGNALS: + /** + * Emitted to indicate that we have locally cached all remote contacts + * + * @param newContacts The list of just-synchronized contacts + */ + Q_SCRIPTABLE + void localCacheSynchronized (const uIDList_t& newContacts); + +protected: + + /** + * Handle a packet of type PACKAGE_TYPE_CONTACTS_RESPONSE_UIDS_TIMESTAMPS + * + * For every uID in the reply: + * Delete any from local storage if it does not appear in the reply + * Compare the modified timestamp for each in the reply and update any which should have changed + * Request the details any IDs which were not locally cached + */ + bool handleResponseUIDsTimestamps (const NetworkPacket&); + + /** + * Handle a packet of type PACKET_TYPE_CONTACTS_RESPONSE_VCARDS + */ + bool handleResponseVCards (const NetworkPacket&); + + /** + * Send a request-type packet which contains no body + * + * @return True if the send was successful, false otherwise + */ + bool sendRequest (const QString& packetType); + + /** + * Send a request-type packet which has a body with the key 'uids' and the value the list of + * specified uIDs + * + * @param packageType Type of package to send + * @param uIDs List of uIDs to request + * @return True if the send was successful, false otherwise + */ + bool sendRequestWithIDs (const QString& packetType, const uIDList_t& uIDs); +}; + +#endif // CONTACTSPLUGIN_H diff --git a/plugins/contacts/kdeconnect_contacts.json b/plugins/contacts/kdeconnect_contacts.json new file mode 100644 index 000000000..9504ba9e0 --- /dev/null +++ b/plugins/contacts/kdeconnect_contacts.json @@ -0,0 +1,33 @@ +{ + "Encoding": "UTF-8", + "KPlugin": { + "Authors": [ + { + "Email": "simon@ergotech.com", + "Name": "Simon Redman", + "Name[x-test]": "xxSimon Redmanxx" + } + ], + "Description": "Synchronize Contacts Between the Desktop and the Connected Device", + "Description[x-test]": "xxSynchronize Contacts Between the Desktop and the Connected Devicexx", + "EnabledByDefault": true, + "Icon": "dialog-ok", + "Id": "kdeconnect_contacts", + "License": "GPL", + "Name": "Contacts", + "Name[x-test]": "xxContactsxx", + "ServiceTypes": [ + "KdeConnect/Plugin" + ], + "Version": "0.1", + "Website": "http://albertvaka.wordpress.com" + }, + "X-KdeConnect-OutgoingPacketType": [ + "kdeconnect.contacts.request_all_uids_timestamps", + "kdeconnect.contacts.request_vcards_by_uid" + ], + "X-KdeConnect-SupportedPacketType": [ + "kdeconnect.contacts.response_uids_timestamps", + "kdeconnect.contacts.response_vcards" + ] +}