/*
* Copyright (C) 2010-2015 FBReader.ORG Limited <contact@fbreader.org>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
package org.geometerplus.android.fbreader.sync;
import java.io.*;
import java.util.*;
import android.app.*;
import android.content.Intent;
import android.os.IBinder;
import android.os.SystemClock;
import android.util.Log;
import org.json.simple.JSONValue;
import org.geometerplus.zlibrary.core.network.*;
import org.geometerplus.zlibrary.core.options.Config;
import org.geometerplus.zlibrary.ui.android.network.SQLiteCookieDatabase;
import org.geometerplus.fbreader.book.*;
import org.geometerplus.fbreader.fbreader.options.SyncOptions;
import org.geometerplus.fbreader.network.sync.SyncData;
import org.geometerplus.android.fbreader.api.FBReaderIntents;
import org.geometerplus.android.fbreader.libraryService.BookCollectionShadow;
public class SyncService extends Service implements IBookCollection.Listener<Book> {
private static void log(String message) {
Log.d("FBReader.Sync", message);
}
private enum Status {
AlreadyUploaded(Book.SYNCHRONISED_LABEL),
Uploaded(Book.SYNCHRONISED_LABEL),
ToBeDeleted(Book.SYNC_DELETED_LABEL),
Failure(Book.SYNC_FAILURE_LABEL),
AuthenticationError(null),
ServerError(null),
SynchronizationDisabled(null),
FailedPreviuousTime(null),
HashNotComputed(null);
private static final List<String> AllLabels = Arrays.asList(
Book.SYNCHRONISED_LABEL,
Book.SYNC_FAILURE_LABEL,
Book.SYNC_DELETED_LABEL,
Book.SYNC_TOSYNC_LABEL
);
public final String Label;
Status(String label) {
Label = label;
}
}
private final BookCollectionShadow myCollection = new BookCollectionShadow();
private final SyncOptions mySyncOptions = new SyncOptions();
private final SyncData mySyncData = new SyncData();
private final SyncNetworkContext myBookUploadContext =
new SyncNetworkContext(this, mySyncOptions, mySyncOptions.UploadAllBooks);
private final SyncNetworkContext mySyncPositionsContext =
new SyncNetworkContext(this, mySyncOptions, mySyncOptions.Positions);
private final SyncNetworkContext mySyncBookmarksContext =
new SyncNetworkContext(this, mySyncOptions, mySyncOptions.Bookmarks);
private static volatile Thread ourSynchronizationThread;
private static volatile Thread ourQuickSynchronizationThread;
private final List<Book> myQueue = Collections.synchronizedList(new LinkedList<Book>());
private static final class Hashes {
final Set<String> Actual = new HashSet<String>();
final Set<String> Deleted = new HashSet<String>();
volatile boolean Initialised = false;
void clear() {
Actual.clear();
Deleted.clear();
Initialised = false;
}
void addAll(Collection<String> actual, Collection<String> deleted) {
if (actual != null) {
Actual.addAll(actual);
}
if (deleted != null) {
Deleted.addAll(deleted);
}
}
@Override
public String toString() {
return String.format(
"%s/%s HASHES (%s)",
Actual.size(),
Deleted.size(),
Initialised ? "complete" : "partial"
);
}
};
private final Hashes myHashesFromServer = new Hashes();
private PendingIntent syncIntent() {
return PendingIntent.getService(
this, 0, new Intent(this, getClass()).setAction(FBReaderIntents.Action.SYNC_SYNC), 0
);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
final String action = intent != null ? intent.getAction() : FBReaderIntents.Action.SYNC_SYNC;
if (FBReaderIntents.Action.SYNC_START.equals(action)) {
final AlarmManager alarmManager = (AlarmManager)getSystemService(ALARM_SERVICE);
alarmManager.cancel(syncIntent());
final Config config = Config.Instance();
config.runOnConnect(new Runnable() {
public void run() {
config.requestAllValuesForGroup("Sync");
config.requestAllValuesForGroup("SyncData");
if (!mySyncOptions.Enabled.getValue()) {
log("disabled");
return;
}
log("enabled");
alarmManager.setInexactRepeating(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime(),
AlarmManager.INTERVAL_HOUR,
syncIntent()
);
SQLiteCookieDatabase.init(SyncService.this);
myCollection.bindToService(SyncService.this, myQuickSynchroniser);
}
});
} else if (FBReaderIntents.Action.SYNC_STOP.equals(action)) {
final AlarmManager alarmManager = (AlarmManager)getSystemService(ALARM_SERVICE);
alarmManager.cancel(syncIntent());
log("stopped");
stopSelf();
} else if (FBReaderIntents.Action.SYNC_SYNC.equals(action)) {
SQLiteCookieDatabase.init(this);
myCollection.bindToService(this, myQuickSynchroniser);
myCollection.bindToService(this, myStandardSynchroniser);
} else if (FBReaderIntents.Action.SYNC_QUICK_SYNC.equals(action)) {
log("quick sync");
SQLiteCookieDatabase.init(this);
myCollection.bindToService(this, myQuickSynchroniser);
}
return START_STICKY;
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private void addBook(Book book) {
if (BookUtil.fileByBook(book).getPhysicalFile() != null) {
myQueue.add(book);
}
}
private synchronized void initHashTables() {
if (myHashesFromServer.Initialised) {
return;
}
try {
myBookUploadContext.reloadCookie();
final int pageSize = 500;
final Map<String,String> data = new HashMap<String,String>();
data.put("page_size", String.valueOf(pageSize));
for (int pageNo = 0; !myHashesFromServer.Initialised; ++pageNo) {
data.put("page_no", String.valueOf(pageNo));
myBookUploadContext.perform(new PostRequest("all.hashes.paged", data) {
@Override
public void processResponse(Object response) {
final Map<String,List<String>> map = (Map<String,List<String>>)response;
final List<String> actualHashes = map.get("actual");
final List<String> deletedHashes = map.get("deleted");
myHashesFromServer.addAll(actualHashes, deletedHashes);
if (actualHashes.size() < pageSize && deletedHashes.size() < pageSize) {
myHashesFromServer.Initialised = true;
}
}
});
log("RECEIVED: " + myHashesFromServer.toString());
}
} catch (SynchronizationDisabledException e) {
myHashesFromServer.clear();
throw e;
} catch (Exception e) {
myHashesFromServer.clear();
e.printStackTrace();
}
}
private final Runnable myStandardSynchroniser = new Runnable() {
@Override
public synchronized void run() {
if (!mySyncOptions.Enabled.getValue()) {
return;
}
myBookUploadContext.reloadCookie();
myCollection.addListener(SyncService.this);
if (ourSynchronizationThread == null) {
ourSynchronizationThread = new Thread() {
public void run() {
final long start = System.currentTimeMillis();
int count = 0;
final Map<Status,Integer> statusCounts = new HashMap<Status,Integer>();
try {
myHashesFromServer.clear();
for (BookQuery q = new BookQuery(new Filter.Empty(), 20);; q = q.next()) {
final List<Book> books = myCollection.books(q);
if (books.isEmpty()) {
break;
}
for (Book b : books) {
addBook(b);
}
}
Status status = null;
while (!myQueue.isEmpty() && status != Status.AuthenticationError) {
final Book book = myQueue.remove(0);
++count;
status = uploadBookToServer(book);
if (status.Label != null) {
for (String label : Status.AllLabels) {
if (status.Label.equals(label)) {
book.addNewLabel(label);
} else {
book.removeLabel(label);
}
}
myCollection.saveBook(book);
}
final Integer sc = statusCounts.get(status);
statusCounts.put(status, sc != null ? sc + 1 : 1);
}
} finally {
log("SYNCHRONIZATION FINISHED IN " + (System.currentTimeMillis() - start) + "msecs");
log("TOTAL BOOKS PROCESSED: " + count);
for (Status value : Status.values()) {
log("STATUS " + value + ": " + statusCounts.get(value));
}
ourSynchronizationThread = null;
}
}
};
ourSynchronizationThread.setPriority(Thread.MIN_PRIORITY);
ourSynchronizationThread.start();
}
}
};
private final Runnable myQuickSynchroniser = new Runnable() {
@Override
public synchronized void run() {
if (!mySyncOptions.Enabled.getValue()) {
return;
}
mySyncPositionsContext.reloadCookie();
if (ourQuickSynchronizationThread == null) {
ourQuickSynchronizationThread = new Thread() {
public void run() {
try {
syncPositions();
syncCustomShelves();
BookmarkSyncUtil.sync(mySyncBookmarksContext, myCollection);
} finally {
ourQuickSynchronizationThread = null;
}
}
};
ourQuickSynchronizationThread.setPriority(Thread.MAX_PRIORITY);
ourQuickSynchronizationThread.start();
}
}
};
private static abstract class PostRequest extends JsonRequest {
PostRequest(String app, Map<String,String> data) {
super(SyncOptions.BASE_URL + "app/" + app);
if (data != null) {
for (Map.Entry<String, String> entry : data.entrySet()) {
addPostParameter(entry.getKey(), entry.getValue());
}
}
}
}
private final class UploadRequest extends ZLNetworkRequest.FileUpload {
private final Book myBook;
private final String myHash;
Status Result = Status.Failure;
UploadRequest(File file, Book book, String hash) {
super(SyncOptions.BASE_URL + "app/book.upload", file, false);
myBook = book;
myHash = hash;
}
@Override
public void handleStream(InputStream stream, int length) throws IOException, ZLNetworkException {
final Object response = JSONValue.parse(new InputStreamReader(stream));
String id = null;
List<String> hashes = null;
String error = null;
String code = null;
try {
final List<Map> responseList = (List<Map>)response;
if (responseList.size() == 1) {
final Map resultMap = (Map)responseList.get(0).get("result");
id = (String)resultMap.get("id");
hashes = (List<String>)resultMap.get("hashes");
error = (String)resultMap.get("error");
code = (String)resultMap.get("code");
}
} catch (Exception e) {
// ignore
}
if (hashes != null && !hashes.isEmpty()) {
myHashesFromServer.addAll(hashes, null);
if (!hashes.contains(myHash)) {
myCollection.setHash(myBook, hashes.get(0));
}
}
if (error != null) {
log("UPLOAD FAILURE: " + error);
if ("ALREADY_UPLOADED".equals(code)) {
Result = Status.AlreadyUploaded;
}
} else if (id != null) {
log("UPLOADED SUCCESSFULLY: " + id);
Result = Status.Uploaded;
} else {
log("UNEXPECED RESPONSE: " + response);
}
}
}
private Status uploadBookToServer(Book book) {
try {
return uploadBookToServerInternal(book);
} catch (SynchronizationDisabledException e) {
return Status.SynchronizationDisabled;
}
}
private Status uploadBookToServerInternal(Book book) {
final File file = BookUtil.fileByBook(book).getPhysicalFile().javaFile();
final String hash = myCollection.getHash(book, false);
final boolean force = book.hasLabel(Book.SYNC_TOSYNC_LABEL);
if (hash == null) {
return Status.HashNotComputed;
} else if (myHashesFromServer.Actual.contains(hash)) {
return Status.AlreadyUploaded;
} else if (!force && myHashesFromServer.Actual.contains(hash)) {
return Status.ToBeDeleted;
} else if (!force && book.hasLabel(Book.SYNC_FAILURE_LABEL)) {
return Status.FailedPreviuousTime;
}
if (file.length() > 120 * 1024 * 1024) {
return Status.Failure;
}
initHashTables();
final Map<String,Object> result = new HashMap<String,Object>();
final PostRequest verificationRequest =
new PostRequest("book.status.by.hash", Collections.singletonMap("sha1", hash)) {
@Override
public void processResponse(Object response) {
result.putAll((Map)response);
}
};
try {
myBookUploadContext.perform(verificationRequest);
} catch (ZLNetworkAuthenticationException e) {
e.printStackTrace();
return Status.AuthenticationError;
} catch (ZLNetworkException e) {
e.printStackTrace();
return Status.ServerError;
}
final String csrfToken = myBookUploadContext.getCookieValue(SyncOptions.DOMAIN, "csrftoken");
try {
final String status = (String)result.get("status");
if ((force && !"found".equals(status)) || "not found".equals(status)) {
try {
final UploadRequest uploadRequest = new UploadRequest(file, book, hash);
uploadRequest.addHeader("Referer", verificationRequest.getURL());
uploadRequest.addHeader("X-CSRFToken", csrfToken);
myBookUploadContext.perform(uploadRequest);
return uploadRequest.Result;
} catch (ZLNetworkAuthenticationException e) {
e.printStackTrace();
return Status.AuthenticationError;
} catch (ZLNetworkException e) {
e.printStackTrace();
return Status.ServerError;
}
} else {
final List<String> hashes = (List<String>)result.get("hashes");
if ("found".equals(status)) {
myHashesFromServer.addAll(hashes, null);
return Status.AlreadyUploaded;
} else /* if ("deleted".equals(status)) */ {
myHashesFromServer.addAll(null, hashes);
return Status.ToBeDeleted;
}
}
} catch (Exception e) {
log("UNEXPECTED RESPONSE: " + result);
return Status.ServerError;
}
}
private void syncPositions() {
try {
mySyncPositionsContext.perform(new JsonRequest2(
SyncOptions.BASE_URL + "sync/position.exchange", mySyncData.data(myCollection)
) {
@Override
public void processResponse(Object response) {
if (mySyncData.updateFromServer((Map<String,Object>)response)) {
sendBroadcast(new Intent(FBReaderIntents.Event.SYNC_UPDATED));
}
}
});
} catch (Throwable t) {
t.printStackTrace();
}
}
private void syncCustomShelves() {
}
@Override
public void onDestroy() {
myCollection.removeListener(this);
myCollection.unbind();
super.onDestroy();
}
@Override
public void onBookEvent(BookEvent event, Book book) {
switch (event) {
default:
break;
case Added:
addBook(book);
break;
case Opened:
SyncOperations.quickSync(this, mySyncOptions);
break;
}
}
@Override
public void onBuildEvent(IBookCollection.Status status) {
}
}