Browse code

BM-15370 Fix: better handling of google cals

Vincent Vignaud authored on 06/01/2020 14:47:57
Showing 6 changed files
... ...
@@ -22,4 +22,5 @@ Require-Bundle: org.junit;bundle-version="4.12.0",
22 22
  net.bluemind.pool,
23 23
  net.bluemind.calendar.api,
24 24
  net.bluemind.core.commons,
25
- net.bluemind.core.task.service;bundle-version="4.1.0"
25
+ net.bluemind.core.task.service;bundle-version="4.1.0",
26
+ slf4j.api;bundle-version="1.7.25"
26 27
new file mode 100644
... ...
@@ -0,0 +1,151 @@
1
+BEGIN:VCALENDAR
2
+PRODID:-//Google Inc//Google Calendar 70.9054//EN
3
+VERSION:2.0
4
+CALSCALE:GREGORIAN
5
+METHOD:PUBLISH
6
+X-WR-CALNAME:beheme.taiste@gmail.com
7
+X-WR-TIMEZONE:Europe/Paris
8
+BEGIN:VEVENT
9
+DTSTART:20191105T130000Z
10
+DTEND:20191105T140000Z
11
+DTSTAMP:${timestamp}
12
+UID:5hr81cnbo9hk1cv42sbj5ll0ma@google.com
13
+CREATED:20191105T111304Z
14
+DESCRIPTION: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et 
15
+  dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo 
16
+  consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 
17
+  Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
18
+LAST-MODIFIED:20191105T111304Z
19
+LOCATION:
20
+SEQUENCE:0
21
+STATUS:CONFIRMED
22
+SUMMARY:Youhou!!!!!
23
+TRANSP:OPAQUE
24
+END:VEVENT
25
+BEGIN:VEVENT
26
+DTSTART:20191015T160000Z
27
+DTEND:20191015T170000Z
28
+DTSTAMP:${timestamp}
29
+UID:09877h3j99q5hiesvk0iuvrjkk@google.com
30
+CREATED:20191015T144558Z
31
+DESCRIPTION:
32
+LAST-MODIFIED:20191015T144558Z
33
+LOCATION:
34
+SEQUENCE:0
35
+STATUS:CONFIRMED
36
+SUMMARY:Yooooooo!!!!
37
+TRANSP:OPAQUE
38
+END:VEVENT
39
+BEGIN:VEVENT
40
+DTSTART:20191015T070000Z
41
+DTEND:20191015T080000Z
42
+DTSTAMP:${timestamp}
43
+UID:78ujc53k2u18g1lp70iq1nb1rb@google.com
44
+CREATED:20191014T150436Z
45
+DESCRIPTION:
46
+LAST-MODIFIED:20191014T150914Z
47
+LOCATION:
48
+SEQUENCE:1
49
+STATUS:CONFIRMED
50
+SUMMARY:Coucou ├ža va?
51
+TRANSP:OPAQUE
52
+END:VEVENT
53
+BEGIN:VEVENT
54
+DTSTART:20191011T090000Z
55
+DTEND:20191011T100000Z
56
+DTSTAMP:${timestamp}
57
+UID:1iefkhg2k6gur1qlplapj6fdr6@google.com
58
+CREATED:20191010T152221Z
59
+DESCRIPTION:
60
+LAST-MODIFIED:20191010T152221Z
61
+LOCATION:
62
+SEQUENCE:0
63
+STATUS:CONFIRMED
64
+SUMMARY:Tommmmmmy!
65
+TRANSP:OPAQUE
66
+END:VEVENT
67
+BEGIN:VEVENT
68
+DTSTART:20191011T060000Z
69
+DTEND:20191011T070000Z
70
+DTSTAMP:${timestamp}
71
+UID:4jmc34a156fsb34mo2f4fs06hn@google.com
72
+CREATED:20191010T151912Z
73
+DESCRIPTION:
74
+LAST-MODIFIED:20191010T151912Z
75
+LOCATION:
76
+SEQUENCE:0
77
+STATUS:CONFIRMED
78
+SUMMARY:Hey Joe!!!
79
+TRANSP:OPAQUE
80
+END:VEVENT
81
+BEGIN:VEVENT
82
+DTSTART:20191010T080000Z
83
+DTEND:20191010T090000Z
84
+DTSTAMP:${timestamp}
85
+UID:1qp44n8mtn22hq99ek3hklnea5@google.com
86
+CREATED:20191009T092823Z
87
+DESCRIPTION:
88
+LAST-MODIFIED:20191009T161028Z
89
+LOCATION:
90
+SEQUENCE:4
91
+STATUS:CONFIRMED
92
+SUMMARY:Yoyoyoyo
93
+TRANSP:OPAQUE
94
+END:VEVENT
95
+BEGIN:VEVENT
96
+DTSTART:20191008T120000Z
97
+DTEND:20191008T130000Z
98
+DTSTAMP:${timestamp}
99
+UID:6q18c65jhm271jm49ug1nvj6n0@google.com
100
+CREATED:20191008T085239Z
101
+DESCRIPTION:
102
+LAST-MODIFIED:20191008T085239Z
103
+LOCATION:
104
+SEQUENCE:0
105
+STATUS:CONFIRMED
106
+SUMMARY:Dameeting
107
+TRANSP:OPAQUE
108
+END:VEVENT
109
+BEGIN:VEVENT
110
+DTSTART:20191009T094500Z
111
+DTEND:20191009T104500Z
112
+DTSTAMP:${timestamp}
113
+UID:3f6g306fn3nrr5kqqocsqju30r@google.com
114
+CREATED:20191008T085149Z
115
+DESCRIPTION:
116
+LAST-MODIFIED:20191008T085149Z
117
+LOCATION:
118
+SEQUENCE:0
119
+STATUS:CONFIRMED
120
+SUMMARY:Catzecat
121
+TRANSP:OPAQUE
122
+END:VEVENT
123
+BEGIN:VEVENT
124
+DTSTART:20191008T070000Z
125
+DTEND:20191008T080000Z
126
+DTSTAMP:${timestamp}
127
+UID:534prjat7htf7e40k0vdnsaf3j@google.com
128
+CREATED:20191007T124716Z
129
+DESCRIPTION:
130
+LAST-MODIFIED:20191008T085132Z
131
+LOCATION:Chez oim
132
+SEQUENCE:0
133
+STATUS:CONFIRMED
134
+SUMMARY:test004 - toto
135
+TRANSP:OPAQUE
136
+END:VEVENT
137
+BEGIN:VEVENT
138
+DTSTART:20191004T090000Z
139
+DTEND:20191004T100000Z
140
+DTSTAMP:${timestamp}
141
+UID:6ue7ke4ft12r3henamp8i3e77a@google.com
142
+CREATED:20191004T075421Z
143
+DESCRIPTION:
144
+LAST-MODIFIED:20191004T131048Z
145
+LOCATION:
146
+SEQUENCE:1
147
+STATUS:CONFIRMED
148
+SUMMARY:Test001TOTO
149
+TRANSP:OPAQUE
150
+END:VEVENT
151
+END:VCALENDAR
... ...
@@ -17,6 +17,10 @@
17 17
  */
