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