diff --git a/agents/migration/CMakeLists.txt b/agents/migration/CMakeLists.txt --- a/agents/migration/CMakeLists.txt +++ b/agents/migration/CMakeLists.txt @@ -26,6 +26,7 @@ target_link_libraries(akonadi_migration_agent gidmigration + googlegroupwaremigration KF5::AkonadiCore KF5::AkonadiAgentBase KF5::Contacts diff --git a/agents/migration/migrationagent.cpp b/agents/migration/migrationagent.cpp --- a/agents/migration/migrationagent.cpp +++ b/agents/migration/migrationagent.cpp @@ -23,6 +23,8 @@ #include "migrationstatuswidget.h" #include +#include + #include #include #include @@ -37,7 +39,8 @@ , mScheduler(new KUiServerJobTracker) { KLocalizedString::setApplicationDomain("akonadi_migration_agent"); - mScheduler.addMigrator(QSharedPointer(new GidMigrator(KContacts::Addressee::mimeType()))); + mScheduler.addMigrator(QSharedPointer::create(KContacts::Addressee::mimeType())); + mScheduler.addMigrator(QSharedPointer::create()); } void MigrationAgent::configure(WId windowId) diff --git a/migration/CMakeLists.txt b/migration/CMakeLists.txt --- a/migration/CMakeLists.txt +++ b/migration/CMakeLists.txt @@ -29,4 +29,5 @@ add_subdirectory(gid) +add_subdirectory(googlegroupware) diff --git a/migration/googlegroupware/CMakeLists.txt b/migration/googlegroupware/CMakeLists.txt new file mode 100644 --- /dev/null +++ b/migration/googlegroupware/CMakeLists.txt @@ -0,0 +1,26 @@ +include_directories(${CMAKE_BINARY_DIR}/resources/google-groupware) + +set(googlegroupwaremigration_SRCS + googleresourcemigrator.cpp + ${MIGRATION_AKONADI_SHARED_SOURCES} + ) + +kcfg_generate_dbus_interface( + ${CMAKE_SOURCE_DIR}/resources/google-groupware/settingsbase.kcfg + org.kde.Akonadi.Google.Settings +) + +qt5_add_dbus_interface(googlegroupwaremigration_SRCS + ${CMAKE_CURRENT_BINARY_DIR}/org.kde.Akonadi.Google.Settings.xml + googlesettingsinterface +) + +add_library(googlegroupwaremigration STATIC ${googlegroupwaremigration_SRCS}) +target_link_libraries(googlegroupwaremigration + KF5::AkonadiCore + KF5::ConfigGui + KF5::I18n + KF5::Wallet + Qt5::DBus + migrationshared +) diff --git a/migration/googlegroupware/googleresourcemigrator.h b/migration/googlegroupware/googleresourcemigrator.h new file mode 100644 --- /dev/null +++ b/migration/googlegroupware/googleresourcemigrator.h @@ -0,0 +1,74 @@ +/* + Copyright (c) 2020 Daniel Vrátil + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library 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 + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#ifndef GOOGLERESOURCEMIGRATOR_H +#define GOOGLERESOURCEMIGRATOR_H + +#include +#include + +#include + +#include + +class GoogleResourceMigrator : public MigratorBase +{ + Q_OBJECT +public: + explicit GoogleResourceMigrator(); + + QString displayName() const override; + QString description() const override; + + bool shouldAutostart() const override; + +protected: + void startWork() override; + void migrateNextAccount(); + +private: + struct Instances { + Akonadi::AgentInstance calendarResource; + Akonadi::AgentInstance contactResource; + bool alreadyExists = false; + }; + + template + struct ResourceValues { + explicit ResourceValues() = default; + template + ResourceValues(U &&calendar, V &&contacts) + : calendar(calendar), contacts(contacts) + {} + + T calendar{}; + T contacts{}; + }; + + bool migrateAccount(const QString &account, const Instances &oldInstances, const Akonadi::AgentInstance &newInstance); + void removeLegacyInstances(const QString &account, const Instances &instances); + QString mergeAccountNames(const ResourceValues &accountName, const Instances &oldInstances) const; + int mergeAccountIds(const ResourceValues &accountId, const Instances &oldInstances) const; + + QMap mMigrations; + int mMigrationCount = 0; + int mMigrationsDone = 0; +}; + +#endif diff --git a/migration/googlegroupware/googleresourcemigrator.cpp b/migration/googlegroupware/googleresourcemigrator.cpp new file mode 100644 --- /dev/null +++ b/migration/googlegroupware/googleresourcemigrator.cpp @@ -0,0 +1,390 @@ +/* + Copyright (c) 2020 Daniel Vrátil + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library 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 Library General Public + License for more details. + + You should have received a copy of the GNU Library General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#include "googleresourcemigrator.h" +#include "googlesettingsinterface.h" +#include "migration_debug.h" + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +#include + +GoogleResourceMigrator::GoogleResourceMigrator() + : MigratorBase(QLatin1String("googleresourcemigrator")) +{} + +QString GoogleResourceMigrator::displayName() const +{ + return i18nc("Name of the Migrator (intended for advanced users).", "Google Resource Migrator"); +} + +QString GoogleResourceMigrator::description() const +{ + return i18nc("Description of the migrator", + "Migrates the old Google Calendar and Google Contacts resources to the new unified Google Groupware Resource"); +} + +bool GoogleResourceMigrator::shouldAutostart() const +{ + return true; +} + +namespace { + +static const QStringView akonadiGoogleCalendarResource = {u"akonadi_googlecalendar_resource"}; +static const QStringView akonadiGoogleContactsResource = {u"akonadi_googlecontacts_resource"}; +static const QStringView akonadiGoogleGroupwareResource = {u"akonadi_google_resource"}; + +bool isLegacyGoogleResource(const Akonadi::AgentInstance &instance) +{ + return instance.type().identifier() == akonadiGoogleCalendarResource + || instance.type().identifier() == akonadiGoogleContactsResource; +} + +bool isGoogleGroupwareResource(const Akonadi::AgentInstance &instance) +{ + return instance.type().identifier() == akonadiGoogleGroupwareResource; +} + +std::unique_ptr settingsForResource(const Akonadi::AgentInstance &instance) +{ + Q_ASSERT(instance.isValid()); + if (!instance.isValid()) { + return {}; + } + + const auto configFile = Akonadi::ServerManager::self()->addNamespace(instance.identifier()) + QStringLiteral("rc"); + const auto configPath = QStandardPaths::locate(QStandardPaths::ConfigLocation, configFile); + return std::unique_ptr{new QSettings{configPath, QSettings::IniFormat}}; +} + +QString getAccountNameFromResourceSettings(const Akonadi::AgentInstance &instance) +{ + Q_ASSERT(instance.isValid()); + if (!instance.isValid()) { + return {}; + } + + const auto config = settingsForResource(instance); + QString account = config->value(QStringLiteral("Account")).toString(); + if (account.isEmpty()) { + account = config->value(QStringLiteral("AccountName")).toString(); + } + + return account; +} + +static const auto WalletFolder = QStringLiteral("Akonadi Google"); + +std::unique_ptr getWallet() +{ + std::unique_ptr wallet{KWallet::Wallet::openWallet(KWallet::Wallet::NetworkWallet(), 0, KWallet::Wallet::Synchronous)}; + if (!wallet) { + qCWarning(MIGRATION_LOG) << "GoogleResourceMigrator: failed to open KWallet."; + return {}; + } + + if (!wallet->hasFolder(WalletFolder)) { + qCWarning(MIGRATION_LOG) << "GoogleResourceMigrator: couldn't find wallet folder for Google resources."; + return {}; + } + wallet->setFolder(WalletFolder); + + return wallet; +} + +QMap backupKWalletData(const QString &account) +{ + qCInfo(MIGRATION_LOG) << "GoogleResourceMigrator: backing up KWallet data for" << account; + + const auto wallet = getWallet(); + if (!wallet) { + return {}; + } + + if (!wallet->entryList().contains(account)) { + qCWarning(MIGRATION_LOG) << "GoogleResourceMigrator: couldn't find KWallet data for account" << account; + return {}; + } + + QMap map; + wallet->readMap(account, map); + return map; +} + +void restoreKWalletData(const QString &account, const QMap &data) +{ + qCInfo(MIGRATION_LOG) << "GoogleResourceMigrator: restoring KWallet data for" << account; + + auto wallet = getWallet(); + if (!wallet) { + return; + } + + wallet->writeMap(account, data); +} + +void removeInstanceAndWait(const Akonadi::AgentInstance &instance) +{ + // Make sure we wait for the resource to actually stop - otherwise we are risking + // race when we restore the KWallet secrets from backup before the removed resource + // actually tries to remove them from the wallet. + const QString serviceName = Akonadi::ServerManager::agentServiceName(Akonadi::ServerManager::Resource, instance.identifier()); + if (!QDBusConnection::sessionBus().interface()->isServiceRegistered(serviceName)) { + Akonadi::AgentManager::self()->removeInstance(instance); + } else { + QDBusServiceWatcher watcher(Akonadi::ServerManager::agentServiceName(Akonadi::ServerManager::Resource, instance.identifier()), + QDBusConnection::sessionBus(), + QDBusServiceWatcher::WatchForUnregistration); + QEventLoop loop; + QObject::connect(&watcher, &QDBusServiceWatcher::serviceUnregistered, + &loop, [&loop, &instance]() { + qCDebug(MIGRATION_LOG) << "GoogleResourceMigrator: resource" << instance.identifier() << "has disappeared from DBus"; + loop.quit(); + }); + QTimer::singleShot(std::chrono::seconds(20), &loop, [&loop, &instance]() { + qCWarning(MIGRATION_LOG) << "GoogleResourceMigrator: timeout while waiting for resource" << instance.identifier() << "to be removed"; + loop.quit(); + }); + + Akonadi::AgentManager::self()->removeInstance(instance); + qCDebug(MIGRATION_LOG) << "GoogleResourceMigrator: waiting for" << instance.identifier() << "to disappear from DBus"; + loop.exec(); + } + + qCInfo(MIGRATION_LOG) << "GoogleResourceMigrator: removed the legacy calendar resource" << instance.identifier(); +} + +} // namespace + +void GoogleResourceMigrator::startWork() +{ + // Discover all existing Google Contacts and Google Calendar resources + const auto allInstances = Akonadi::AgentManager::self()->instances(); + for (const auto &instance : allInstances) { + if (isLegacyGoogleResource(instance)) { + const auto account = getAccountNameFromResourceSettings(instance); + if (account.isEmpty()) { + qCInfo(MIGRATION_LOG) << "GoogleResourceMigrator: resource" << instance.identifier() << "is not configued, removing"; + Akonadi::AgentManager::self()->removeInstance(instance); + continue; + } + qCInfo(MIGRATION_LOG) << "GoogleResourceMigrator: discovered resource" << instance.identifier() + << "for account" << account; + if (instance.type().identifier() == akonadiGoogleCalendarResource) { + mMigrations[account].calendarResource = instance; + } else if (instance.type().identifier() == akonadiGoogleContactsResource) { + mMigrations[account].contactResource = instance; + } + } else if (isGoogleGroupwareResource(instance)) { + const auto account = getAccountNameFromResourceSettings(instance); + mMigrations[account].alreadyExists = true; + } + } + + mMigrationCount = mMigrations.size(); + migrateNextAccount(); +} + +void GoogleResourceMigrator::removeLegacyInstances(const QString &account, const Instances &instances) +{ + // Legacy resources wipe KWallet data when removed, so we need to back the data up + // before removing them and restore it afterwards + const auto kwalletData = backupKWalletData(account); + + if (instances.calendarResource.isValid()) { + removeInstanceAndWait(instances.calendarResource); + } + if (instances.contactResource.isValid()) { + removeInstanceAndWait(instances.contactResource); + } + + restoreKWalletData(account, kwalletData); +} + +void GoogleResourceMigrator::migrateNextAccount() +{ + setProgress((static_cast(mMigrationsDone) / mMigrationCount) * 100); + if (mMigrations.empty()) { + setMigrationState(MigratorBase::Complete); + return; + } + + QString account; + Instances instances; + std::tie(account, instances) = *mMigrations.constKeyValueBegin(); + mMigrations.erase(mMigrations.begin()); + + if (instances.alreadyExists) { + message(Info, i18n("Google Groupware Resource for account %1 already exists, skipping.", account)); + // Just to be sure, check that there are no left-over legacy instances + removeLegacyInstances(account, instances); + + ++mMigrationsDone; + QMetaObject::invokeMethod(this, &GoogleResourceMigrator::migrateNextAccount, Qt::QueuedConnection); + return; + } + + qCInfo(MIGRATION_LOG) << "GoogleResourceMigrator: starting migration of account" << account; + message(Info, i18n("Starting migration of account %1", account)); + qCInfo(MIGRATION_LOG) << "GoogleResourceMigrator: creating new" << akonadiGoogleGroupwareResource; + message(Info, i18n("Creating new instance of Google Gropware Resource")); + auto job = new Akonadi::AgentInstanceCreateJob(akonadiGoogleGroupwareResource.toString(), this); + connect(job, &Akonadi::AgentInstanceCreateJob::finished, + this, [this, job, account, instances](KJob *) { + if (job->error()) { + qCWarning(MIGRATION_LOG) << "GoogleResourceMigrator: Failed to create new Google Groupware Resource:" << job->errorString(); + message(Error, i18n("Failed to create a new Google Groupware Resource: %1", job->errorString())); + setMigrationState(MigratorBase::Failed); + return; + } + + const auto newInstance = job->instance(); + if (!migrateAccount(account, instances, newInstance)) { + qCWarning(MIGRATION_LOG) << "GoogleResourceMigrator: failed to migrate account" << account; + message(Error, i18n("Failed to migrate account %1", account)); + setMigrationState(MigratorBase::Failed); + return; + } + + removeLegacyInstances(account, instances); + + // Reconfigure and restart the new instance + newInstance.reconfigure(); + newInstance.restart(); + + if (instances.calendarResource.isValid() ^ instances.contactResource.isValid()) { + const auto res = instances.calendarResource.isValid() + ? instances.calendarResource.identifier() + : instances.contactResource.identifier(); + qCInfo(MIGRATION_LOG) << "GoogleResourceMigrator: migrated configuration from" << res + << "to" << newInstance.identifier(); + } else { + qCInfo(MIGRATION_LOG) << "GoogleResourceMigrator: migrated configuration from" + << instances.calendarResource.identifier() << "and" + << instances.contactResource.identifier() << "to" + << newInstance.identifier(); + } + message(Success, i18n("Migrated account %1 to new Google Groupware Resource", account)); + + ++mMigrationsDone; + migrateNextAccount(); + }); + job->start(); +} + +QString GoogleResourceMigrator::mergeAccountNames(const ResourceValues &accountName, const Instances &oldInstances) const +{ + if (!accountName.calendar.isEmpty() && !accountName.contacts.isEmpty()) { + if (accountName.calendar == accountName.contacts) { + return accountName.calendar; + } else { + qCWarning(MIGRATION_LOG) << "GoogleResourceMigrator: account name mismatch:" + << oldInstances.calendarResource.identifier() << "=" << accountName.calendar << "," + << oldInstances.contactResource.identifier() << "=" << accountName.contacts << ". Ignoring both."; + } + } else if (!accountName.calendar.isEmpty()) { + return accountName.calendar; + } else if( !accountName.contacts.isEmpty()) { + return accountName.contacts; + } + + return {}; +} + +int GoogleResourceMigrator::mergeAccountIds(const ResourceValues &accountId, const Instances &oldInstances) const +{ + if (accountId.calendar > 0 && accountId.contacts > 0) { + if (accountId.calendar == accountId.contacts) { + return accountId.calendar; + } else { + qCWarning(MIGRATION_LOG) << "GoogleResourceMigrator: account id mismatch:" + << oldInstances.calendarResource.identifier() << "=" << accountId.calendar << "," + << oldInstances.contactResource.identifier() << "=" << accountId.contacts << ". Ignoring both."; + } + return 0; + } + + // Return the non-zero entry + return std::max(accountId.calendar, accountId.contacts); +} + +bool GoogleResourceMigrator::migrateAccount(const QString &account, const Instances &oldInstances, + const Akonadi::AgentInstance &newInstance) +{ + org::kde::Akonadi::Google::Settings resourceSettings{ + Akonadi::ServerManager::self()->agentServiceName(Akonadi::ServerManager::Resource, newInstance.identifier()), + QStringLiteral("/Settings"), QDBusConnection::sessionBus()}; + if (!resourceSettings.isValid()) { + qCWarning(MIGRATION_LOG) << "GoogleResourceMigrator: failed to obtain settings DBus interface of " << newInstance.identifier(); + return false; + } + + resourceSettings.setAccount(account); + + ResourceValues accountName; + ResourceValues accountId; + ResourceValues enableIntervalCheck; + ResourceValues intervalCheck{60, 60}; + + if (oldInstances.calendarResource.isValid()) { + const auto calendarSettings = settingsForResource(oldInstances.calendarResource); + // Calendar-specific + resourceSettings.setCalendars(calendarSettings->value(QStringLiteral("Calendars")).toStringList()); + resourceSettings.setTaskLists(calendarSettings->value(QStringLiteral("TaskLists")).toStringList()); + resourceSettings.setEventsSince(calendarSettings->value(QStringLiteral("EventsSince")).toString()); + + enableIntervalCheck.calendar = calendarSettings->value(QStringLiteral("EnableIntervalCheck"), false).toBool(); + intervalCheck.calendar = calendarSettings->value(QStringLiteral("IntervalCheckTime"), 60).toInt(); + + accountName.calendar = calendarSettings->value(QStringLiteral("AccountName")).toString(); + accountId.calendar = calendarSettings->value(QStringLiteral("AccountId"), 0).toInt(); + } + + if (oldInstances.contactResource.isValid()) { + const auto contactsSettings = settingsForResource(oldInstances.contactResource); + + enableIntervalCheck.contacts = contactsSettings->value(QStringLiteral("EnableIntervalCheck"), false).toBool(); + intervalCheck.contacts = contactsSettings->value(QStringLiteral("IntervalCheckTime"), 60).toInt(); + + accountName.contacts = contactsSettings->value(QStringLiteral("AccountName")).toString(); + accountId.contacts = contactsSettings->value(QStringLiteral("AccountId"), 0).toInt(); + } + + // And now some merging: + resourceSettings.setEnableIntervalCheck(enableIntervalCheck.calendar || enableIntervalCheck.contacts); + resourceSettings.setIntervalCheckTime(std::min(intervalCheck.calendar, intervalCheck.contacts)); + + resourceSettings.setAccountName(mergeAccountNames(accountName, oldInstances)); + resourceSettings.setAccountId(mergeAccountIds(accountId, oldInstances)); + + resourceSettings.save(); + + return true; +}