18 18
 package net.bluemind.calendar.sync.tests;
19 19
 
20
+import java.io.File;
21
+import java.io.IOException;
22
+import java.nio.charset.StandardCharsets;
23
+import java.nio.file.Files;
20 24
 import java.time.LocalDateTime;
21 25
 import java.time.ZoneId;
22 26
 import java.time.ZoneOffset;
... ...
@@ -39,6 +43,7 @@ import org.junit.After;
39 43
 import org.junit.Assert;
40 44
 import org.junit.Before;
41 45
 import org.junit.Test;
46
+import org.slf4j.LoggerFactory;
42 47
 import org.vertx.java.core.AsyncResult;
43 48
 import org.vertx.java.core.Handler;
44 49
 import org.vertx.java.core.http.HttpServer;
... ...
@@ -77,6 +82,7 @@ public class CalendarSyncVerticleTests {
77 82
 	private static final String ICS_FILE_52_EVENTS = "resources/ics-test-52-events.ics";
78 83
 	private static final String ICS_FILE_BAD_CONTENT = "resources/ics-test-bad-content.ics";
79 84
 	private static final String ICS_FILE_BAD_CONTENT_2 = "resources/ics-test-bad-content-2.ics";
85
+	private static final String ICS_FILE_GOOGLE = "resources/ics-test-google.ics";
80 86
 	private static final String ICS_URL = "http://localhost:8091/ics";
81 87
 	private static final String CALENDAR_UID = "bluemind-test-calendar-id";
82 88
 	private static final String ETAG_1 = "W/\"etag-1-token\"";
... ...
@@ -169,7 +175,14 @@ public class CalendarSyncVerticleTests {
169 175
 		});
