MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - data - SettingsCollector.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 93.4 % 376 351
Test Date: 2026-07-27 17:53:44 Functions: 100.0 % 14 14
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 54.1 % 1019 551

             Branch data     Line data    Source code
       1                 :             : #include "data/SettingsCollector.h"
       2                 :             : 
       3                 :             : #include <QDir>
       4                 :             : #include <QFile>
       5                 :             : #include <QHash>
       6                 :             : #include <QLoggingCategory>
       7                 :             : #include <QRegularExpression>
       8                 :             : #include <QSet>
       9                 :             : #include <QSettings>
      10                 :             : #include <QVariantMap>
      11                 :             : 
      12                 :             : #include "data/CalendarStore.h"
      13                 :             : #include "data/DavCredentials.h"
      14                 :             : #include "data/MailCache.h"
      15                 :             : #include "ui/FolderSubscriptionDialog.h"
      16                 :             : 
      17   [ +  +  +  -  :          51 : Q_LOGGING_CATEGORY(lcSettingsSync, "mailjd.settingssync")
             +  -  -  - ]
      18                 :             : 
      19                 :             : namespace {
      20                 :             : 
      21                 :             : constexpr int kMaxDavAccounts = 16;
      22                 :             : constexpr int kMaxDavSelections = 256;
      23                 :             : constexpr int kMaxSyncedMapEntries = 4096;
      24                 :             : 
      25                 :           5 : const QRegularExpression &iconNamePattern() {
      26                 :             :   static const QRegularExpression pattern(
      27   [ +  +  +  -  :           9 :       QStringLiteral("^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$"));
             +  -  -  - ]
      28                 :           5 :   return pattern;
      29                 :             : }
      30                 :             : 
      31                 :           8 : const QRegularExpression &colorPattern() {
      32                 :             :   static const QRegularExpression pattern(
      33                 :           8 :       QStringLiteral("^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|"
      34   [ +  +  +  -  :          16 :                      "[0-9A-Fa-f]{8})$"));
             +  -  -  - ]
      35                 :           8 :   return pattern;
      36                 :             : }
      37                 :             : 
      38                 :         196 : QString normalizedCategory(const QString &category) {
      39   [ +  +  +  +  :         783 :   if (category == QStringLiteral("carddavAccounts") ||
             +  -  +  + ]
      40   [ +  +  +  +  :         391 :       category == QStringLiteral("caldavAccounts")) {
                   +  - ]
      41                 :           2 :     return QStringLiteral("davAccounts");
      42                 :             :   }
      43                 :         194 :   return category;
      44                 :             : }
      45                 :             : 
      46                 :          19 : QStringList effectiveRemoteCategories(const SyncPayload &payload,
      47                 :             :                                       const QSettings &settings) {
      48                 :             :   const QStringList configured =
      49                 :             :       settings
      50         [ +  - ]:          57 :           .value(QStringLiteral("sync/categories"),
      51         [ +  - ]:          38 :                  SyncPayload::allCategories())
      52         [ +  - ]:          19 :           .toStringList();
      53                 :          19 :   QSet<QString> localPolicy;
      54         [ +  + ]:         111 :   for (const QString &category : configured)
      55   [ +  -  +  - ]:          92 :     localPolicy.insert(normalizedCategory(category));
      56                 :             : 
      57                 :             :   // Privacy policy and the external-content whitelist are intentionally not
      58                 :             :   // remotely applicable. The IMAP message is only a transport container and
      59                 :             :   // does not establish the sender as an authorized device.
      60                 :             :   const QSet<QString> remotelyApplicable = {
      61                 :          19 :       QStringLiteral("folderIcons"),    QStringLiteral("folderColors"),
      62                 :          19 :       QStringLiteral("calendarColors"), QStringLiteral("hiddenFolders"),
      63   [ +  +  -  - ]:         133 :       QStringLiteral("davAccounts"),    QStringLiteral("general")};
      64                 :             : 
      65                 :          19 :   QStringList result;
      66         [ +  + ]:          60 :   for (const QString &rawCategory : payload.enabledCategories) {
      67         [ +  - ]:          41 :     const QString category = normalizedCategory(rawCategory);
      68         [ +  - ]:          76 :     if (localPolicy.contains(category) &&
      69   [ +  +  +  + ]:          76 :         remotelyApplicable.contains(category) &&
      70         [ +  + ]:          35 :         !result.contains(category)) {
      71         [ +  - ]:          34 :       result.append(category);
      72                 :             :     }
      73                 :          41 :   }
      74                 :          19 :   return result;
      75   [ +  -  -  -  :         152 : }
                   -  - ]
      76                 :             : 
      77                 :          34 : bool safeText(const QString &value, qsizetype maxLength) {
      78   [ +  -  -  +  :          34 :   if (value.isEmpty() || value.size() > maxLength)
                   -  + ]
      79                 :           0 :     return false;
      80         [ +  + ]:         263 :   for (const QChar ch : value) {
      81   [ +  -  -  + ]:         229 :     if (ch == QChar::Null || (ch.category() == QChar::Other_Control &&
      82   [ -  -  -  + ]:         229 :                               ch != QLatin1Char('\t')))
      83                 :           0 :       return false;
      84                 :             :   }
      85                 :          34 :   return true;
      86                 :             : }
      87                 :             : 
      88                 :           9 : QStringList safeDavSelections(const QStringList &values) {
      89                 :           9 :   QStringList result;
      90         [ +  - ]:           9 :   result.reserve(qMin(values.size(), qsizetype{kMaxDavSelections}));
      91         [ +  + ]:          14 :   for (const QString &value : values) {
      92         [ -  + ]:           5 :     if (result.size() >= kMaxDavSelections)
      93                 :           0 :       break;
      94   [ +  -  +  -  :           5 :     if (safeText(value, 4096) && !result.contains(value))
                   +  - ]
      95         [ +  - ]:           5 :       result.append(value);
      96                 :             :   }
      97                 :           9 :   return result;
      98                 :           0 : }
      99                 :             : 
     100                 :           6 : bool validDavAccount(const SyncPayload::CardDavAccount &account) {
     101   [ +  -  -  - ]:          12 :   return safeText(account.id.trimmed(), 128) &&
     102   [ +  -  +  -  :          18 :          safeText(account.username.trimmed(), 1024) &&
          +  -  +  -  -  
                      - ]
     103   [ +  -  +  -  :          18 :          !DavCredentials::canonicalOrigin(account.serverUrl).isEmpty();
          +  -  +  -  -  
                      - ]
     104                 :             : }
     105                 :             : 
     106                 :           3 : bool sameDavIdentity(const QVariantMap &local,
     107                 :             :                      const SyncPayload::CardDavAccount &remote) {
     108   [ +  -  +  -  :           9 :   return local.value(QStringLiteral("id")).toString().trimmed() ==
          +  -  +  -  +  
          -  +  -  +  -  
          +  -  -  -  -  
          -  -  -  -  -  
             -  -  -  - ]
     109   [ +  -  +  -  :           6 :              remote.id.trimmed() &&
                   -  - ]
     110   [ +  -  +  -  :           9 :          local.value(QStringLiteral("username")).toString().trimmed() ==
          +  -  +  -  +  
          -  +  -  +  -  
          +  -  -  -  -  
          -  -  -  -  -  
             -  -  -  - ]
     111   [ +  -  +  -  :          12 :              remote.username.trimmed() &&
          +  -  +  +  +  
             -  +  -  -  
                      - ]
     112   [ +  -  -  - ]:           6 :          DavCredentials::canonicalOrigin(
     113   [ +  -  +  -  :          12 :              local.value(QStringLiteral("serverUrl")).toString()) ==
          +  -  +  -  +  
          -  +  -  +  -  
          -  -  -  -  -  
             -  -  -  -  
                      - ]
     114   [ +  -  +  -  :          12 :              DavCredentials::canonicalOrigin(remote.serverUrl);
          +  -  +  -  -  
                      - ]
     115                 :             : }
     116                 :             : 
     117                 :          13 : QList<QVariantMap> readSettingsArray(QSettings &settings,
     118                 :             :                                      const QString &name) {
     119                 :          13 :   QList<QVariantMap> result;
     120         [ +  - ]:          13 :   const int count = settings.beginReadArray(name);
     121         [ +  - ]:          13 :   result.reserve(count);
     122         [ +  + ]:          17 :   for (int i = 0; i < count; ++i) {
     123         [ +  - ]:           4 :     settings.setArrayIndex(i);
     124                 :           4 :     QVariantMap fields;
     125   [ +  -  +  -  :          19 :     for (const QString &key : settings.childKeys())
             +  -  +  + ]
     126   [ +  -  +  - ]:          19 :       fields.insert(key, settings.value(key));
     127         [ +  - ]:           4 :     result.append(fields);
     128                 :           4 :   }
     129         [ +  - ]:          13 :   settings.endArray();
     130                 :          13 :   return result;
     131                 :           0 : }
     132                 :             : 
     133                 :           7 : void writeSettingsArray(QSettings &settings, const QString &name,
     134                 :             :                         const QList<QVariantMap> &items) {
     135         [ +  - ]:           7 :   settings.remove(name);
     136         [ +  - ]:           7 :   settings.beginWriteArray(name, items.size());
     137         [ +  + ]:          15 :   for (int i = 0; i < items.size(); ++i) {
     138                 :           8 :     settings.setArrayIndex(i);
     139                 :           8 :     const QVariantMap &fields = items[i];
     140   [ +  -  +  -  :          44 :     for (auto it = fields.cbegin(); it != fields.cend(); ++it)
                   +  + ]
     141         [ +  - ]:          36 :       settings.setValue(it.key(), it.value());
     142                 :             :   }
     143                 :           7 :   settings.endArray();
     144                 :           7 : }
     145                 :             : 
     146                 :             : } // namespace
     147                 :             : 
     148                 :             : // ═══════════════════════════════════════════════════════
     149                 :             : // collectLocal – gather all local settings into SyncPayload
     150                 :             : // ═══════════════════════════════════════════════════════
     151                 :             : 
     152                 :          12 : SyncPayload SettingsCollector::collectLocal(MailCache *cache,
     153                 :             :                                            const QString &configDir,
     154                 :             :                                            CalendarStore *calStore) {
     155         [ +  - ]:          12 :   SyncPayload p;
     156                 :          12 :   p.version = 1;
     157         [ +  - ]:          12 :   p.lastModified = QDateTime::currentDateTimeUtc();
     158                 :             : 
     159         [ +  - ]:          12 :   QSettings s;
     160                 :             : 
     161                 :             :   // clientId
     162   [ +  -  +  - ]:          12 :   p.clientId = s.value(QStringLiteral("sync/clientId")).toString();
     163                 :             : 
     164                 :             :   // --- folderIcons ---
     165                 :             :   // Use allKeys() instead of childKeys() because QSettings treats '/' as
     166                 :             :   // a group separator. Folder paths with '/' delimiter (e.g. "Work/Projects")
     167                 :             :   // become nested subgroups that childKeys() would miss.
     168         [ +  - ]:          12 :   s.beginGroup(QStringLiteral("folder/icon"));
     169         [ +  - ]:          12 :   const auto iconKeys = s.allKeys();
     170         [ +  + ]:          17 :   for (const auto &key : iconKeys) {
     171   [ +  -  +  -  :           5 :     p.folderIcons[key] = s.value(key).toString();
                   +  - ]
     172                 :             :   }
     173         [ +  - ]:          12 :   s.endGroup();
     174                 :             : 
     175                 :             :   // --- folderColors ---
     176         [ +  - ]:          12 :   s.beginGroup(QStringLiteral("folder/color"));
     177         [ +  - ]:          12 :   const auto colorKeys = s.allKeys();
     178         [ +  + ]:          17 :   for (const auto &key : colorKeys) {
     179   [ +  -  +  -  :           5 :     p.folderColors[key] = s.value(key).toString();
                   +  - ]
     180                 :             :   }
     181         [ +  - ]:          12 :   s.endGroup();
     182                 :             : 
     183                 :             :   // --- hiddenFolders ---
     184         [ +  - ]:          12 :   p.hiddenFolders = FolderSubscriptionDialog::loadHidden(configDir);
     185                 :             : 
     186                 :             :   // The external-content whitelist is intentionally local-only. A visible
     187                 :             :   // From header is spoofable, and an unsigned sync message must not expand
     188                 :             :   // the set of messages that may load remote resources automatically.
     189                 :             : 
     190                 :             :   // --- carddavAccounts ---
     191         [ +  - ]:          12 :   int count = s.beginReadArray(QStringLiteral("carddav/accounts"));
     192         [ +  + ]:          14 :   for (int i = 0; i < count; ++i) {
     193         [ +  - ]:           2 :     s.setArrayIndex(i);
     194                 :           2 :     SyncPayload::CardDavAccount acc;
     195   [ +  -  +  - ]:           2 :     acc.id = s.value(QStringLiteral("id")).toString();
     196   [ +  -  +  - ]:           2 :     acc.serverUrl = s.value(QStringLiteral("serverUrl")).toString();
     197   [ +  -  +  - ]:           2 :     acc.username = s.value(QStringLiteral("username")).toString();
     198                 :             :     // NOTE: password intentionally NOT collected (security)
     199   [ +  -  +  - ]:           2 :     acc.selectedBooks = s.value(QStringLiteral("selectedBooks")).toStringList();
     200         [ +  - ]:           2 :     p.carddavAccounts.append(acc);
     201                 :           2 :   }
     202         [ +  - ]:          12 :   s.endArray();
     203                 :             : 
     204                 :             :   // --- caldavConfigs (T-334: references CardDAV accounts) ---
     205         [ +  - ]:          12 :   int caCount = s.beginReadArray(QStringLiteral("caldav/configs"));
     206         [ +  + ]:          15 :   for (int i = 0; i < caCount; ++i) {
     207         [ +  - ]:           3 :     s.setArrayIndex(i);
     208                 :           3 :     SyncPayload::CalDavSyncConfig cfg;
     209   [ +  -  +  - ]:           3 :     cfg.carddavAccountId = s.value(QStringLiteral("carddavAccountId")).toString();
     210   [ +  -  +  - ]:           3 :     cfg.selectedCalendars = s.value(QStringLiteral("selectedCalendars")).toStringList();
     211   [ +  -  +  - ]:           3 :     cfg.readOnlyCalendars = s.value(QStringLiteral("readOnlyCalendars")).toStringList();
     212   [ +  -  +  - ]:           6 :     cfg.syncIntervalMinutes = s.value(QStringLiteral("syncIntervalMin"), 15).toInt();
     213         [ +  - ]:           3 :     p.caldavConfigs.append(cfg);
     214                 :           3 :   }
     215         [ +  - ]:          12 :   s.endArray();
     216                 :             : 
     217                 :             :   // --- calendarColors (from CalendarStore SQLite) ---
     218         [ +  + ]:          12 :   if (calStore) {
     219         [ +  - ]:           8 :     const auto calendars = calStore->allCalendars();
     220         [ +  + ]:          15 :     for (const auto &cal : calendars) {
     221         [ +  - ]:           7 :       QString color = calStore->calendarColor(cal.path);
     222         [ +  + ]:           7 :       if (!color.isEmpty())
     223         [ +  - ]:           6 :         p.calendarColors[cal.path] = color;
     224                 :           7 :     }
     225                 :           8 :   }
     226                 :             : 
     227                 :             :   // --- general ---
     228                 :             :   p.general.defaultView =
     229         [ +  - ]:          36 :       s.value(QStringLiteral("view/defaultMode"), QStringLiteral("text"))
     230         [ +  - ]:          24 :           .toString();
     231                 :             :   p.general.externalContent =
     232         [ +  - ]:          36 :       s.value(QStringLiteral("view/externalContent"), QStringLiteral("block"))
     233         [ +  - ]:          24 :           .toString();
     234                 :             :   p.general.language =
     235         [ +  - ]:          36 :       s.value(QStringLiteral("i18n/language"), QStringLiteral("auto"))
     236         [ +  - ]:          12 :           .toString();
     237                 :          12 :   p.general.carddavSyncInterval =
     238   [ +  -  +  - ]:          24 :       s.value(QStringLiteral("carddav/syncIntervalMin"), 0).toInt();
     239                 :          12 :   p.general.caldavSyncInterval =
     240   [ +  -  +  - ]:          24 :       s.value(QStringLiteral("caldav/syncIntervalMin"), 15).toInt();
     241                 :             : 
     242                 :             :   // --- enabledCategories ---
     243                 :             :   const QStringList configuredCategories =
     244   [ +  -  +  - ]:          36 :       s.value(QStringLiteral("sync/categories"), SyncPayload::allCategories())
     245         [ +  - ]:          12 :           .toStringList();
     246         [ +  + ]:          75 :   for (const QString &rawCategory : configuredCategories) {
     247         [ +  - ]:          63 :     const QString category = normalizedCategory(rawCategory);
     248   [ +  -  +  -  :         126 :     if (SyncPayload::allCategories().contains(category) &&
          +  -  +  -  -  
                      - ]
     249         [ +  - ]:          63 :         !p.enabledCategories.contains(category)) {
     250         [ +  - ]:          63 :       p.enabledCategories.append(category);
     251                 :             :     }
     252                 :          63 :   }
     253                 :             : 
     254                 :          12 :   return p;
     255                 :          12 : }
     256                 :             : 
     257                 :             : // ═══════════════════════════════════════════════════════
     258                 :             : // applyRemote – apply a remote SyncPayload to local settings
     259                 :             : // ═══════════════════════════════════════════════════════
     260                 :             : 
     261                 :          19 : QStringList SettingsCollector::applyRemote(const SyncPayload &payload,
     262                 :             :                                            MailCache *cache,
     263                 :             :                                            const QString &configDir,
     264                 :             :                                            CalendarStore *calStore) {
     265         [ +  - ]:          19 :   QSettings s;
     266         [ +  - ]:          19 :   const QStringList cats = effectiveRemoteCategories(payload, s);
     267                 :          19 :   QStringList applied;
     268                 :             : 
     269   [ +  -  +  -  :          38 :   qCInfo(lcSettingsSync) << "Accepted remote settings categories:"
             +  -  +  + ]
     270   [ +  -  +  - ]:          19 :                          << cats.join(QStringLiteral(", "));
     271                 :             : 
     272                 :             :   // --- folderIcons ---
     273   [ +  +  +  -  :          44 :   if (cats.contains(QStringLiteral("folderIcons")) &&
          +  -  +  +  -  
                -  -  - ]
     274   [ +  -  +  - ]:           6 :       payload.folderIcons.size() <= kMaxSyncedMapEntries) {
     275                 :             :     // Clear existing folder icons
     276         [ +  - ]:           6 :     s.beginGroup(QStringLiteral("folder/icon"));
     277         [ +  - ]:           6 :     s.remove(QString()); // removes all keys in the group
     278         [ +  - ]:           6 :     s.endGroup();
     279                 :             :     // Write new ones
     280         [ +  - ]:           6 :     for (auto it = payload.folderIcons.constBegin();
     281   [ +  -  +  + ]:          11 :          it != payload.folderIcons.constEnd(); ++it) {
     282         [ +  - ]:          10 :       if (safeText(it.key(), 4096) &&
     283   [ +  -  +  -  :          10 :           iconNamePattern().match(it.value()).hasMatch()) {
          +  -  +  -  +  
             -  +  -  -  
                      - ]
     284   [ +  -  +  - ]:          10 :         s.setValue(QStringLiteral("folder/icon/") + it.key(), it.value());
     285                 :             :       } else {
     286   [ #  #  #  #  :           0 :         qCWarning(lcSettingsSync) << "Rejected invalid folder icon mapping for"
             #  #  #  # ]
     287         [ #  # ]:           0 :                                   << it.key();
     288                 :             :       }
     289                 :             :     }
     290   [ +  -  +  -  :          12 :     qCInfo(lcSettingsSync) << "Applied" << payload.folderIcons.size()
          +  -  +  -  +  
                -  +  + ]
     291         [ +  - ]:           6 :                            << "folder icons";
     292         [ +  - ]:           6 :     applied.append(QStringLiteral("folderIcons"));
     293                 :             :   }
     294                 :             : 
     295                 :             :   // --- folderColors ---
     296   [ +  +  +  -  :          44 :   if (cats.contains(QStringLiteral("folderColors")) &&
          +  -  +  +  -  
                -  -  - ]
     297   [ +  -  +  - ]:           6 :       payload.folderColors.size() <= kMaxSyncedMapEntries) {
     298         [ +  - ]:           6 :     s.beginGroup(QStringLiteral("folder/color"));
     299         [ +  - ]:           6 :     s.remove(QString());
     300         [ +  - ]:           6 :     s.endGroup();
     301         [ +  - ]:           6 :     for (auto it = payload.folderColors.constBegin();
     302   [ +  -  +  + ]:          11 :          it != payload.folderColors.constEnd(); ++it) {
     303         [ +  - ]:          10 :       if (safeText(it.key(), 4096) &&
     304   [ +  -  +  -  :          10 :           colorPattern().match(it.value()).hasMatch()) {
          +  -  +  -  +  
             -  +  -  -  
                      - ]
     305   [ +  -  +  - ]:          10 :         s.setValue(QStringLiteral("folder/color/") + it.key(), it.value());
     306                 :             :       } else {
     307   [ #  #  #  #  :           0 :         qCWarning(lcSettingsSync) << "Rejected invalid folder color mapping for"
             #  #  #  # ]
     308         [ #  # ]:           0 :                                   << it.key();
     309                 :             :       }
     310                 :             :     }
     311   [ +  -  +  -  :          12 :     qCInfo(lcSettingsSync) << "Applied" << payload.folderColors.size()
          +  -  +  -  +  
                -  +  + ]
     312         [ +  - ]:           6 :                            << "folder colors";
     313         [ +  - ]:           6 :     applied.append(QStringLiteral("folderColors"));
     314                 :             :   }
     315                 :             : 
     316                 :             :   // --- hiddenFolders ---
     317         [ +  + ]:          19 :   if (cats.contains(QStringLiteral("hiddenFolders"))) {
     318                 :           5 :     QStringList hidden;
     319         [ +  - ]:           5 :     hidden.reserve(qMin(payload.hiddenFolders.size(), qsizetype{4096}));
     320         [ +  + ]:           9 :     for (const QString &folder : payload.hiddenFolders) {
     321         [ -  + ]:           4 :       if (hidden.size() >= 4096)
     322                 :           0 :         break;
     323   [ +  -  +  -  :           4 :       if (safeText(folder, 4096) && !hidden.contains(folder))
                   +  - ]
     324         [ +  - ]:           4 :         hidden.append(folder);
     325                 :             :     }
     326         [ +  - ]:           5 :     FolderSubscriptionDialog::saveHidden(configDir, hidden);
     327   [ +  -  +  -  :          10 :     qCInfo(lcSettingsSync) << "Applied" << hidden.size()
          +  -  +  -  +  
                      + ]
     328         [ +  - ]:           5 :                            << "hidden folders";
     329         [ +  - ]:           5 :     applied.append(QStringLiteral("hiddenFolders"));
     330                 :           5 :   }
     331                 :             : 
     332                 :             :   Q_UNUSED(cache);
     333                 :             : 
     334                 :             :   // --- carddavAccounts ---
     335                 :          19 :   QSet<QString> acceptedDavIds;
     336         [ +  + ]:          19 :   if (cats.contains(QStringLiteral("davAccounts"))) {
     337                 :             :     QList<QVariantMap> localAccounts =
     338         [ +  - ]:           8 :         readSettingsArray(s, QStringLiteral("carddav/accounts"));
     339                 :           8 :     QHash<QString, int> localById;
     340         [ +  + ]:          12 :     for (int i = 0; i < localAccounts.size(); ++i)
     341   [ +  -  +  -  :           8 :       localById.insert(localAccounts[i].value(QStringLiteral("id")).toString(),
             +  -  +  - ]
     342                 :             :                        i);
     343                 :             : 
     344                 :           8 :     bool changed = false;
     345                 :           8 :     QSet<QString> seenRemoteIds;
     346         [ +  + ]:          14 :     for (const auto &remote : payload.carddavAccounts) {
     347         [ +  - ]:           6 :       const QString remoteId = remote.id.trimmed();
     348   [ +  -  +  -  :           6 :       if (!validDavAccount(remote) || seenRemoteIds.contains(remoteId)) {
             -  +  -  + ]
     349   [ #  #  #  #  :           0 :         qCWarning(lcSettingsSync) << "Rejected invalid or duplicate DAV account"
             #  #  #  # ]
     350         [ #  # ]:           0 :                                   << remoteId;
     351                 :           0 :         continue;
     352                 :           0 :       }
     353         [ +  - ]:           6 :       seenRemoteIds.insert(remoteId);
     354                 :             : 
     355                 :           6 :       const auto localIt = localById.constFind(remoteId);
     356         [ +  + ]:           6 :       if (localIt != localById.cend()) {
     357         [ +  - ]:           3 :         QVariantMap &local = localAccounts[*localIt];
     358   [ +  -  +  + ]:           3 :         if (!sameDavIdentity(local, remote)) {
     359   [ +  -  +  -  :           2 :           qCWarning(lcSettingsSync)
                   +  + ]
     360   [ +  -  +  - ]:           1 :               << "Rejected remote DAV identity change for account" << remoteId;
     361                 :           1 :           continue;
     362                 :           1 :         }
     363         [ +  - ]:           4 :         local.insert(QStringLiteral("selectedBooks"),
     364         [ +  - ]:           4 :                      safeDavSelections(remote.selectedBooks));
     365         [ +  - ]:           2 :         acceptedDavIds.insert(remoteId);
     366                 :           2 :         changed = true;
     367                 :           2 :         continue;
     368                 :           2 :       }
     369                 :             : 
     370         [ -  + ]:           3 :       if (localAccounts.size() >= kMaxDavAccounts) {
     371   [ #  #  #  #  :           0 :         qCWarning(lcSettingsSync) << "DAV account limit reached";
             #  #  #  # ]
     372                 :           0 :         break;
     373                 :             :       }
     374                 :           3 :       QVariantMap pending;
     375         [ +  - ]:           6 :       pending.insert(QStringLiteral("id"), remoteId);
     376   [ +  -  +  - ]:           6 :       pending.insert(QStringLiteral("serverUrl"), remote.serverUrl.trimmed());
     377   [ +  -  +  - ]:           6 :       pending.insert(QStringLiteral("username"), remote.username.trimmed());
     378         [ +  - ]:           6 :       pending.insert(QStringLiteral("selectedBooks"),
     379         [ +  - ]:           6 :                      safeDavSelections(remote.selectedBooks));
     380         [ +  - ]:           6 :       pending.insert(QStringLiteral("requiresLocalAuthorization"), true);
     381         [ +  - ]:           3 :       localAccounts.append(pending);
     382         [ +  - ]:           3 :       localById.insert(remoteId, localAccounts.size() - 1);
     383         [ +  - ]:           3 :       acceptedDavIds.insert(remoteId);
     384                 :           3 :       changed = true;
     385      [ +  +  - ]:           6 :     }
     386                 :             : 
     387         [ +  + ]:           8 :     if (changed) {
     388         [ +  - ]:           5 :       writeSettingsArray(s, QStringLiteral("carddav/accounts"), localAccounts);
     389         [ +  - ]:           5 :       applied.append(QStringLiteral("davAccounts"));
     390   [ +  -  +  -  :          10 :       qCInfo(lcSettingsSync) << "Merged" << acceptedDavIds.size()
          +  -  +  -  +  
                      + ]
     391                 :             :                              << "DAV account selections without changing"
     392         [ +  - ]:           5 :                                 " local endpoint identities";
     393                 :             :     }
     394                 :           8 :   }
     395                 :             : 
     396                 :             :   // --- caldavConfigs (T-334: calendar selection per CardDAV account) ---
     397   [ +  +  +  -  :          46 :   if (cats.contains(QStringLiteral("davAccounts")) &&
             +  -  +  + ]
     398         [ +  + ]:           8 :       !acceptedDavIds.isEmpty()) {
     399                 :             :     QList<QVariantMap> localConfigs =
     400         [ +  - ]:           5 :         readSettingsArray(s, QStringLiteral("caldav/configs"));
     401                 :           5 :     QHash<QString, int> localConfigById;
     402         [ -  + ]:           5 :     for (int i = 0; i < localConfigs.size(); ++i) {
     403         [ #  # ]:           0 :       localConfigById.insert(
     404         [ #  # ]:           0 :           localConfigs[i]
     405         [ #  # ]:           0 :               .value(QStringLiteral("carddavAccountId"))
     406         [ #  # ]:           0 :               .toString(),
     407                 :             :           i);
     408                 :             :     }
     409                 :             : 
     410                 :           5 :     bool configsChanged = false;
     411         [ +  + ]:           7 :     for (const auto &remote : payload.caldavConfigs) {
     412         [ +  - ]:           2 :       const QString accountId = remote.carddavAccountId.trimmed();
     413         [ -  + ]:           2 :       if (!acceptedDavIds.contains(accountId))
     414                 :           0 :         continue;
     415         [ +  - ]:           2 :       const QStringList selected = safeDavSelections(remote.selectedCalendars);
     416         [ +  - ]:           2 :       QStringList readOnly = safeDavSelections(remote.readOnlyCalendars);
     417   [ +  -  +  -  :           3 :       for (auto it = readOnly.begin(); it != readOnly.end();) {
                   +  + ]
     418         [ -  + ]:           1 :         if (!selected.contains(*it))
     419         [ #  # ]:           0 :           it = readOnly.erase(it);
     420                 :             :         else
     421                 :           1 :           ++it;
     422                 :             :       }
     423                 :             : 
     424                 :           2 :       auto localIt = localConfigById.constFind(accountId);
     425         [ +  - ]:           2 :       if (localIt == localConfigById.cend()) {
     426                 :           2 :         QVariantMap config;
     427         [ +  - ]:           4 :         config.insert(QStringLiteral("carddavAccountId"), accountId);
     428         [ +  - ]:           4 :         config.insert(QStringLiteral("selectedCalendars"), selected);
     429         [ +  - ]:           4 :         config.insert(QStringLiteral("readOnlyCalendars"), readOnly);
     430                 :             :         // Scheduling remains a local policy; do not copy the remote interval.
     431         [ +  - ]:           4 :         config.insert(QStringLiteral("syncIntervalMin"), 15);
     432         [ +  - ]:           2 :         localConfigs.append(config);
     433         [ +  - ]:           2 :         localConfigById.insert(accountId, localConfigs.size() - 1);
     434                 :           2 :       } else {
     435         [ #  # ]:           0 :         QVariantMap &config = localConfigs[*localIt];
     436         [ #  # ]:           0 :         config.insert(QStringLiteral("selectedCalendars"), selected);
     437         [ #  # ]:           0 :         config.insert(QStringLiteral("readOnlyCalendars"), readOnly);
     438                 :             :       }
     439                 :           2 :       configsChanged = true;
     440         [ +  - ]:           2 :     }
     441         [ +  + ]:           5 :     if (configsChanged)
     442         [ +  - ]:           2 :       writeSettingsArray(s, QStringLiteral("caldav/configs"), localConfigs);
     443                 :           5 :   }
     444                 :             : 
     445                 :             :   // --- calendarColors (to CalendarStore SQLite) ---
     446   [ +  +  +  +  :          42 :   if (cats.contains(QStringLiteral("calendarColors")) && calStore &&
          +  -  +  -  +  
             +  -  -  -  
                      - ]
     447   [ +  -  +  - ]:           4 :       payload.calendarColors.size() <= kMaxSyncedMapEntries) {
     448         [ +  - ]:           4 :     for (auto it = payload.calendarColors.constBegin();
     449   [ +  -  +  + ]:           7 :          it != payload.calendarColors.constEnd(); ++it) {
     450         [ +  - ]:           6 :       if (safeText(it.key(), 4096) &&
     451   [ +  -  +  -  :           6 :           colorPattern().match(it.value()).hasMatch()) {
          +  -  +  -  +  
             -  +  -  -  
                      - ]
     452         [ +  - ]:           3 :         calStore->setCalendarColor(it.key(), it.value());
     453                 :             :       }
     454                 :             :     }
     455   [ +  -  +  -  :           8 :     qCInfo(lcSettingsSync) << "Applied" << payload.calendarColors.size()
          +  -  +  -  +  
                -  +  + ]
     456         [ +  - ]:           4 :                            << "calendar colors";
     457         [ +  - ]:           4 :     applied.append(QStringLiteral("calendarColors"));
     458                 :             :   }
     459                 :             : 
     460                 :             :   // --- general ---
     461         [ +  + ]:          19 :   if (cats.contains(QStringLiteral("general"))) {
     462                 :           3 :     bool generalApplied = false;
     463   [ +  +  +  -  :          11 :     if (payload.general.defaultView == QStringLiteral("text") ||
             +  -  +  - ]
     464   [ +  +  +  +  :           5 :         payload.general.defaultView == QStringLiteral("html")) {
                   +  - ]
     465         [ +  - ]:           3 :       s.setValue(QStringLiteral("view/defaultMode"),
     466                 :           3 :                  payload.general.defaultView);
     467                 :           3 :       generalApplied = true;
     468                 :             :     }
     469   [ +  +  +  -  :           8 :     if (payload.general.language == QStringLiteral("auto") ||
                   +  - ]
     470   [ +  +  +  -  :          13 :         payload.general.language == QStringLiteral("en") ||
             +  +  +  - ]
     471   [ +  +  +  +  :           4 :         payload.general.language == QStringLiteral("de")) {
                   +  + ]
     472         [ +  - ]:           6 :       s.setValue(QStringLiteral("i18n/language"), payload.general.language);
     473                 :           3 :       generalApplied = true;
     474                 :             :     }
     475                 :             :     // view/externalContent and all network scheduling intervals stay local.
     476         [ +  - ]:           3 :     if (generalApplied) {
     477         [ +  - ]:           3 :       applied.append(QStringLiteral("general"));
     478   [ +  -  +  -  :           6 :       qCInfo(lcSettingsSync) << "Applied non-sensitive general settings";
             +  -  +  + ]
     479                 :             :     }
     480                 :             :   }
     481                 :             : 
     482         [ +  + ]:          19 :   if (!applied.isEmpty()) {
     483         [ +  - ]:          24 :     s.setValue(QStringLiteral("sync/lastSync"),
     484   [ +  -  +  - ]:          24 :                QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
     485                 :             :   }
     486                 :             : 
     487         [ +  - ]:          19 :   s.sync();
     488   [ +  -  +  -  :          19 :   if (!QFile::setPermissions(s.fileName(),
                   +  + ]
     489                 :             :                              QFileDevice::ReadOwner | QFileDevice::WriteOwner)) {
     490   [ +  -  +  -  :           4 :     qCWarning(lcSettingsSync) << "Failed to restrict settings permissions for"
             +  -  +  + ]
     491   [ +  -  +  - ]:           2 :                               << s.fileName();
     492                 :             :   }
     493                 :          19 :   return applied;
     494                 :          19 : }
     495                 :             : 
     496                 :             : // ═══════════════════════════════════════════════════════
     497                 :             : // categoryChanged – diff helper
     498                 :             : // ═══════════════════════════════════════════════════════
     499                 :             : 
     500                 :          49 : bool SettingsCollector::categoryChanged(const QString &category,
     501                 :             :                                         const SyncPayload &local,
     502                 :             :                                         const SyncPayload &remote) {
     503         [ +  + ]:          49 :   if (category == QStringLiteral("folderIcons"))
     504                 :           9 :     return local.folderIcons != remote.folderIcons;
     505         [ +  + ]:          40 :   if (category == QStringLiteral("folderColors"))
     506                 :           5 :     return local.folderColors != remote.folderColors;
     507         [ +  + ]:          35 :   if (category == QStringLiteral("hiddenFolders"))
     508                 :           4 :     return local.hiddenFolders != remote.hiddenFolders;
     509         [ +  + ]:          31 :   if (category == QStringLiteral("externalContentWhitelist")) {
     510         [ +  + ]:           8 :     if (local.externalContentWhitelist.size() !=
     511                 :           4 :         remote.externalContentWhitelist.size())
     512                 :           1 :       return true;
     513         [ +  + ]:           4 :     for (int i = 0; i < local.externalContentWhitelist.size(); ++i) {
     514                 :          12 :       if (local.externalContentWhitelist[i].type !=
     515   [ +  +  +  + ]:           5 :               remote.externalContentWhitelist[i].type ||
     516         [ +  + ]:           2 :           local.externalContentWhitelist[i].value !=
     517                 :           2 :               remote.externalContentWhitelist[i].value)
     518                 :           2 :         return true;
     519                 :             :     }
     520                 :           1 :     return false;
     521                 :             :   }
     522         [ +  + ]:          27 :   if (category == QStringLiteral("calendarColors"))
     523                 :           4 :     return local.calendarColors != remote.calendarColors;
     524   [ +  +  +  +  :          81 :   if (category == QStringLiteral("davAccounts") ||
             +  -  +  + ]
     525   [ +  +  +  +  :          35 :       category == QStringLiteral("carddavAccounts")) {
                   +  - ]
     526         [ +  + ]:          12 :     if (local.carddavAccounts.size() != remote.carddavAccounts.size())
     527                 :           1 :       return true;
     528         [ +  + ]:          15 :     for (int i = 0; i < local.carddavAccounts.size(); ++i) {
     529                 :           8 :       const auto &a = local.carddavAccounts[i];
     530                 :           8 :       const auto &b = remote.carddavAccounts[i];
     531         [ +  + ]:          15 :       if (a.id != b.id || a.serverUrl != b.serverUrl ||
     532   [ +  +  +  +  :          15 :           a.username != b.username || a.selectedBooks != b.selectedBooks)
             +  +  +  + ]
     533                 :           4 :         return true;
     534                 :             :     }
     535                 :             :     // Also check caldav configs
     536         [ +  + ]:           7 :     if (local.caldavConfigs.size() != remote.caldavConfigs.size())
     537                 :           1 :       return true;
     538         [ +  + ]:           7 :     for (int i = 0; i < local.caldavConfigs.size(); ++i) {
     539                 :           5 :       const auto &a = local.caldavConfigs[i];
     540                 :           5 :       const auto &b = remote.caldavConfigs[i];
     541                 :           5 :       if (a.carddavAccountId != b.carddavAccountId ||
     542         [ +  + ]:           4 :           a.selectedCalendars != b.selectedCalendars ||
     543   [ +  +  +  +  :          11 :           a.readOnlyCalendars != b.readOnlyCalendars ||
                   +  + ]
     544         [ +  + ]:           2 :           a.syncIntervalMinutes != b.syncIntervalMinutes)
     545                 :           4 :         return true;
     546                 :             :     }
     547                 :           2 :     return false;
     548                 :             :   }
     549         [ +  + ]:          11 :   if (category == QStringLiteral("general")) {
     550                 :           9 :     return local.general.defaultView != remote.general.defaultView ||
     551         [ +  + ]:           8 :            local.general.externalContent != remote.general.externalContent ||
     552         [ +  + ]:           7 :            local.general.language != remote.general.language ||
     553                 :           4 :            local.general.carddavSyncInterval !=
     554   [ +  +  +  + ]:          20 :                remote.general.carddavSyncInterval ||
     555                 :           3 :            local.general.caldavSyncInterval !=
     556         [ +  + ]:          12 :                remote.general.caldavSyncInterval;
     557                 :             :   }
     558                 :           2 :   return false;
     559                 :             : }
        

Generated by: LCOV version 2.0-1