Performance improvement:
[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
19
20import be.nikiroo.jvcard.Card;
0b6140e4
NR
21import be.nikiroo.jvcard.Contact;
22import be.nikiroo.jvcard.Data;
5ad0e17e
NR
23import be.nikiroo.jvcard.launcher.CardResult;
24import be.nikiroo.jvcard.launcher.CardResult.MergeCallback;
a046fa49 25import be.nikiroo.jvcard.parsers.Format;
0b6140e4 26import be.nikiroo.jvcard.parsers.Vcard21Parser;
59597d59 27import be.nikiroo.jvcard.remote.SimpleSocket.BlockAppendable;
7da41ecd 28import be.nikiroo.jvcard.resources.StringUtils;
e119a1c1
NR
29import be.nikiroo.jvcard.resources.bundles.RemoteBundle;
30import be.nikiroo.jvcard.resources.enums.RemotingOption;
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 279 s.sendCommand(Command.POST_CARD);
59597d59
NR
280 BlockAppendable app = s.createBlockAppendable();
281 Vcard21Parser.write(app, local);
282 app.close();
845fb1d7 283 local.saveAs(getCache(cacheDirOrig), Format.VCard21);
4298276a
NR
284 setLastModified(s.receiveLine());
285 break;
02b341aa 286 }
845fb1d7 287 case PUT_CARD: {
02b341aa
NR
288 String serverLastModifTime = updateToServer(s, original,
289 local);
4298276a 290
845fb1d7 291 local.saveAs(getCache(cacheDirOrig), Format.VCard21);
845fb1d7 292
02b341aa 293 setLastModified(serverLastModifTime);
4298276a 294 break;
845fb1d7
NR
295 }
296 case HASH_CONTACT: {
02b341aa 297 String serverLastModifTime = updateFromServer(s, local);
845fb1d7
NR
298
299 local.save();
300 local.saveAs(getCache(cacheDirOrig), Format.VCard21);
02b341aa
NR
301
302 setLastModified(serverLastModifTime);
303 break;
304 }
305 case HELP: {
02b341aa
NR
306 // note: we are holding the server here, so it could throw
307 // us away if we take too long
308
5ad0e17e 309 // TODO: check if those files are deleted
02b341aa
NR
310 File mergeF = File.createTempFile("contact-merge", ".vcf");
311 File serverF = File
312 .createTempFile("contact-server", ".vcf");
313 original.saveAs(serverF, Format.VCard21);
314
315 Card server = new Card(serverF, Format.VCard21);
316 updateFromServer(s, server);
317
5ad0e17e
NR
318 // Do an auto sync
319 server.saveAs(mergeF, Format.VCard21);
02b341aa 320 Card merge = new Card(mergeF, Format.VCard21);
5ad0e17e
NR
321 List<Contact> added = new LinkedList<Contact>();
322 List<Contact> removed = new LinkedList<Contact>();
323 original.compare(local, added, removed, removed, added);
324 for (Contact c : removed)
325 merge.getById(c.getId()).delete();
326 for (Contact c : added)
327 merge.add(Vcard21Parser.clone(c));
328
329 merge.save();
330
331 // defer to client:
332 if (callback == null) {
333 throw new IOException(
334 "Conflicting changes detected and merge operation not allowed");
335 }
336
337 merge = callback.merge(original, local, server, merge);
338 if (merge == null) {
339 throw new IOException(
340 "Conflicting changes detected and merge operation cancelled");
341 }
342
343 // TODO: something like:
344 // String serverLastModifTime = updateToServer(s, original,
345 // merge);
346 // ...but without starting with original since it is not
347 // true here
348 s.sendCommand(Command.POST_CARD);
59597d59
NR
349 BlockAppendable app = s.createBlockAppendable();
350 Vcard21Parser.write(app, merge);
351 app.close();
5ad0e17e
NR
352 String serverLastModifTime = s.receiveLine();
353 //
02b341aa
NR
354
355 merge.saveAs(getCache(cacheDir), Format.VCard21);
356 merge.saveAs(getCache(cacheDirOrig), Format.VCard21);
357
358 setLastModified(serverLastModifTime);
359
360 local = merge;
361
845fb1d7
NR
362 break;
363 }
5ad0e17e
NR
364 default:
365 // will not happen
366 break;
0b6140e4 367 }
4298276a 368
845fb1d7 369 s.sendCommand(Command.SELECT);
a046fa49 370 }
4298276a 371 } catch (IOException e) {
5ad0e17e 372 return new CardResult(e);
4298276a 373 } catch (Exception e) {
5ad0e17e 374 return new CardResult(new IOException(e));
4298276a
NR
375 } finally {
376 s.close();
a046fa49
NR
377 }
378
5ad0e17e 379 return new CardResult(local, true, true, serverChanges);
a046fa49
NR
380 }
381
02b341aa
NR
382 /**
383 * Will update the currently selected {@link Card} on the remote server to
384 * be in the same state as <tt>local</tt>, assuming the server is currently
385 * in <tt>original</tt> state.
386 *
387 * @param s
388 * the {@link SimpleSocket} to work on, which <b>MUST</b> be in
389 * SELECT mode
390 * @param original
391 * the original {@link Card} as it was before the client made
392 * changes to it
393 * @param local
394 * the {@link Card} to which state we want the server in
395 *
396 * @return the last modified time from the remote server (which is basically
397 * "now")
398 *
399 * @throws IOException
400 * in case of IO error
401 */
402 private String updateToServer(SimpleSocket s, Card original, Card local)
403 throws IOException {
404 List<Contact> added = new LinkedList<Contact>();
405 List<Contact> removed = new LinkedList<Contact>();
406 List<Contact> from = new LinkedList<Contact>();
407 List<Contact> to = new LinkedList<Contact>();
408 original.compare(local, added, removed, from, to);
409
410 s.sendCommand(Command.PUT_CARD);
411
412 for (Contact c : removed) {
413 s.sendCommand(Command.DELETE_CONTACT, c.getId());
414 }
415 for (Contact c : added) {
416 s.sendCommand(Command.POST_CONTACT, c.getId());
59597d59
NR
417 BlockAppendable app = s.createBlockAppendable();
418 Vcard21Parser.write(app, c, -1);
419 s.close();
02b341aa
NR
420 }
421 if (from.size() > 0) {
422 for (int index = 0; index < from.size(); index++) {
423 Contact f = from.get(index);
424 Contact t = to.get(index);
425
426 List<Data> subadded = new LinkedList<Data>();
427 List<Data> subremoved = new LinkedList<Data>();
428 f.compare(t, subadded, subremoved, subremoved, subadded);
5ad0e17e 429 s.sendCommand(Command.PUT_CONTACT, f.getId());
02b341aa 430 for (Data d : subremoved) {
5ad0e17e 431 s.sendCommand(Command.DELETE_DATA, d.getContentState(true));
02b341aa
NR
432 }
433 for (Data d : subadded) {
5ad0e17e 434 s.sendCommand(Command.POST_DATA, d.getContentState(true));
59597d59
NR
435 BlockAppendable app = s.createBlockAppendable();
436 Vcard21Parser.write(app, d);
437 app.close();
02b341aa 438 }
5ad0e17e 439 s.sendCommand(Command.PUT_CONTACT);
02b341aa
NR
440 }
441 }
442
443 s.sendCommand(Command.PUT_CARD);
444
445 return s.receiveLine();
446 }
447
448 /**
449 * Will update the given {@link Card} object (not {@link File}) to the
450 * currently selected {@link Card} on the remote server.
451 *
452 * @param s
453 * the {@link SimpleSocket} to work on, which <b>MUST</b> be in
454 * SELECT mode
455 * @param local
456 * the {@link Card} to update
457 *
458 * @return the last modified time from the remote server
459 *
460 * @throws IOException
461 * in case of IO error
462 */
463 private String updateFromServer(SimpleSocket s, Card local)
464 throws IOException {
465 s.sendCommand(Command.PUT_CARD);
466
467 s.sendCommand(Command.LIST_CONTACT);
468 Map<String, String> remote = new HashMap<String, String>();
469 for (String line : s.receiveBlock()) {
470 int indexSp = line.indexOf(" ");
471 String hash = line.substring(0, indexSp);
472 String uid = line.substring(indexSp + 1);
473
474 remote.put(uid, hash);
475 }
476
477 List<Contact> deleted = new LinkedList<Contact>();
478 List<Contact> changed = new LinkedList<Contact>();
479 List<String> added = new LinkedList<String>();
480
481 for (Contact c : local) {
482 String hash = remote.get(c.getId());
483 if (hash == null) {
484 deleted.add(c);
5ad0e17e 485 } else if (!hash.equals(c.getContentState(true))) {
02b341aa
NR
486 changed.add(c);
487 }
488 }
489
490 for (String uid : remote.keySet()) {
491 if (local.getById(uid) == null)
492 added.add(uid);
493 }
494
495 // process:
496
497 for (Contact c : deleted) {
498 c.delete();
499 }
500
501 for (String uid : added) {
502 s.sendCommand(Command.GET_CONTACT, uid);
503 for (Contact cc : Vcard21Parser.parseContact(s.receiveBlock())) {
504 local.add(cc);
505 }
506 }
507
508 for (Contact c : changed) {
509 c.delete();
510 s.sendCommand(Command.GET_CONTACT, c.getId());
511 for (Contact cc : Vcard21Parser.parseContact(s.receiveBlock())) {
512 local.add(cc);
513 }
514 }
515
516 s.sendCommand(Command.PUT_CARD);
517
518 return s.receiveLine();
519 }
520
a046fa49
NR
521 /**
522 * Return the requested cache for the current resource.
523 *
524 * @param dir
525 * the cache to use
526 *
527 * @return the cached {@link File}
528 */
529 private File getCache(File dir) {
530 return new File(dir.getPath() + File.separator + name);
531 }
532
533 /**
534 * Return the cached {@link File} corresponding to the current resource.
535 *
536 * @return the cached {@link File}
537 */
538 public File getCache() {
539 return new File(cacheDir.getPath() + File.separator + name);
540 }
541
542 /**
543 * Get the last modified date of the current resource's original cached
544 * file, that is, the time the server reported as the "last modified time"
545 * when this resource was transfered.
546 *
547 * @return the last modified time from the server back when this resource
548 * was transfered
549 */
550 public long getLastModified() {
551 try {
552 BufferedReader in = new BufferedReader(new InputStreamReader(
553 new FileInputStream(cacheDirOrigTS.getPath()
554 + File.separator + name)));
555 String line = in.readLine();
556 in.close();
557
558 return StringUtils.toTime(line);
559 } catch (FileNotFoundException e) {
560 return -1;
cf77cb35 561 } catch (Exception e) {
a046fa49
NR
562 return -1;
563 }
564 }
565
566 /**
567 * Set the last modified date of the current resource's original cached
568 * file, that is, the time the server reported as the "last modified time"
569 * when this resource was transfered.
570 *
571 * @param time
572 * the last modified time from the server back when this resource
573 * was transfered
574 */
575 public void setLastModified(String time) {
576 try {
577 BufferedWriter out = new BufferedWriter(new OutputStreamWriter(
578 new FileOutputStream(cacheDirOrigTS.getPath()
579 + File.separator + name)));
580 out.append(time);
581 out.newLine();
582 out.close();
583 } catch (FileNotFoundException e) {
584 e.printStackTrace();
585 } catch (IOException e) {
586 e.printStackTrace();
587 }
588 }
589
590 /**
591 * Configure the synchronisation mechanism (cache, auto update...).
592 *
593 * @throws InvalidParameterException
594 * if the remote configuration file <tt>remote.properties</tt>
595 * cannot be accessed or if the cache directory cannot be used
596 */
597 static private void config() {
598 String dir = null;
e119a1c1 599 RemoteBundle bundle = new RemoteBundle();
a046fa49
NR
600
601 try {
e119a1c1 602 dir = bundle.getString(RemotingOption.CLIENT_CACHE_DIR);
a046fa49
NR
603
604 cacheDir = new File(dir + File.separator + "current");
605 cacheDir.mkdir();
606 cacheDirOrig = new File(dir + File.separator + "original");
607 cacheDirOrig.mkdir();
608 cacheDirOrigTS = new File(dir + File.separator + "timestamps");
609 cacheDirOrigTS.mkdir();
610
611 if (!cacheDir.exists() || !cacheDirOrig.exists()) {
612 throw new IOException("Cannot open or create cache store at: "
613 + dir);
614 }
615
e119a1c1
NR
616 autoSync = bundle
617 .getBoolean(RemotingOption.CLIENT_AUTO_SYNC, false);
a046fa49
NR
618 } catch (Exception e) {
619 throw new InvalidParameterException(
620 "Cannot open or create cache store at: " + dir);
621 }
622 }
623}