170 176
 
171 177
 		router.head("/ics", this::handleHead);
172
-		router.get("/ics", this::handleGet);
178
+		router.get("/ics", event -> {
179
+			try {
180
+				handleGet(event);
181
+			} catch (IOException e) {
182
+				LoggerFactory.getLogger(CalendarSyncVerticleTests.class).error("Unable to handle GET request.", e);
183
+				Assert.fail(e.getMessage());
184
+			}
185
+		});
173 186
 
174 187
 		icsHttpServer.requestHandler(router).listen(8091);
175 188
 	}
... ...
@@ -179,9 +192,29 @@ public class CalendarSyncVerticleTests {
179 192
 		event.response().setStatusCode(this.computeStatus(event)).end();
180 193
 	}
181 194
 
182
-	private void handleGet(final HttpServerRequest event) {
195
+	private void handleGet(final HttpServerRequest event) throws IOException {
183 196
 		this.nextResponse.headers.forEach((key, value) -> event.response().putHeader(key, value));
184
-		event.response().setStatusCode(this.computeStatus(event)).sendFile(this.nextResponse.returnedIcs).end();
197
+		event.response().setStatusCode(this.computeStatus(event))
198
+				.sendFile(this.handleIcsVariables(this.nextResponse.returnedIcs)).end();
199
+	}
200
+
201
+	private String handleIcsVariables(String icsFile) throws IOException {
202
+		String icsContent = new String(Files.readAllBytes(new File(icsFile).toPath()), StandardCharsets.UTF_8);
203
+		String timestampVariable = "${timestamp}";
204
+		if (icsContent.contains(timestampVariable)) {
205
+			LocalDateTime dateTime = LocalDateTime.now();
206
+			DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'");
207
+			String dateTimeString = dateTime.format(formatter);
208
+			icsContent = icsContent.replaceAll(Pattern.quote(timestampVariable), dateTimeString);
209
+			// create file to send
210
+			File newFile = File
211
+					.createTempFile("icsTest-" + this.getClass().getSimpleName() + System.currentTimeMillis(), ".ics");
212
+			Files.write(newFile.toPath(), icsContent.getBytes());
213
+			return newFile.getAbsolutePath();
214
+		} else {
215
+			// no variables, use the original file
216
+			return icsFile;
217
+		}
185 218
 	}
186 219
 
187 220
 	private int computeStatus(final HttpServerRequest event) {
... ...
@@ -543,7 +576,35 @@ public class CalendarSyncVerticleTests {
543 576
 		this.nextResponse.headers.put("Last-Modified", formattedDate);
544 577
 		this.nextResponse.headers.put(FORCE_STATUS_HEADER, "200");
545 578
 		this.checkSyncOkNoUpdates(1500);
579
+	}
580
+
581
+	/**
582
+	 * Check we do not update the ICS content when dealing with a google
583
+	 * calendar which changes at each request because of the DTSTAMP line.
584
+	 */
585
+	@Test
586
+	public void testLastModifiedGoogle() throws InterruptedException {
587
+		this.init();
588
+
589
+		final ZonedDateTime utcLastModified = LAST_MODIF_DATE
590
+				.withZoneSameInstant(ZoneId.ofOffset("UTC", ZoneOffset.UTC));
591
+		final String formattedDate = DateTimeFormatter.RFC_1123_DATE_TIME.format(utcLastModified);
592
+
593
+		// first sync replaces the initial empty ics
594
+		this.nextResponse = new PreparedResponse(ICS_FILE_GOOGLE);
595
+		this.nextResponse.headers.put("Last-Modified", formattedDate);
596
+		// RFC_1123 dates drop milliseconds, so add at least 1sec sleep to see
597
+		// differences in times comparisons
598
+		this.checkSyncOkWithChanges(1500);
599
+
600
+		// other syncs are done but calendars not updated
601
+		// should also check the ICS parsing has not be done, but there is no
602
+		// way to do that currently
603
+		this.nextResponse = new PreparedResponse(ICS_FILE_GOOGLE);
604
+		this.checkSyncOkNoUpdates(1500);
546 605
 
606
+		this.nextResponse = new PreparedResponse(ICS_FILE_GOOGLE);
607
+		this.checkSyncOkNoUpdates(1500);
547 608
 	}
548 609
 
549 610
 	/**
... ...
@@ -18,5 +18,6 @@ Require-Bundle: net.bluemind.core.container.sync,
18 18
  net.bluemind.core.task.service,
19 19
  bcprov,
20 20
  net.bluemind.icalendar.parser,
21
- net.bluemind.domain.api
21
+ net.bluemind.domain.api,
22
+ net.bluemind.lib.ical4j
22 23
 Export-Package: net.bluemind.calendar.sync
... ...
@@ -19,12 +19,11 @@
19 19
 package net.bluemind.calendar.sync;
20 20
 
21 21
 import java.io.BufferedReader;
22
+import java.io.IOException;
22 23
 import java.io.InputStreamReader;
23 24
 import java.net.HttpURLConnection;
24 25
 import java.net.MalformedURLException;
25 26
 import java.net.URL;
26
-import java.security.InvalidAlgorithmParameterException;
27
-import java.security.Security;
28 27
 import java.sql.SQLException;
29 28
 import java.time.Instant;
30 29
 import java.time.ZoneId;
... ...
@@ -37,11 +36,11 @@ import java.util.Optional;
37 36
 import java.util.concurrent.TimeUnit;
38 37
 import java.util.regex.Matcher;
39 38
 import java.util.regex.Pattern;
39
+import java.util.stream.Collectors;
40 40
 
41 41
 import javax.net.ssl.HttpsURLConnection;
42 42
 import javax.net.ssl.SSLContext;
43 43
 
44
-import org.bouncycastle.jce.provider.BouncyCastleProvider;
45 44
 import org.slf4j.Logger;
46 45
 import org.slf4j.LoggerFactory;
47 46
 
... ...
@@ -67,9 +66,14 @@ import net.bluemind.core.task.service.TaskUtils;
67 66
 import net.bluemind.core.utils.JsonUtils;
68 67
 import net.bluemind.domain.api.IDomainSettings;
69 68
 import net.bluemind.utils.Trust;
69
+import net.fortuna.ical4j.data.UnfoldingReader;
70 70
 
71 71
 public class CalendarContainerSync implements ISyncableContainer {
72 72
 
73
+	private static final String HTTP_PREFIX = "http://";
74
+
75
+	private static final String HTTPS_PREFIX = "https://";
76
+
73 77
 	public static final String DOMAIN_SETTING_MIN_DELAY_KEY = "domain.setting.calendar.sync.min.delay";
74 78
 
75 79
 	/** The default delay between two synchronizations of a same calendar. */
... ...
@@ -82,12 +86,12 @@ public class CalendarContainerSync implements ISyncableContainer {
82 86
 	private static final String SYNC_TOKEN_KEY_MD5_HASH = "md5";
83 87
 
84 88
 	private class SyncData {
85
-		public long timestamp;
86
-		public String modifiedSince;
87
-		public String etag;
88
-		public String ics;
89
-		public String md5Hash;
90
-		public long nextSync;
89
+		private long timestamp;
90
+		private String modifiedSince;
91
+		private String etag;
92
+		private String ics;
93
+		private String md5Hash;
94
+		private long nextSync;
91 95
 	}
92 96
 
93 97
 	private static final Logger logger = LoggerFactory.getLogger(CalendarContainerSync.class);
... ...
@@ -113,7 +117,7 @@ public class CalendarContainerSync implements ISyncableContainer {
113 117
 	}
114 118
 
115 119
 	@Override
116
-	public ContainerSyncResult sync(Map<String, String> syncTokens, IServerTaskMonitor monitor) throws ServerFault {
120
+	public ContainerSyncResult sync(Map<String, String> syncTokens, IServerTaskMonitor monitor) {
117 121
 		monitor.begin(3, null);
118 122
 		try {
119 123
 			if (this.calendarSettings != null && this.calendarSettings.containsKey("icsUrl")) {
... ...
@@ -123,16 +127,15 @@ public class CalendarContainerSync implements ISyncableContainer {
123 127
 				final String md5Hash = syncTokens.get(SYNC_TOKEN_KEY_MD5_HASH);
124 128
 				final SyncData syncData = fetchData(modifiedSince, etag, md5Hash, this.calendarSettings.get("icsUrl"));
125 129
 				logger.info("Sync calendar {} (uid:{})", container.name, container.uid);
126
-				final ContainerSyncResult ret = syncCalendar(syncData, container.name, monitor.subWork(2));
127
-				logger.info(String.format("%s calendar sync done. created: %d, updated: %d, deleted: %d",
128
-						container.name, ret.added, ret.updated, ret.removed));
130
+				final ContainerSyncResult ret = syncCalendar(syncData);
131
+				logger.info("{} calendar sync done. created: {}, updated: {}, deleted: {}", container.name, ret.added,
132
+						ret.updated, ret.removed);
129 133
 				return ret;
130 134
 			}
131 135
 
132 136
 			logger.error("Fail to fetch container settings for calendar {} (uid: {})", container.name, container.uid);
133 137
 			return null;
134 138
 		} catch (Exception e) {
135
-			logger.error(e.getMessage(), e);
136 139
 			throw new ServerFault(e);
137 140
 		}
138 141
 	}
... ...
@@ -144,8 +147,7 @@ public class CalendarContainerSync implements ISyncableContainer {
144 147
 		return DEFAULT_NEXT_SYNC_DELAY;
145 148
 	}
146 149
 
147
-	private ContainerSyncResult syncCalendar(SyncData data, String calendarName, IServerTaskMonitor monitor)
148
-			throws ServerFault {
150
+	private ContainerSyncResult syncCalendar(SyncData data) {
149 151
 		ContainerSyncResult ret = new ContainerSyncResult();
150 152
 		ret.status = new ContainerSyncStatus();
151 153
 		ret.status.nextSync = Math.max(data.timestamp + this.nextSyncDelay(), data.nextSync);
... ...
@@ -158,10 +160,10 @@ public class CalendarContainerSync implements ISyncableContainer {
158 160
 			try {
159 161
 				IVEvent service = context.provider().instance(IVEvent.class, container.uid);
160 162
 
161
-				TaskRef syncIcs = service.syncIcs(GenericStream.simpleValue(data.ics, s -> s.getBytes()));
163
+				TaskRef syncIcs = service.syncIcs(GenericStream.simpleValue(data.ics, this::getBytes));
162 164
 				TaskStatus status = TaskUtils.wait(context.provider(), syncIcs);
163 165
 
164
-				System.err.println(status.result);
166
+				logger.info("Sync ICS result: {}", status.result);
165 167
 
166 168
 				ContainerUpdatesResult result = JsonUtils.read(status.result, ContainerUpdatesResult.class);
167 169
 
... ...
@@ -178,6 +180,10 @@ public class CalendarContainerSync implements ISyncableContainer {
178 180
 		return ret;
179 181
 	}
180 182
 
183
+	private byte[] getBytes(String string) {
184
+		return string.getBytes();
185
+	}
186
+
181 187
 	private SyncData fetchData(String modifiedSince, String etag, String md5Hash, String icsUrl) throws Exception {
182 188
 		SyncData ret = new SyncData();
183 189
 		ret.timestamp = System.currentTimeMillis();
... ...
@@ -187,7 +193,7 @@ public class CalendarContainerSync implements ISyncableContainer {
187 193
 		final boolean originalIcsUrlHasWebcalProtocol;
188 194
 		if (icsUrl.startsWith("webcal://")) {
189 195
 			originalIcsUrlHasWebcalProtocol = true;
190
-			icsUrl = icsUrl.replace("webcal://", "http://");
196
+			icsUrl = icsUrl.replace("webcal://", HTTP_PREFIX);
191 197
 		} else {
192 198
 			originalIcsUrlHasWebcalProtocol = false;
193 199
 		}
... ...
@@ -229,9 +235,9 @@ public class CalendarContainerSync implements ISyncableContainer {
229 235
 				// update
230 236
 				return ret;
231 237
 			}
232
-		} else if (originalIcsUrlHasWebcalProtocol && icsUrl.startsWith("http://")) {
238
+		} else if (originalIcsUrlHasWebcalProtocol && icsUrl.startsWith(HTTP_PREFIX)) {
233 239
 			// try now with https
234
-			return this.fetchData(modifiedSince, etag, md5Hash, icsUrl.replace("http://", "https://"));
240
+			return this.fetchData(modifiedSince, etag, md5Hash, icsUrl.replace(HTTP_PREFIX, HTTPS_PREFIX));
235 241
 		} else {
236 242
 			return ret;
237 243
 		}
... ...
@@ -249,27 +255,11 @@ public class CalendarContainerSync implements ISyncableContainer {
249 255
 		return response.status == 304 || (response.lastModified > 0 && response.lastModified <= lastModification);
250 256
 	}
251 257
 
252
-	private ResponseData requestIcs(String icsUrl, String method, String syncToken, String etag) throws Exception {
253
-		try {
254
-			return call(icsUrl, method, syncToken, etag);
255
-		} catch (InvalidAlgorithmParameterException e) {
256
-			BouncyCastleProvider bc = new BouncyCastleProvider();
257
-			boolean installed = Security.getProvider(bc.getName()) != null;
258
-			try {
259
-				Security.removeProvider(bc.getName());
260
-				Security.insertProviderAt(bc, 1);
261
-				return call(icsUrl, method, syncToken, etag);
262
-			} finally {
263
-				Security.removeProvider(bc.getName());
264
-				if (installed) {
265
-					// add provider to the end of the provider's list
266
-					Security.addProvider(bc);
267
-				}
268
-			}
269
-		}
258
+	private ResponseData requestIcs(String icsUrl, String method, String syncToken, String etag) throws IOException {
259
+		return call(icsUrl, method, syncToken, etag);
270 260
 	}
271 261
 
272
-	private ResponseData call(String icsUrl, String method, String modifiedSince, String etag) throws Exception {
262
+	private ResponseData call(String icsUrl, String method, String modifiedSince, String etag) throws IOException {
273 263
 		URL url = new URL(icsUrl);
274 264
 
275 265
 		HttpURLConnection conn = null;
... ...
@@ -301,6 +291,7 @@ public class CalendarContainerSync implements ISyncableContainer {
301 291
 							.format(modifiedSinceDate);
302 292
 					conn.setRequestProperty("If-Modified-Since", formattedModifiedSince);
303 293
 				} catch (NumberFormatException e) {
294
+					// nothing to do
304 295
 				}
305 296
 			}
306 297
 
... ...
@@ -312,14 +303,7 @@ public class CalendarContainerSync implements ISyncableContainer {
312 303
 			int status = conn.getResponseCode();
313 304
 			String data = null;
314 305
 			if (method.equals("GET")) {
315
-				StringBuilder ics = new StringBuilder();
316
-				try (BufferedReader rd = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
317
-					String line;
318
-					while ((line = rd.readLine()) != null) {
319
-						ics.append(line).append("\n");
320
-					}
321
-				}
322
-				data = ics.toString();
306
+				data = this.fetchAndSanitizeIcs(conn);
323 307
 			}
324 308
 
325 309
 			// Cache-Control/max-age has precedence over Expires
... ...
@@ -337,8 +321,20 @@ public class CalendarContainerSync implements ISyncableContainer {
337 321
 
338 322
 	}
339 323
 
324
+	private String fetchAndSanitizeIcs(HttpURLConnection httpURLConnection) throws IOException {
325
+		try (BufferedReader reader = new BufferedReader(
326
+				new UnfoldingReader(new InputStreamReader(httpURLConnection.getInputStream()), 8192, true), 8192)) {
327
+			return reader.lines().filter(this::isIcsLineValid).collect(Collectors.joining("\n"));
328
+		}
329
+	}
330
+
331
+	private boolean isIcsLineValid(String icsLine) {
332
+		// invalidate line with timestamp in order to avoid unnecessary update
333
+		return !icsLine.startsWith("DTSTAMP:");
334
+	}
335
+
340 336
 	/** @see RFC-2183 https://tools.ietf.org/html/rfc2183 */
341
-	private final static Pattern CONTENT_DISPO_MODIF_DATE_PATTERN = Pattern.compile("modification-date=\"(.*?)\";?");
337
+	private static final Pattern CONTENT_DISPO_MODIF_DATE_PATTERN = Pattern.compile("modification-date=\"(.*?)\";?");
342 338
 
343 339
 	private long extractModificationDate(final HttpURLConnection connection) {
344 340
 		final String contentDispo = connection.getHeaderField("Content-Disposition");
... ...
@@ -377,7 +373,8 @@ public class CalendarContainerSync implements ISyncableContainer {
377 373
 	private long extractCacheControlMaxAge(final HttpURLConnection connection) {
378 374
 		// retrieve Cache-Control directives and extract max-age if present
379 375
 		final Optional<String> cacheControlWithMaxAge = connection.getHeaderFields().entrySet().stream()
380
-				.filter(entry -> entry.getKey() != null ? entry.getKey().equalsIgnoreCase("Cache-Control") : false)
376
+				.filter(entry -> entry.getKey() != null ? entry.getKey().equalsIgnoreCase("Cache-Control")
377
+						: Boolean.FALSE)
381 378
 				.flatMap(entry -> entry.getValue().stream()).filter(v -> MAX_AGE_PATTERN.matcher(v).find()).findFirst();
382 379
 
383 380
 		if (cacheControlWithMaxAge.isPresent()) {
... ...
@@ -61,8 +61,7 @@ import net.bluemind.lib.vertx.IVerticleFactory;
61 61
  * <li><i>Waiting</i> means: the number of days since the last
62 62
  * synchronization</li>
63 63
  * <li>When {@link #syncErrorLimit()} synchronization errors is reached, a
64
- * calendar is excluded from the synchronization mechanism FIXME how to
65
- * recover?</li>
64
+ * calendar is excluded from the synchronization mechanism</li>
66 65
  * <li>Each synchronization of a same calendar is delayed by
67 66
  * {@link CalendarContainerSync#nextSyncDelay()} milliseconds</li>
68 67
  * </ul>