Remote support ~complete (need more tests at least)
[jvcard.git] / src / be / nikiroo / jvcard / remote / Sync.java
CommitLineData
a046fa49
NR
1package be.nikiroo.jvcard.remote;
2
3import java.io.BufferedReader;
4import java.io.BufferedWriter;
5import java.io.File;
6import java.io.FileInputStream;
7import java.io.FileNotFoundException;
8import java.io.FileOutputStream;
9import java.io.IOException;
10import java.io.InputStreamReader;
11import java.io.OutputStreamWriter;
12import java.net.Socket;
13import java.net.UnknownHostException;
14import java.security.InvalidParameterException;
845fb1d7 15import java.util.HashMap;
0b6140e4 16import java.util.LinkedList;
a046fa49 17import java.util.List;
845fb1d7 18import java.util.Map;
a046fa49
NR
19import java.util.MissingResourceException;
20import java.util.ResourceBundle;
21
22import be.nikiroo.jvcard.Card;
0b6140e4
NR
23import be.nikiroo.jvcard.Contact;
24import be.nikiroo.jvcard.Data;
5ad0e17e
NR
25import be.nikiroo.jvcard.launcher.CardResult;
26import be.nikiroo.jvcard.launcher.CardResult.MergeCallback;
a046fa49 27import be.nikiroo.jvcard.parsers.Format;
0b6140e4 28import be.nikiroo.jvcard.parsers.Vcard21Parser;
a046fa49 29import be.nikiroo.jvcard.resources.Bundles;
7da41ecd 30import be.nikiroo.jvcard.resources.StringUtils;
a046fa49
NR
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 */
39public class Sync {
40 /** The time in ms after which we declare that 2 timestamps are different */
02b341aa 41 static private final int GRACE_TIME = 2001;
a046fa49
NR
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 /**
02b341aa 119 * Check if the remote server already know about this resource.
a046fa49
NR
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);
845fb1d7 129 s.sendCommand(Command.LIST_CARD);
a046fa49
NR
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
02b341aa
NR
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
5ad0e17e
NR
162 * @param callback
163 * the {@link MergeCallback} to call in case of conflict
02b341aa
NR
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 */
5ad0e17e
NR
172 public CardResult sync(boolean force, MergeCallback callback)
173 throws UnknownHostException, IOException {
a046fa49
NR
174 long tsOriginal = getLastModified();
175
845fb1d7 176 Card local = new Card(getCache(cacheDir), Format.VCard21);
845fb1d7 177
a046fa49
NR
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) {
5ad0e17e 181 return new CardResult(local, true, false, false);
a046fa49
NR
182 }
183
184 SimpleSocket s = new SimpleSocket(new Socket(host, port), "sync client");
185
186 // get the server time stamp
187 long tsServer = -1;
5ad0e17e 188 boolean serverChanges = false;
a046fa49
NR
189 try {
190 s.open(true);
845fb1d7 191 s.sendCommand(Command.LIST_CARD);
a046fa49
NR
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 }
a046fa49 203
4298276a 204 // Error cases:
02b341aa 205 // - file not present neither in cache nor on server
4298276a
NR
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 }
a046fa49 212
4298276a 213 // Check changes
5ad0e17e 214 serverChanges = (tsServer - tsOriginal) > GRACE_TIME;
4298276a 215 boolean localChanges = false;
4298276a
NR
216 Card original = null;
217 if (tsOriginal != -1) {
4298276a
NR
218 original = new Card(getCache(cacheDirOrig), Format.VCard21);
219 localChanges = !local.isEquals(original, true);
220 }
a046fa49 221
845fb1d7 222 Command action = null;
a046fa49 223
4298276a
NR
224 // Sync to server if:
225 if (localChanges) {
845fb1d7 226 action = Command.PUT_CARD;
4298276a 227 }
a046fa49 228
4298276a
NR
229 // Sync from server if:
230 if (serverChanges) {
845fb1d7 231 action = Command.HASH_CONTACT;
4298276a 232 }
0b6140e4 233
4298276a
NR
234 // Sync from/to server if
235 if (serverChanges && localChanges) {
845fb1d7 236 action = Command.HELP;
4298276a 237 }
a046fa49 238
02b341aa 239 // POST the whole file if:
4298276a 240 if (tsServer == -1) {
845fb1d7 241 action = Command.POST_CARD;
4298276a 242 }
a046fa49 243
4298276a
NR
244 // GET the whole file if:
245 if (tsOriginal == -1) {
845fb1d7 246 action = Command.GET_CARD;
4298276a 247 }
a046fa49 248
4298276a
NR
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) {
845fb1d7 258 s.sendCommand(Command.SELECT, name);
4298276a
NR
259 if (tsServer != StringUtils.toTime(s.receiveLine())) {
260 System.err.println("DEBUG: it changed. retry.");
845fb1d7 261 s.sendCommand(Command.SELECT);
4298276a 262 s.close();
5ad0e17e 263 return sync(force, callback);
0b6140e4 264 }
4298276a
NR
265
266 switch (action) {
02b341aa 267 case GET_CARD: {
845fb1d7 268 s.sendCommand(Command.GET_CARD);
4298276a
NR
269 List<String> data = s.receiveBlock();
270 setLastModified(data.remove(0));
5ad0e17e 271 local.replaceListContent(Vcard21Parser.parseContact(data));
4298276a 272
845fb1d7
NR
273 if (local.isDirty())
274 local.save();
275 local.saveAs(getCache(cacheDirOrig), Format.VCard21);
4298276a 276 break;
02b341aa
NR
277 }
278 case POST_CARD: {
845fb1d7
NR
279 s.sendCommand(Command.POST_CARD);
280 s.sendBlock(Vcard21Parser.toStrings(local));
281 local.saveAs(getCache(cacheDirOrig), Format.VCard21);
4298276a
NR
282 setLastModified(s.receiveLine());
283 break;
02b341aa 284 }
845fb1d7 285 case PUT_CARD: {
02b341aa
NR
286 String serverLastModifTime = updateToServer(s, original,
287 local);
4298276a 288
845fb1d7 289 local.saveAs(getCache(cacheDirOrig), Format.VCard21);
845fb1d7 290
02b341aa 291 setLastModified(serverLastModifTime);
4298276a 292 break;
845fb1d7
NR
293 }
294 case HASH_CONTACT: {
02b341aa 295 String serverLastModifTime = updateFromServer(s, local);
845fb1d7
NR
296
297 local.save();
298 local.saveAs(getCache(cacheDirOrig), Format.VCard21);
02b341aa
NR
299
300 setLastModified(serverLastModifTime);
301 break;
302 }
303 case HELP: {
02b341aa
NR
304 // note: we are holding the server here, so it could throw
305 // us away if we take too long
306
5ad0e17e 307 // TODO: check if those files are deleted
02b341aa
NR
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
5ad0e17e
NR
316 // Do an auto sync
317 server.saveAs(mergeF, Format.VCard21);
02b341aa 318 Card merge = new Card(mergeF, Format.VCard21);
5ad0e17e
NR
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 //
02b341aa
NR
350
351 merge.saveAs(getCache(cacheDir), Format.VCard21);
352 merge.saveAs(getCache(cacheDirOrig), Format.VCard21);
353
354 setLastModified(serverLastModifTime);
355
356 local = merge;
357
845fb1d7
NR
358 break;
359 }
5ad0e17e
NR
360 default:
361 // will not happen
362 break;
0b6140e4 363 }
4298276a 364
845fb1d7 365 s.sendCommand(Command.SELECT);
a046fa49 366 }
4298276a 367 } catch (IOException e) {
5ad0e17e 368 return new CardResult(e);
4298276a 369 } catch (Exception e) {
5ad0e17e 370 return new CardResult(new IOException(e));
4298276a
NR
371 } finally {
372 s.close();
a046fa49
NR
373 }
374
5ad0e17e 375 return new CardResult(local, true, true, serverChanges);
a046fa49
NR
376 }
377
02b341aa
NR
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);
5ad0e17e 423 s.sendCommand(Command.PUT_CONTACT, f.getId());
02b341aa 424 for (Data d : subremoved) {
5ad0e17e 425 s.sendCommand(Command.DELETE_DATA, d.getContentState(true));
02b341aa
NR
426 }
427 for (Data d : subadded) {
5ad0e17e 428 s.sendCommand(Command.POST_DATA, d.getContentState(true));
02b341aa
NR
429 s.sendBlock(Vcard21Parser.toStrings(d));
430 }
5ad0e17e 431 s.sendCommand(Command.PUT_CONTACT);
02b341aa
NR
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);
5ad0e17e 477 } else if (!hash.equals(c.getContentState(true))) {
02b341aa
NR
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
a046fa49
NR
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;
cf77cb35 553 } catch (Exception e) {
a046fa49
NR
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}