Resources system rewrite + new "--save-config DIR" option
[jvcard.git] / src / be / nikiroo / jvcard / remote / Sync.java
1 package be.nikiroo.jvcard.remote;
2
3 import java.io.BufferedReader;
4 import java.io.BufferedWriter;
5 import java.io.File;
6 import java.io.FileInputStream;
7 import java.io.FileNotFoundException;
8 import java.io.FileOutputStream;
9 import java.io.IOException;
10 import java.io.InputStreamReader;
11 import java.io.OutputStreamWriter;
12 import java.net.Socket;
13 import java.net.UnknownHostException;
14 import java.security.InvalidParameterException;
15 import java.util.HashMap;
16 import java.util.LinkedList;
17 import java.util.List;
18 import java.util.Map;
19
20 import be.nikiroo.jvcard.Card;
21 import be.nikiroo.jvcard.Contact;
22 import be.nikiroo.jvcard.Data;
23 import be.nikiroo.jvcard.launcher.CardResult;
24 import be.nikiroo.jvcard.launcher.CardResult.MergeCallback;
25 import be.nikiroo.jvcard.parsers.Format;
26 import be.nikiroo.jvcard.parsers.Vcard21Parser;
27 import be.nikiroo.jvcard.resources.StringUtils;
28 import be.nikiroo.jvcard.resources.bundles.RemoteBundle;
29 import be.nikiroo.jvcard.resources.enums.RemotingOption;
30
31 /**
32 * This class will synchronise {@link Card}s between a local instance an a
33 * remote jVCard server.
34 *
35 * @author niki
36 *
37 */
38 public class Sync {
39 /** The time in ms after which we declare that 2 timestamps are different */
40 static private final int GRACE_TIME = 2001;
41
42 /** Directory where to store local cache of remote {@link Card}s. */
43 static private File cacheDir;
44
45 /**
46 * Directory where to store cache of remote {@link Card}s without
47 * modifications since the last synchronisation.
48 */
49 static private File cacheDirOrig;
50 /** Directory where to store timestamps for files in cacheDirOrig */
51 static private File cacheDirOrigTS;
52
53 static private boolean autoSync;
54 private String host;
55 private int port;
56
57 /** Resource name on the remote server. */
58 private String name;
59
60 /**
61 * Create a new {@link Sync} object, ready to operate for the given resource
62 * on the given server.
63 *
64 * <p>
65 * Note that the format used is the standard "host:port_number/file", with
66 * an optional <tt>jvcard://</tt> prefix.
67 * </p>
68 *
69 * <p>
70 * E.g.: <tt>jvcard://localhost:4444/family.vcf</tt>
71 * </p>
72 *
73 * @param url
74 * the server and port to contact, optionally prefixed with
75 * <tt>jvcard://</tt>
76 *
77 * @throws InvalidParameterException
78 * if the remote configuration file <tt>remote.properties</tt>
79 * cannot be accessed or if the cache directory cannot be used
80 */
81 public Sync(String url) {
82 if (cacheDir == null) {
83 config();
84 }
85
86 try {
87 url = url.replace("jvcard://", "");
88 int indexSl = url.indexOf('/');
89 this.name = url.substring(indexSl + 1);
90 url = url.substring(0, indexSl);
91 this.host = url.split("\\:")[0];
92 this.port = Integer.parseInt(url.split("\\:")[1]);
93 } catch (Exception e) {
94 throw new InvalidParameterException(
95 "the given parameter was not a valid HOST:PORT value: "
96 + url);
97 }
98 }
99
100 /**
101 * Create a new {@link Sync} object, ready to operate on the given server.
102 *
103 *
104 * @param host
105 * the server to contact
106 * @param port
107 * the port to use
108 * @param name
109 * the resource name to synchronise to
110 */
111 public Sync(String host, int port, String name) {
112 this.host = host;
113 this.port = port;
114 this.name = name;
115 }
116
117 /**
118 * Check if the remote server already know about this resource.
119 *
120 * @return TRUE if it is possible to contact the remote server and that this
121 * server has the resource available
122 */
123 public boolean isAvailable() {
124 try {
125 SimpleSocket s = new SimpleSocket(new Socket(host, port),
126 "check avail client");
127 s.open(true);
128 s.sendCommand(Command.LIST_CARD);
129 List<String> timestampedFiles = s.receiveBlock();
130 s.close();
131
132 for (String timestampedFile : timestampedFiles) {
133 String file = timestampedFile.substring(StringUtils.fromTime(0)
134 .length() + 1);
135 if (file.equals(name)) {
136 return true;
137 }
138 }
139 } catch (Exception e) {
140 }
141
142 return false;
143 }
144
145 /**
146 * Synchronise the current resource if needed, then return the locally
147 * cached version of said resource.
148 *
149 * <p>
150 * A synchronisation is deemed necessary if one of the following is true:
151 * <ul>
152 * <li><tt>force</tt> is TRUE</li>
153 * <li><tt>CLIENT_AUTO_SYNC</tt> is TRUE in the configuration file</li>
154 * <li>the {@link Card} exists locally but not on the remote server</li>
155 * <li>the {@link Card} exists on the remote server but not locally</li>
156 * </ul>
157 * </p>
158 *
159 * @param force
160 * force the synchronisation to occur
161 * @param callback
162 * the {@link MergeCallback} to call in case of conflict
163 *
164 * @return the synchronised (or not) {@link Card}
165 *
166 * @throws UnknownHostException
167 * in case of server name resolution failure
168 * @throws IOException
169 * in case of IO error
170 */
171 public CardResult sync(boolean force, MergeCallback callback)
172 throws UnknownHostException, IOException {
173 long tsOriginal = getLastModified();
174
175 Card local = new Card(getCache(cacheDir), Format.VCard21);
176
177 // do NOT update unless we are in autoSync or forced mode or we don't
178 // have the file on cache
179 if (!autoSync && !force && tsOriginal != -1) {
180 return new CardResult(local, true, false, false);
181 }
182
183 SimpleSocket s = new SimpleSocket(new Socket(host, port), "sync client");
184
185 // get the server time stamp
186 long tsServer = -1;
187 boolean serverChanges = false;
188 try {
189 s.open(true);
190 s.sendCommand(Command.LIST_CARD);
191 List<String> timestampedFiles = s.receiveBlock();
192
193 for (String timestampedFile : timestampedFiles) {
194 String file = timestampedFile.substring(StringUtils.fromTime(0)
195 .length() + 1);
196 if (file.equals(name)) {
197 tsServer = StringUtils.toTime(timestampedFile.substring(0,
198 StringUtils.fromTime(0).length()));
199 break;
200 }
201 }
202
203 // Error cases:
204 // - file not present neither in cache nor on server
205 // - remote < previous
206 if ((tsServer == -1 && tsOriginal == -1)
207 || (tsServer != -1 && tsOriginal != -1 && ((tsOriginal - tsServer) > GRACE_TIME))) {
208 throw new IOException(
209 "The timestamps between server and client are invalid");
210 }
211
212 // Check changes
213 serverChanges = (tsServer - tsOriginal) > GRACE_TIME;
214 boolean localChanges = false;
215 Card original = null;
216 if (tsOriginal != -1) {
217 original = new Card(getCache(cacheDirOrig), Format.VCard21);
218 localChanges = !local.isEquals(original, true);
219 }
220
221 Command action = null;
222
223 // Sync to server if:
224 if (localChanges) {
225 action = Command.PUT_CARD;
226 }
227
228 // Sync from server if:
229 if (serverChanges) {
230 action = Command.HASH_CONTACT;
231 }
232
233 // Sync from/to server if
234 if (serverChanges && localChanges) {
235 action = Command.HELP;
236 }
237
238 // POST the whole file if:
239 if (tsServer == -1) {
240 action = Command.POST_CARD;
241 }
242
243 // GET the whole file if:
244 if (tsOriginal == -1) {
245 action = Command.GET_CARD;
246 }
247
248 System.err.println("remote: " + (tsServer / 1000) % 1000 + " ("
249 + tsServer + ")");
250 System.err.println("previous: " + (tsOriginal / 1000) % 1000 + " ("
251 + tsOriginal + ")");
252 System.err.println("local changes: " + localChanges);
253 System.err.println("server changes: " + serverChanges);
254 System.err.println("choosen action: " + action);
255
256 if (action != null) {
257 s.sendCommand(Command.SELECT, name);
258 if (tsServer != StringUtils.toTime(s.receiveLine())) {
259 System.err.println("DEBUG: it changed. retry.");
260 s.sendCommand(Command.SELECT);
261 s.close();
262 return sync(force, callback);
263 }
264
265 switch (action) {
266 case GET_CARD: {
267 s.sendCommand(Command.GET_CARD);
268 List<String> data = s.receiveBlock();
269 setLastModified(data.remove(0));
270 local.replaceListContent(Vcard21Parser.parseContact(data));
271
272 if (local.isDirty())
273 local.save();
274 local.saveAs(getCache(cacheDirOrig), Format.VCard21);
275 break;
276 }
277 case POST_CARD: {
278 s.sendCommand(Command.POST_CARD);
279 s.sendBlock(Vcard21Parser.toStrings(local));
280 local.saveAs(getCache(cacheDirOrig), Format.VCard21);
281 setLastModified(s.receiveLine());
282 break;
283 }
284 case PUT_CARD: {
285 String serverLastModifTime = updateToServer(s, original,
286 local);
287
288 local.saveAs(getCache(cacheDirOrig), Format.VCard21);
289
290 setLastModified(serverLastModifTime);
291 break;
292 }
293 case HASH_CONTACT: {
294 String serverLastModifTime = updateFromServer(s, local);
295
296 local.save();
297 local.saveAs(getCache(cacheDirOrig), Format.VCard21);
298
299 setLastModified(serverLastModifTime);
300 break;
301 }
302 case HELP: {
303 // note: we are holding the server here, so it could throw
304 // us away if we take too long
305
306 // TODO: check if those files are deleted
307 File mergeF = File.createTempFile("contact-merge", ".vcf");
308 File serverF = File
309 .createTempFile("contact-server", ".vcf");
310 original.saveAs(serverF, Format.VCard21);
311
312 Card server = new Card(serverF, Format.VCard21);
313 updateFromServer(s, server);
314
315 // Do an auto sync
316 server.saveAs(mergeF, Format.VCard21);
317 Card merge = new Card(mergeF, Format.VCard21);
318 List<Contact> added = new LinkedList<Contact>();
319 List<Contact> removed = new LinkedList<Contact>();
320 original.compare(local, added, removed, removed, added);
321 for (Contact c : removed)
322 merge.getById(c.getId()).delete();
323 for (Contact c : added)
324 merge.add(Vcard21Parser.clone(c));
325
326 merge.save();
327
328 // defer to client:
329 if (callback == null) {
330 throw new IOException(
331 "Conflicting changes detected and merge operation not allowed");
332 }
333
334 merge = callback.merge(original, local, server, merge);
335 if (merge == null) {
336 throw new IOException(
337 "Conflicting changes detected and merge operation cancelled");
338 }
339
340 // TODO: something like:
341 // String serverLastModifTime = updateToServer(s, original,
342 // merge);
343 // ...but without starting with original since it is not
344 // true here
345 s.sendCommand(Command.POST_CARD);
346 s.sendBlock(Vcard21Parser.toStrings(merge));
347 String serverLastModifTime = s.receiveLine();
348 //
349
350 merge.saveAs(getCache(cacheDir), Format.VCard21);
351 merge.saveAs(getCache(cacheDirOrig), Format.VCard21);
352
353 setLastModified(serverLastModifTime);
354
355 local = merge;
356
357 break;
358 }
359 default:
360 // will not happen
361 break;
362 }
363
364 s.sendCommand(Command.SELECT);
365 }
366 } catch (IOException e) {
367 return new CardResult(e);
368 } catch (Exception e) {
369 return new CardResult(new IOException(e));
370 } finally {
371 s.close();
372 }
373
374 return new CardResult(local, true, true, serverChanges);
375 }
376
377 /**
378 * Will update the currently selected {@link Card} on the remote server to
379 * be in the same state as <tt>local</tt>, assuming the server is currently
380 * in <tt>original</tt> state.
381 *
382 * @param s
383 * the {@link SimpleSocket} to work on, which <b>MUST</b> be in
384 * SELECT mode
385 * @param original
386 * the original {@link Card} as it was before the client made
387 * changes to it
388 * @param local
389 * the {@link Card} to which state we want the server in
390 *
391 * @return the last modified time from the remote server (which is basically
392 * "now")
393 *
394 * @throws IOException
395 * in case of IO error
396 */
397 private String updateToServer(SimpleSocket s, Card original, Card local)
398 throws IOException {
399 List<Contact> added = new LinkedList<Contact>();
400 List<Contact> removed = new LinkedList<Contact>();
401 List<Contact> from = new LinkedList<Contact>();
402 List<Contact> to = new LinkedList<Contact>();
403 original.compare(local, added, removed, from, to);
404
405 s.sendCommand(Command.PUT_CARD);
406
407 for (Contact c : removed) {
408 s.sendCommand(Command.DELETE_CONTACT, c.getId());
409 }
410 for (Contact c : added) {
411 s.sendCommand(Command.POST_CONTACT, c.getId());
412 s.sendBlock(Vcard21Parser.toStrings(c, -1));
413 }
414 if (from.size() > 0) {
415 for (int index = 0; index < from.size(); index++) {
416 Contact f = from.get(index);
417 Contact t = to.get(index);
418
419 List<Data> subadded = new LinkedList<Data>();
420 List<Data> subremoved = new LinkedList<Data>();
421 f.compare(t, subadded, subremoved, subremoved, subadded);
422 s.sendCommand(Command.PUT_CONTACT, f.getId());
423 for (Data d : subremoved) {
424 s.sendCommand(Command.DELETE_DATA, d.getContentState(true));
425 }
426 for (Data d : subadded) {
427 s.sendCommand(Command.POST_DATA, d.getContentState(true));
428 s.sendBlock(Vcard21Parser.toStrings(d));
429 }
430 s.sendCommand(Command.PUT_CONTACT);
431 }
432 }
433
434 s.sendCommand(Command.PUT_CARD);
435
436 return s.receiveLine();
437 }
438
439 /**
440 * Will update the given {@link Card} object (not {@link File}) to the
441 * currently selected {@link Card} on the remote server.
442 *
443 * @param s
444 * the {@link SimpleSocket} to work on, which <b>MUST</b> be in
445 * SELECT mode
446 * @param local
447 * the {@link Card} to update
448 *
449 * @return the last modified time from the remote server
450 *
451 * @throws IOException
452 * in case of IO error
453 */
454 private String updateFromServer(SimpleSocket s, Card local)
455 throws IOException {
456 s.sendCommand(Command.PUT_CARD);
457
458 s.sendCommand(Command.LIST_CONTACT);
459 Map<String, String> remote = new HashMap<String, String>();
460 for (String line : s.receiveBlock()) {
461 int indexSp = line.indexOf(" ");
462 String hash = line.substring(0, indexSp);
463 String uid = line.substring(indexSp + 1);
464
465 remote.put(uid, hash);
466 }
467
468 List<Contact> deleted = new LinkedList<Contact>();
469 List<Contact> changed = new LinkedList<Contact>();
470 List<String> added = new LinkedList<String>();
471
472 for (Contact c : local) {
473 String hash = remote.get(c.getId());
474 if (hash == null) {
475 deleted.add(c);
476 } else if (!hash.equals(c.getContentState(true))) {
477 changed.add(c);
478 }
479 }
480
481 for (String uid : remote.keySet()) {
482 if (local.getById(uid) == null)
483 added.add(uid);
484 }
485
486 // process:
487
488 for (Contact c : deleted) {
489 c.delete();
490 }
491
492 for (String uid : added) {
493 s.sendCommand(Command.GET_CONTACT, uid);
494 for (Contact cc : Vcard21Parser.parseContact(s.receiveBlock())) {
495 local.add(cc);
496 }
497 }
498
499 for (Contact c : changed) {
500 c.delete();
501 s.sendCommand(Command.GET_CONTACT, c.getId());
502 for (Contact cc : Vcard21Parser.parseContact(s.receiveBlock())) {
503 local.add(cc);
504 }
505 }
506
507 s.sendCommand(Command.PUT_CARD);
508
509 return s.receiveLine();
510 }
511
512 /**
513 * Return the requested cache for the current resource.
514 *
515 * @param dir
516 * the cache to use
517 *
518 * @return the cached {@link File}
519 */
520 private File getCache(File dir) {
521 return new File(dir.getPath() + File.separator + name);
522 }
523
524 /**
525 * Return the cached {@link File} corresponding to the current resource.
526 *
527 * @return the cached {@link File}
528 */
529 public File getCache() {
530 return new File(cacheDir.getPath() + File.separator + name);
531 }
532
533 /**
534 * Get the last modified date of the current resource's original cached
535 * file, that is, the time the server reported as the "last modified time"
536 * when this resource was transfered.
537 *
538 * @return the last modified time from the server back when this resource
539 * was transfered
540 */
541 public long getLastModified() {
542 try {
543 BufferedReader in = new BufferedReader(new InputStreamReader(
544 new FileInputStream(cacheDirOrigTS.getPath()
545 + File.separator + name)));
546 String line = in.readLine();
547 in.close();
548
549 return StringUtils.toTime(line);
550 } catch (FileNotFoundException e) {
551 return -1;
552 } catch (Exception e) {
553 return -1;
554 }
555 }
556
557 /**
558 * Set the last modified date of the current resource's original cached
559 * file, that is, the time the server reported as the "last modified time"
560 * when this resource was transfered.
561 *
562 * @param time
563 * the last modified time from the server back when this resource
564 * was transfered
565 */
566 public void setLastModified(String time) {
567 try {
568 BufferedWriter out = new BufferedWriter(new OutputStreamWriter(
569 new FileOutputStream(cacheDirOrigTS.getPath()
570 + File.separator + name)));
571 out.append(time);
572 out.newLine();
573 out.close();
574 } catch (FileNotFoundException e) {
575 e.printStackTrace();
576 } catch (IOException e) {
577 e.printStackTrace();
578 }
579 }
580
581 /**
582 * Configure the synchronisation mechanism (cache, auto update...).
583 *
584 * @throws InvalidParameterException
585 * if the remote configuration file <tt>remote.properties</tt>
586 * cannot be accessed or if the cache directory cannot be used
587 */
588 static private void config() {
589 String dir = null;
590 RemoteBundle bundle = new RemoteBundle();
591
592 try {
593 dir = bundle.getString(RemotingOption.CLIENT_CACHE_DIR);
594
595 cacheDir = new File(dir + File.separator + "current");
596 cacheDir.mkdir();
597 cacheDirOrig = new File(dir + File.separator + "original");
598 cacheDirOrig.mkdir();
599 cacheDirOrigTS = new File(dir + File.separator + "timestamps");
600 cacheDirOrigTS.mkdir();
601
602 if (!cacheDir.exists() || !cacheDirOrig.exists()) {
603 throw new IOException("Cannot open or create cache store at: "
604 + dir);
605 }
606
607 autoSync = bundle
608 .getBoolean(RemotingOption.CLIENT_AUTO_SYNC, false);
609 } catch (Exception e) {
610 throw new InvalidParameterException(
611 "Cannot open or create cache store at: " + dir);
612 }
613 }
614 }