service/CalDavClient.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <optional> | ||
| 4 | |||
| 5 | #include <QHash> | ||
| 6 | #include <QList> | ||
| 7 | #include <QNetworkReply> | ||
| 8 | #include <QObject> | ||
| 9 | #include <QSet> | ||
| 10 | #include <QString> | ||
| 11 | |||
| 12 | #include "data/CalendarModels.h" | ||
| 13 | #include "service/DavResponse.h" | ||
| 14 | |||
| 15 | // Sprint 93 S93.6.2: one sync-collection (RFC 6578) result. `full` = the | ||
| 16 | // request carried no token, so the lists are the complete collection — | ||
| 17 | // unless `truncated` (Sprint 96 S96.3.0): the server answered with a 507 | ||
| 18 | // for the collection and further pages follow from `syncToken`. | ||
| 19 | struct DavCollectionChanges { | ||
| 20 | bool full = false; | ||
| 21 | bool truncated = false; // RFC 6578 §3.6: more pages follow | ||
| 22 | QList<CalendarEvent> events; // rows of changed resources (resolved hrefs) | ||
| 23 | QList<CalendarTask> tasks; | ||
| 24 | QStringList removedHrefs; // resolved URLs of removed resources | ||
| 25 | QStringList failedHrefs; // members with an error / without calendar-data | ||
| 26 | QString syncToken; // the token to store after applying | ||
| 27 | QString requestToken; // the token this page was requested with | ||
| 28 | }; | ||
| 29 |
4/7✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 14 not taken.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✗ Branch 14 → 15 not taken.
|
1 | Q_DECLARE_METATYPE(DavCollectionChanges) |
| 30 | |||
| 31 | class QNetworkAccessManager; | ||
| 32 | class QNetworkReply; | ||
| 33 | class QTimer; | ||
| 34 | |||
| 35 | // ═══════════════════════════════════════════════════════ | ||
| 36 | // CalDAV client for calendar discovery and sync (Sprint 32 – T-331) | ||
| 37 | // Architecture mirrors CardDavClient (Sprint 16). | ||
| 38 | // ═══════════════════════════════════════════════════════ | ||
| 39 | |||
| 40 | class CalDavClient : public QObject { | ||
| 41 | Q_OBJECT | ||
| 42 | public: | ||
| 43 | CalDavClient(const QString &serverUrl, const QString &username, | ||
| 44 | const QString &password, QObject *parent = nullptr); | ||
| 45 | |||
| 46 | /// Discover available calendars via PROPFIND | ||
| 47 | void discoverCalendars(); | ||
| 48 | |||
| 49 | /// Sync all events (VEVENT) from a calendar via REPORT | ||
| 50 | void syncCalendar(const QString &calendarPath); | ||
| 51 | |||
| 52 | /// Sync all tasks (VTODO) from a calendar via REPORT | ||
| 53 | void syncTasks(const QString &calendarPath); | ||
| 54 | |||
| 55 | // ── Sprint 39 – T-530: Write API ────────────────────── | ||
| 56 | |||
| 57 | /// Create a new event on the server (HTTP PUT + If-None-Match) | ||
| 58 | void createEvent(const QString &calendarPath, const CalendarEvent &event); | ||
| 59 | /// Update an existing event (HTTP PUT + If-Match: etag) | ||
| 60 | void updateEvent(const CalendarEvent &event); | ||
| 61 | /// Delete an event (HTTP DELETE + If-Match: etag) | ||
| 62 | void deleteEvent(const CalendarEvent &event); | ||
| 63 | // Sprint 93 S93.5.8: writes the collection's APPLE:calendar-color | ||
| 64 | // (PROPPATCH). calendarColorSaved on success, writeFailed otherwise. | ||
| 65 | void setCalendarColor(const QString &calendarPath, const QString &color); | ||
| 66 | |||
| 67 | /// Create a new task on the server | ||
| 68 | void createTask(const QString &calendarPath, const CalendarTask &task); | ||
| 69 | /// Update an existing task | ||
| 70 | void updateTask(const CalendarTask &task); | ||
| 71 | /// Delete a task | ||
| 72 | void deleteTask(const CalendarTask &task); | ||
| 73 | |||
| 74 | // ── Sprint 93 S93.1.6: raw resource requests for the write outbox ── | ||
| 75 | // Each returns a request id echoed in resourceResponse(). `ifMatch` is a | ||
| 76 | // validated entity-tag (invalid ones are refused locally with status 0); | ||
| 77 | // `createOnly` sends If-None-Match: * instead. | ||
| 78 | quint64 putResource(const QString &url, const QByteArray &body, const QString &ifMatch, | ||
| 79 | bool createOnly); | ||
| 80 | quint64 deleteResource(const QString &url, const QString &ifMatch); | ||
| 81 | quint64 getResource(const QString &url); | ||
| 82 | // Sprint 108 S108.8.5 (RFC 6638 §5): a VFREEBUSY request POSTed to the | ||
| 83 | // schedule outbox; the schedule-response arrives as resourceResponse(). | ||
| 84 | quint64 postFreeBusy(const QString &outboxUrl, const QByteArray &request); | ||
| 85 | // S108.8.4: OPTIONS on a URL; resourceResponse().davHeader carries the | ||
| 86 | // compliance classes (calendar-auto-schedule). | ||
| 87 | quint64 options(const QString &url); | ||
| 88 | // The URL of an event resource: its stored href, else <calendar>/<uid>.ics. | ||
| 89 | QString eventResourceUrl(const QString &resourceHref, const QString &calendarPath, | ||
| 90 | const QString &uid) const; | ||
| 91 | |||
| 92 | /// Serialize a CalendarEvent to iCalendar format (RFC 5545 VEVENT) | ||
| 93 | static QByteArray eventToICalendar(const CalendarEvent &event); | ||
| 94 | // Q11: a master with recurrence exceptions shares one DAV resource; | ||
| 95 | // pushes must serialize the whole series or the PUT would drop half of | ||
| 96 | // it on the server. | ||
| 97 | static QByteArray seriesToICalendar(const CalendarEvent &master, | ||
| 98 | const QList<CalendarEvent> &exceptions); | ||
| 99 | /// Serialize a CalendarTask to iCalendar format (RFC 5545 VTODO) | ||
| 100 | static QByteArray taskToICalendar(const CalendarTask &task); | ||
| 101 | |||
| 102 | /// Test seam: replace the network access manager (takes ownership). Lets | ||
| 103 | /// unit tests inject a fake NAM that returns canned replies for the write API. | ||
| 104 | void setNetworkAccessManager(QNetworkAccessManager *nam); | ||
| 105 | |||
| 106 | // T-79.F1/H8: number of network replies still in flight. The client | ||
| 107 | // emits allRequestsFinished() when it drops to 0 — callers connect that | ||
| 108 | // to deleteLater() instead of a fixed-lifetime timer. | ||
| 109 | int pendingReplies() const { return m_pendingReplies; } | ||
| 110 | // Sprint 93 S93.6.3: the transport error behind the last syncFailed() | ||
| 111 | // (authentication, host, TLS, timeout …) so the sync status can tell a | ||
| 112 | // rejected password from an unreachable server without parsing the | ||
| 113 | // (possibly translated) error text. NoError for data/protocol errors. | ||
| 114 | 8 | QNetworkReply::NetworkError lastNetworkError() const { return m_lastNetworkError; } | |
| 115 | // Sprint 93 S93.5.2: the principal announced an RFC 6638 | ||
| 116 | // schedule-outbox-URL (the server sends iMIP replies itself). Unset when | ||
| 117 | // discovery did not read the principal (configured calendar home). | ||
| 118 | 78 | std::optional<bool> serverScheduling() const { return m_serverScheduling; } | |
| 119 | // Sprint 108: the resolved schedule-outbox URL and the principal's | ||
| 120 | // calendar-user-address-set (lower case) of the last discovery. | ||
| 121 | 1 | QString scheduleOutboxUrl() const { return m_scheduleOutboxUrl; } | |
| 122 | 1 | QStringList calendarUserAddresses() const { return m_calendarUserAddresses; } | |
| 123 | |||
| 124 | // Sprint 93 S93.6.2: RFC 6578 sync-collection. An empty token returns | ||
| 125 | // the whole collection (changes.full); otherwise only the resources | ||
| 126 | // changed or removed since the token. Results: collectionSynced(), or | ||
| 127 | // collectionSyncRejected() when the server does not support the report | ||
| 128 | // (invalidToken = false) or no longer accepts the token (true). | ||
| 129 | void syncCollection(const QString &calendarPath, const QString &syncToken); | ||
| 130 | |||
| 131 | // Sprint 96 S96.3.7: PUT/DELETE resource requests carry | ||
| 132 | // `Push-Dont-Notify: "<registrationUrl>"` (never "*") while set. | ||
| 133 | 142 | void setPushDontNotify(const QString ®istrationUrl) { m_pushDontNotify = registrationUrl; } | |
| 134 | |||
| 135 | signals: | ||
| 136 | // T-79.F1/H8: the last outstanding reply finished (or the inactivity | ||
| 137 | // watchdog gave up). Safe point to deleteLater() the client. | ||
| 138 | void allRequestsFinished(); | ||
| 139 | // F-54 (SPRINT-104): every finished reply re-emits this, so an account | ||
| 140 | // run can restart its fixed budget and long batched downloads survive. | ||
| 141 | void requestAnswered(); | ||
| 142 | |||
| 143 | void calendarsDiscovered(const QList<CalendarInfo> &calendars); | ||
| 144 | // `complete` = false when the server truncated the result (507) or a | ||
| 145 | // member came without calendar-data (Sprint 96 S96.3.0): store the rows, | ||
| 146 | // but prune nothing and commit no CTag. | ||
| 147 | void eventsSynced(const QString &calendarPath, | ||
| 148 | const QList<CalendarEvent> &events, bool complete); | ||
| 149 | void tasksSynced(const QString &calendarPath, | ||
| 150 | const QList<CalendarTask> &tasks, bool complete); | ||
| 151 | void syncFailed(const QString &error); | ||
| 152 | void collectionSynced(const QString &calendarPath, const DavCollectionChanges &changes); | ||
| 153 | void collectionSyncRejected(const QString &calendarPath, bool invalidToken); | ||
| 154 | |||
| 155 | // T-530 write result signals | ||
| 156 | void eventSaved(const CalendarEvent &event); | ||
| 157 | void eventDeleted(const QString &uid); | ||
| 158 | void calendarColorSaved(const QString &calendarPath); | ||
| 159 | void taskSaved(const CalendarTask &task); | ||
| 160 | void taskDeleted(const QString &uid); | ||
| 161 | void writeFailed(const QString &error); | ||
| 162 | void resourceResponse(const DavResponse &response); | ||
| 163 | #ifdef MAILJD_E2E_TESTING | ||
| 164 | // Metadata only: operation class and an opaque per-client request token. | ||
| 165 | void e2eTransportPhase(const QString &phase, quint64 requestToken); | ||
| 166 | #endif | ||
| 167 | |||
| 168 | private: | ||
| 169 | friend class TestCalDav; | ||
| 170 | friend class TestSprint39; | ||
| 171 | friend class TestSprint79ICal; | ||
| 172 | friend class TestSprint79Services; | ||
| 173 | |||
| 174 | // T-79.F1/H8: register a dispatched reply for lifetime tracking. | ||
| 175 | // Call AFTER connecting the reply's business handler, so follow-up | ||
| 176 | // requests started from result signals are counted before the zero | ||
| 177 | // check runs. | ||
| 178 | void trackReply(QNetworkReply *reply); | ||
| 179 | void onInactivityTimeout(); | ||
| 180 | quint64 sendResourceRequest(const QByteArray &method, const QString &url, const QByteArray &body, | ||
| 181 | const QList<QPair<QByteArray, QByteArray>> &headers); | ||
| 182 | quint64 m_nextResourceRequest = 0; | ||
| 183 | |||
| 184 | void onDiscoverReply(QNetworkReply *reply); | ||
| 185 | enum class DiscoveryStep { | ||
| 186 | ConfiguredCollection, | ||
| 187 | WellKnown, | ||
| 188 | Principal, | ||
| 189 | CalendarHome, | ||
| 190 | NextcloudFallback | ||
| 191 | }; | ||
| 192 | void requestDiscovery(const QString &url, DiscoveryStep step); | ||
| 193 | void advanceDiscovery(const QString &reason); | ||
| 194 | void onSyncEventsReply(QNetworkReply *reply, const QString &calendarPath); | ||
| 195 | void onSyncTasksReply(QNetworkReply *reply, const QString &calendarPath); | ||
| 196 | // F-24 (SPRINT-104): fetch members that came back without data. | ||
| 197 | void fetchFailedMembers(const QString &calendarPath, DavCollectionChanges changes); | ||
| 198 | void fetchFailedMemberBatch(const QString &calendarPath, DavCollectionChanges changes, | ||
| 199 | QStringList pending); | ||
| 200 | // One calendar-multiget carries at most this many hrefs (S106-057). | ||
| 201 | static constexpr int MaxMultigetMembers = 100; | ||
| 202 | void onSyncCollectionReply(QNetworkReply *reply, const QString &calendarPath, | ||
| 203 | const QString &requestToken); | ||
| 204 | // F-54 (SPRINT-104): when a token-less full download breaks the DAV | ||
| 205 | // response bounds (10 MiB / 30 s), the client falls back to what the | ||
| 206 | // address books do — an ETag-only listing, then calendar-multiget in | ||
| 207 | // batches that halve on too-large answers. Every response stays bounded. | ||
| 208 | void requestEtagListing(const QString &calendarPath); | ||
| 209 | void onEtagListingReply(QNetworkReply *reply, const QString &calendarPath); | ||
| 210 | void fetchNextEnumerationBatch(const QString &calendarPath); | ||
| 211 | // Shared wire form of RFC 4791 §7.9 calendar-multiget for the resolved | ||
| 212 | // `hrefs` (F-24 fallback and F-54 batches). Returns nullptr when the | ||
| 213 | // collection URL or a member path is unusable. | ||
| 214 | QNetworkReply *sendCalendarMultiget(const QString &calendarPath, const QStringList &hrefs); | ||
| 215 | bool queryComplete(const QByteArray &xml, const QString &calendarPath) const; | ||
| 216 | QStringList failedMembers(const QStringList &hrefs, const QString &calendarPath) const; | ||
| 217 | // S106-704: the `requested` members a calendar-multiget reply neither | ||
| 218 | // listed, refused nor removed (resolved hrefs). They stay failed. | ||
| 219 | QStringList unansweredMembers(const QStringList &requested, const QStringList &listed, | ||
| 220 | const QStringList &failed, const QStringList &removed, | ||
| 221 | const QString &calendarPath) const; | ||
| 222 | |||
| 223 | /// Build the CalDAV resource URL for a new resource path + UID | ||
| 224 | QString resourceUrl(const QString &calendarPath, const QString &uid) const; | ||
| 225 | QString resourceUrlForExistingResource(const QString &resourceHref, | ||
| 226 | const QString &calendarPath, | ||
| 227 | const QString &uid) const; | ||
| 228 | QString resolveDavUrl(const QString &href) const; | ||
| 229 | |||
| 230 | /// Parse iCal text into CalendarEvent list | ||
| 231 | static QList<CalendarEvent> parseICalEvents(const QByteArray &xmlData); | ||
| 232 | static bool parseICalEvents(const QByteArray &xmlData, | ||
| 233 | QList<CalendarEvent> *events, | ||
| 234 | QString *error); | ||
| 235 | |||
| 236 | /// Parse iCal text into CalendarTask list | ||
| 237 | static QList<CalendarTask> parseICalTasks(const QByteArray &xmlData); | ||
| 238 | static bool parseICalTasks(const QByteArray &xmlData, | ||
| 239 | QList<CalendarTask> *tasks, | ||
| 240 | QString *error); | ||
| 241 | |||
| 242 | QByteArray authHeader() const; | ||
| 243 | #ifdef MAILJD_E2E_TESTING | ||
| 244 | void reportE2eTransportPhase(const QString &operation); | ||
| 245 | #endif | ||
| 246 | |||
| 247 | // Validate the complete RFC entity-tag grammar before setting If-Match. | ||
| 248 | static bool isValidEtag(const QString &etag); | ||
| 249 | |||
| 250 | QNetworkAccessManager *m_nam = nullptr; | ||
| 251 | QString m_serverUrl; | ||
| 252 | QString m_username; | ||
| 253 | QString m_password; | ||
| 254 | DiscoveryStep m_discoveryStep = DiscoveryStep::ConfiguredCollection; | ||
| 255 | int m_discoveryRedirects = 0; // same-origin PROPFIND redirects followed in this discovery | ||
| 256 | bool m_pushPropertiesRejected = false; | ||
| 257 | QString m_pushDontNotify; // S96.1.2: retry discovery without push properties | ||
| 258 | QString m_discoveryError; | ||
| 259 | QNetworkReply::NetworkError m_lastNetworkError = QNetworkReply::NoError; | ||
| 260 | std::optional<bool> m_serverScheduling; | ||
| 261 | QString m_scheduleOutboxUrl; | ||
| 262 | QStringList m_calendarUserAddresses; | ||
| 263 | |||
| 264 | // T-79.F1/H8: outstanding-reply tracking + inactivity watchdog. | ||
| 265 | // The watchdog restarts on every request/response; a stall longer than | ||
| 266 | // kInactivityTimeoutMs fails the outstanding work (syncFailed + | ||
| 267 | // writeFailed) and releases the client via allRequestsFinished(). | ||
| 268 | int m_pendingReplies = 0; | ||
| 269 | QSet<QNetworkReply *> m_trackedReplies; | ||
| 270 | QTimer *m_inactivityTimer = nullptr; | ||
| 271 | |||
| 272 | // F-54 (SPRINT-104): client-side batching state of one collection's | ||
| 273 | // listing-based enumeration. Pending hrefs are grouped lazily into | ||
| 274 | // `batches` of `batchSize`; a too-large answer splits its batch in half | ||
| 275 | // (ContactBookSync's pattern) and halves the size of future batches. A | ||
| 276 | // single resource above the limit fails as a member (failedHrefs), never | ||
| 277 | // as an endless split. | ||
| 278 | struct EnumerationFetch { | ||
| 279 | QString syncToken; | ||
| 280 | QStringList pending; // resolved hrefs not yet grouped | ||
| 281 | QList<QStringList> batches; // ready groups; halves are prepended | ||
| 282 | QStringList failedHrefs; // members that failed their multiget | ||
| 283 | QStringList removedHrefs; | ||
| 284 | QList<CalendarEvent> events; | ||
| 285 | QList<CalendarTask> tasks; | ||
| 286 | int batchSize = kEnumerationBatchSize; | ||
| 287 | }; | ||
| 288 | QHash<QString, EnumerationFetch> m_enumerations; | ||
| 289 | QSet<QString> m_listingFallback; // collections already retried via listing | ||
| 290 | #ifdef MAILJD_E2E_TESTING | ||
| 291 | quint64 m_e2eRequestToken = 0; | ||
| 292 | #endif | ||
| 293 | static constexpr int kInactivityTimeoutMs = 60000; | ||
| 294 | static constexpr int kEnumerationBatchSize = 50; | ||
| 295 | }; | ||
| 296 |