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