package org.kvj.lima1.sync.controller;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.kvj.lima1.sync.Lima1SyncApp;
import org.kvj.lima1.sync.LoginForm;
import org.kvj.lima1.sync.PJSONObject;
import org.kvj.lima1.sync.QueryOperator;
import org.kvj.lima1.sync.R;
import org.kvj.lima1.sync.SyncServiceInfo;
import org.kvj.lima1.sync.controller.data.AppInfo;
import org.kvj.lima1.sync.controller.data.FKey;
import org.kvj.lima1.sync.controller.data.TableInfo;
import org.kvj.lima1.sync.controller.net.HttpClientTransport;
import org.kvj.lima1.sync.controller.net.NetTransport.NetTransportException;
import org.kvj.lima1.sync.controller.net.OAuthProvider;
import org.kvj.lima1.sync.controller.net.OAuthProvider.OAuthProviderListener;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.text.TextUtils;
import android.util.Log;
public class SyncController implements OAuthProviderListener {
public static interface SyncControllerListener {
public void syncStarted();
public void syncCompleted(String error);
}
class AutoSyncTask extends TimerTask {
private String app;
public AutoSyncTask(String app) {
this.app = app;
}
@Override
public void run() {
sync(app);
}
}
private static final int LOGIN_ID = 1;
private static int AUTO_SYNC_SEC_DEFAULT = 30;
private static final String TAG = "Sync";
private static final int TYPE_NO_NETWORK = -1;
private HttpClientTransport transport;
private OAuthProvider net;
private Map<String, AppInfo> infos = new HashMap<String, AppInfo>();
private SyncControllerListener listener = null;
private Map<String, AutoSyncTask> autoSyncTasks = new HashMap<String, AutoSyncTask>();
private Map<String, Boolean> autoSyncLocks = new HashMap<String, Boolean>();
private Timer autoSyncTimer = new Timer("AutoSync");
private int imageWidth = 0;
private Lima1SyncApp context = null;
public SyncController(Lima1SyncApp context) {
this.context = context;
this.transport = new HttpClientTransport();
transport.setURL(context, "https://lima1-kvj.rhcloud.com");
this.net = new OAuthProvider(transport, "lima1android", context.getStringPreference(R.string.token,
R.string.tokenDefault), this);
imageWidth = Math.max(context.getResources().getDisplayMetrics().widthPixels, context.getResources()
.getDisplayMetrics().heightPixels);
}
@Override
public void onNeedToken() {
NotificationManager notificationManager = (NotificationManager) Lima1SyncApp.getInstance().getSystemService(
Context.NOTIFICATION_SERVICE);
Notification notification = new Notification(R.drawable.ic_login, "Lima1", System.currentTimeMillis());
PendingIntent intent = PendingIntent.getActivity(Lima1SyncApp.getInstance(), 0,
new Intent(Lima1SyncApp.getInstance(), LoginForm.class), PendingIntent.FLAG_CANCEL_CURRENT);
notification.setLatestEventInfo(Lima1SyncApp.getInstance(), "Lima1", "Login/password required", intent);
notification.defaults = Notification.DEFAULT_ALL;
notification.flags |= Notification.FLAG_AUTO_CANCEL;
notificationManager.notify(LOGIN_ID, notification);
}
public String verifyToken(String username, String password) {
try {
Log.i(TAG, "Verify: " + username + ", " + password);
String token = net.tokenByUsernamePassword(username, password);
Lima1SyncApp.getInstance().setStringPreference(R.string.token, token);
return null;
} catch (NetTransportException e) {
e.printStackTrace();
return e.getMessage();
}
}
private int hasConnection() {
ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo wifiNetwork = cm.getNetworkInfo(ConnectivityManager.TYPE_WIFI);
if (wifiNetwork != null && wifiNetwork.isConnected()) {
return ConnectivityManager.TYPE_WIFI;
}
NetworkInfo mobileNetwork = cm.getNetworkInfo(ConnectivityManager.TYPE_MOBILE);
if (mobileNetwork != null && mobileNetwork.isConnected()) {
return ConnectivityManager.TYPE_WIFI;
}
NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
if (activeNetwork != null && activeNetwork.isConnected()) {
return activeNetwork.getType();
}
return TYPE_NO_NETWORK;
}
public String sync(final String app) {
// int networkType = hasConnection();
// if (TYPE_NO_NETWORK == networkType) { // No connection
// Log.w(TAG, "Skipping sync because of no network");
// return null;
// }
// Log.i(TAG, "Network: " + networkType);
final AppInfo info = getInfo(app);
if (null == info.db) {
Log.e(TAG, "DB error");
return null;
}
synchronized (autoSyncLocks) { // Lock
Boolean syncNow = autoSyncLocks.get(app);
Log.i(TAG, "Sync lock: " + syncNow + ", " + app);
if (null != syncNow && syncNow) { // Already started
return null;
}
autoSyncLocks.put(app, true);
}
final AutoSyncTask task;
synchronized (autoSyncTasks) { // Remove itself
task = autoSyncTasks.get(app);
autoSyncTasks.put(app, null); // Removed
}
final String id = "sync" + info.id();
Thread syncThread = new Thread() {
@Override
public void run() {
String syncResult = _sync(info, app);
if (null != task) { // Not empty
task.cancel();
}
synchronized (autoSyncLocks) { // Unlock
autoSyncLocks.put(app, false);
}
Intent syncFinish = new Intent(SyncServiceInfo.SYNC_FINISH_INTENT);
syncFinish.putExtra(SyncServiceInfo.KEY_APP, app);
syncFinish.putExtra(SyncServiceInfo.KEY_TOKEN, id);
syncFinish.putExtra(SyncServiceInfo.KEY_RESULT, syncResult);
context.sendBroadcast(syncFinish);
}
};
syncThread.start();
return id;
}
private String _sync(AppInfo info, String app) {
if (null != listener) {
listener.syncStarted();
}
try {
info.db.getDatabase().beginTransaction();
JSONObject newSchema = net.rest(app, "/rest/schema?", null);
// Log.i(TAG, "Schema: " + newSchema);
boolean fullSync = false;
long inFrom = 0;
long outFrom = 0;
int itemSent = 0;
int itemReceived = 0;
Cursor c = info.db.getDatabase().query("updates", new String[] { "version_in", "version_out" }, null, null,
null, null, "id desc", "1");
if (c.moveToFirst()) {
inFrom = c.getLong(0);
outFrom = c.getLong(1);
}
c.close();
if (null == info.schemaInfo) {
// Save schema, reset DB
fullSync = true;
outFrom = 0;
}
String upgradeResult = info.upgradeSchema(newSchema);
if (null != upgradeResult) { // Upgrade failed
throw new Exception(upgradeResult);
}
// Upload files
c = info.db.getDatabase().query("uploads", new String[] { "id", "name", "path", "status" }, null, null,
null, null, "id");
if (c.moveToFirst()) { // Have data
File folder = info.getFilesFolder();
if (null == folder) { // No cache
Log.e(TAG, "No cache folder: " + app);
throw new Exception("Cache folder is not accessible");
}
do {
String id = c.getString(0);
String name = c.getString(1);
int status = c.getInt(3);
if (3 == status) { // Remove
String uri = String.format("/rest/file/remove?name=%s&", name);
net.rest(app, uri, null);
removeCacheFile(info, name);
} else {
File file = new File(folder, name);
if (file.exists()) { // Still here - upload
String uri = String.format("/rest/file/upload?name=%s&", name);
Map<String, Object> params = new HashMap<String, Object>();
params.put("file", new FileInputStream(file));
net.rest(app, uri, params);
removeCacheFile(info, name);
}
}
info.db.getDatabase().delete("uploads", "id=?", new String[] { id });
} while (c.moveToNext());
}
c.close();
// Send changes
int slots = 10;
JSONArray result = new JSONArray();
int slotsUsed = 0;
int index = 0;
StringBuffer sqls = new StringBuffer();
List<String> sqlArgs = new ArrayList<String>();
for (String stream : info.schemaInfo.tables.keySet()) {
// For every table
if (index > 0) { // Add union
sqls.append(" union ");
}
sqls.append("select id, '" + stream + "' stream, data, updated, status from t_" + stream
+ " where own=? and updated>?");
sqlArgs.add("1");
sqlArgs.add(Long.toString(inFrom));
index++;
}
sqls.append(" order by updated");
c = info.db.getDatabase().rawQuery(sqls.toString(), sqlArgs.toArray(new String[0]));
int slotsNeeded = 1;
if (c.moveToFirst()) {
do {
slotsUsed += slotsNeeded;
JSONObject json = new JSONObject();
if (slotsUsed > slots) {
// Send
json.put("a", result);
net.rest(app, "/rest/in?", json);
slotsUsed = 0;
result = new JSONArray();
json = new JSONObject();
}
json.put("s", c.getString(1));
json.put("st", c.getInt(4));
json.put("u", c.getLong(3));
json.put("o", c.getString(2));
json.put("i", c.getLong(0));
result.put(json);
itemSent++;
inFrom = c.getLong(3);
} while (c.moveToNext());
}
Log.i(TAG, "In data: " + result.length());
if (result.length() > 0) {
JSONObject json = new JSONObject();
json.put("a", result);
net.rest(app, "/rest/in?", json);
}
c.close();
// Receive data
while (true) {
String url = String.format("/rest/out?from=%d&%s", outFrom, fullSync ? "" : "inc=yes&");
JSONObject res = net.rest(app, url, null);
JSONArray arr = res.getJSONArray("a");
if (arr.length() == 0) {
outFrom = res.getLong("u");
break;
}
for (int i = 0; i < arr.length(); i++) {
JSONObject object = arr.getJSONObject(i);
outFrom = object.getLong("u");
JSONObject obj = new JSONObject(object.getString("o"));
create(app, object.getString("s"), obj, object.getInt("st"), object.getLong("u"));
itemReceived++;
}
}
// Finish sync: insert updates, remove removed data
ContentValues values = new ContentValues();
values.put("id", info.id());
values.put("version_in", inFrom);
values.put("version_out", outFrom);
info.db.getDatabase().insert("updates", null, values);
for (String stream : info.schemaInfo.tables.keySet()) {
info.db.getDatabase().delete("t_" + stream, "status=? and updated<=?",
new String[] { "3", Long.toString(inFrom) });
}
info.db.getDatabase().setTransactionSuccessful();
info.setSchema(newSchema);
Log.i(TAG, "Sync done: out: " + itemSent + ", in: " + itemReceived);
if (null != listener) {
listener.syncCompleted(null);
}
return null;
} catch (Exception e) {
e.printStackTrace();
if (null != listener) {
listener.syncCompleted("Error in sync");
}
return e.getMessage();
} finally {
info.db.getDatabase().endTransaction();
}
}
private void scheduleAutoSync(String app) {
synchronized (autoSyncTasks) { // Lock
AutoSyncTask task = autoSyncTasks.get(app);
if (null != task) { // Not null
task.cancel();
}
task = new AutoSyncTask(app);
autoSyncTasks.put(app, task);
autoSyncTimer.schedule(task, 1000 * AUTO_SYNC_SEC_DEFAULT);
}
}
private Long create(String app, String stream, JSONObject obj, int status, Long updated) throws Exception {
AppInfo info = getInfo(app);
ContentValues values = new ContentValues();
TableInfo tinfo = info.getTableInfo(stream);
if (null == tinfo) {
throw new Exception("Not synchronized");
}
if (!obj.has("id")) {
obj.put("id", info.id());
}
values.put("id", obj.getLong("id"));
values.put("status", status);
values.put("data", obj.toString());
if (null != updated) {
values.put("updated", updated.longValue());
values.put("own", 0);
} else {
values.put("updated", info.id());
values.put("own", 1);
// Log.i(TAG, "Create/update: " + obj.getLong("id") + ", " +
// values.getAsLong("updated"));
}
for (String field : tinfo.numbers) { // Add numbers
values.put("f_" + field, obj.optLong(field));
}
for (String field : tinfo.texts) { // Add texts
values.put("f_" + field, obj.optString(field));
}
Cursor c = info.db.getDatabase().query("t_" + stream, new String[] { "id" }, "id=?",
new String[] { Long.toString(obj.getLong("id")) }, null, null, null);
if (c.moveToFirst()) { // Found - update
info.db.getDatabase().update("t_" + stream, values, "id=?",
new String[] { Long.toString(obj.getLong("id")) });
} else {
info.db.getDatabase().insert("t_" + stream, null, values);
}
c.close();
return obj.optLong("id");
}
private synchronized AppInfo getInfo(String app) {
AppInfo info = infos.get(app);
if (null == info) {
info = new AppInfo(Lima1SyncApp.getInstance(), app);
infos.put(app, info);
}
return info;
}
public PJSONObject createUpdate(String app, String stream, PJSONObject obj) {
AppInfo info = getInfo(app);
TableInfo tinfo = info.getTableInfo(stream);
if (null == tinfo) {
Log.e(TAG, "No DB");
return null;
}
try {
info.db.getDatabase().beginTransaction();
create(app, stream, obj, 1, null);
info.db.getDatabase().setTransactionSuccessful();
scheduleAutoSync(app);
return obj;
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
info.db.getDatabase().endTransaction();
}
}
public PJSONObject remove(String app, String stream, PJSONObject obj) {
AppInfo info = getInfo(app);
TableInfo tinfo = info.getTableInfo(stream);
if (null == tinfo) {
Log.e(TAG, "No DB");
return null;
}
try {
info.db.getDatabase().beginTransaction();
ContentValues values = new ContentValues();
values.put("status", 3);
values.put("updated", info.id());
values.put("own", 1);
info.db.getDatabase().update("t_" + stream, values, "id=?", new String[] { obj.optString("id") });
info.db.getDatabase().setTransactionSuccessful();
scheduleAutoSync(app);
return obj;
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
info.db.getDatabase().endTransaction();
}
}
public PJSONObject removeCascade(String app, String stream, PJSONObject obj) {
AppInfo info = getInfo(app);
TableInfo tinfo = info.getTableInfo(stream);
if (null == tinfo) {
Log.e(TAG, "No DB");
return null;
}
long id = obj.optLong("id");
for (FKey fkey : tinfo.fkeys) { // Remove cascade for every fkey
PJSONObject[] items = query(app, fkey.table, new QueryOperator[] { new QueryOperator(fkey.field, id) },
null, null);
if (null == items) { // Error selecting
Log.w(TAG, "Error selecting from: " + fkey.table + "::" + fkey.field);
return null;
}
Log.i(TAG, "Remove cascade: " + fkey.table + "::" + fkey.field + ": " + items.length);
for (PJSONObject object : items) { // Remove
PJSONObject result = removeCascade(app, fkey.table, object);
if (null == result) {
Log.e(TAG, "Error removing item: " + object);
return null;
}
}
}
return remove(app, stream, obj);
}
private int jsonIndexOf(JSONArray arr, String value) throws JSONException {
if (null == arr) {
return -1;
}
for (int i = 0; i < arr.length(); i++) {
if (arr.getString(i).equals(value)) {
return i;
}
}
return -1;
}
private String parseOrder(String order, String def, TableInfo tinfo) throws JSONException {
if (TextUtils.isEmpty(order)) {
return def;
}
StringBuilder buffer = new StringBuilder();
String[] parts = order.split(",");
for (int i = 0; i < parts.length; i++) {
String part = parts[i].trim();
String[] arr = part.split("\\s");
String field = arr[0];
if (tinfo.numbers.contains(field) || tinfo.texts.contains(field)) {
field = "f_" + field;
}
Log.i(TAG, "parseOrder: " + part + ", " + field);
if (i > 0) {
buffer.append(", ");
}
buffer.append(field);
if (arr.length > 1) {
buffer.append(" " + arr[1]);
}
}
return buffer.toString();
}
private String arrayToQuery(QueryOperator[] arr, List<String> values, String orand, TableInfo info)
throws JSONException {
StringBuilder buffer = new StringBuilder();
for (int i = 0, fields = 0; i < arr.length; i++) {
QueryOperator op = arr[i];
String field = null;
if (info.numbers.contains(op.getName())) {
field = "f_" + op.getName();
} else {
if (info.texts.contains(op.getName())) {
field = "f_" + op.getName();
}
}
if ("id".equals(op.getName())) {
field = "id";
}
if ("updated".equals(op.getName())) {
field = "updated";
}
if (null != field) {
if (fields++ > 0) {
buffer.append(" " + orand + " ");
}
buffer.append(field);
buffer.append(op.getOperator());
buffer.append("?");
values.add(op.getValue());
}
}
return buffer.toString();
}
public PJSONObject[] query(String app, String stream, QueryOperator[] ops, String order, String limit) {
AppInfo info = getInfo(app);
if (null == info.db) {
Log.e(TAG, "No DB: " + app);
return null;
}
TableInfo tinfo = info.getTableInfo(stream);
if (null == tinfo) {
Log.e(TAG, "Unsupported stream: " + stream + "::" + app);
return null;
}
try {
List<String> values = new ArrayList<String>();
values.add("3");
String where = "status<>?";
if (null != ops) {
String cond = arrayToQuery(ops, values, "and", tinfo);
if (!TextUtils.isEmpty(cond)) {
where += " and (" + cond + ")";
}
}
// Log.i(TAG, "Query: " + stream + ", " + where + ", " + values);
Cursor c = info.db.getDatabase().query("t_" + stream, new String[] { "data" }, where,
values.toArray(new String[0]), null, null, parseOrder(order, "id", tinfo), limit);
List<PJSONObject> result = new ArrayList<PJSONObject>();
if (c.moveToFirst()) {
do {
result.add(new PJSONObject(c.getString(0)));
} while (c.moveToNext());
}
return result.toArray(new PJSONObject[0]);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
public void setListener(SyncControllerListener listener) {
this.listener = listener;
}
public String getFile(String app, String name) {
AppInfo info = getInfo(app);
if (null == info.db) {
Log.e(TAG, "No DB: " + app);
return null;
}
File folder = info.getFilesFolder();
if (null == folder) { // No cache
Log.e(TAG, "No cache folder: " + app);
return null;
}
try {
File file = new File(folder, name);
if (file.exists()) { // Already downloaded
return file.getAbsolutePath();
}
String url = String.format("/rest/file/download?name=%s&", name);
if (name.endsWith(".jpg")) { // Add with
url += String.format("width=%d&", imageWidth);
}
// Download
Log.i(TAG, "Downloading file: " + name + ", imageWidth: " + imageWidth);
InputStream stream = net.raw(app, url, null);
copyStreams(stream, new FileOutputStream(file));
return file.getAbsolutePath();
} catch (Exception e) {
Log.e(TAG, "Error getting file:", e);
}
return null;
}
private void copyStreams(InputStream in, OutputStream out) throws IOException {
BufferedInputStream bis = new BufferedInputStream(in);
BufferedOutputStream bos = new BufferedOutputStream(out);
byte[] buffer = new byte[4096];
int bytes = 0;
while ((bytes = bis.read(buffer)) > 0) {
bos.write(buffer, 0, bytes);
}
bis.close();
bos.close();
}
public boolean removeFile(String app, String name) {
AppInfo info = getInfo(app);
if (null == info.db) {
Log.e(TAG, "No DB: " + app);
return false;
}
removeCacheFile(info, name);
try {
info.db.getDatabase().beginTransaction();
Cursor c = info.db.getDatabase().query("uploads", new String[] { "id" }, "name=? and status=?",
new String[] { name, "1" }, null, null, null);
if (c.moveToFirst()) { // Have data - delete
info.db.getDatabase().delete("uploads", "id=?", new String[] { c.getString(0) });
} else {
ContentValues values = new ContentValues();
values.put("id", info.id());
values.put("name", name);
values.put("status", 3);
info.db.getDatabase().insert("uploads", null, values);
}
info.db.getDatabase().setTransactionSuccessful();
scheduleAutoSync(app);
return true;
} catch (Exception e) {
Log.e(TAG, "Error removing file:", e);
} finally {
info.db.getDatabase().endTransaction();
}
return false;
}
private boolean removeCacheFile(AppInfo info, String name) {
File folder = info.getFilesFolder();
if (null == folder) { // No cache
Log.e(TAG, "No cache folder: " + info.name);
return false;
}
try {
File file = new File(folder, name);
if (!file.exists()) { // Already downloaded
return true;
}
return file.delete();
} catch (Exception e) {
Log.e(TAG, "Error getting file:", e);
}
return false;
}
public String uploadFile(String app, String path) {
AppInfo info = getInfo(app);
if (null == info.db) {
Log.e(TAG, "No DB: " + app);
return null;
}
File folder = info.getFilesFolder();
if (null == folder) { // No cache
Log.e(TAG, "No cache folder: " + app);
return null;
}
File inFile = new File(path);
if (!inFile.exists() || !inFile.isFile()) { // Invalid input file
Log.e(TAG, "File not found: " + inFile);
return null;
}
String ext = ".bin";
if (-1 != path.lastIndexOf('.')) { // Have ext
ext = path.substring(path.lastIndexOf('.')).toLowerCase();
}
String name = "" + info.id() + ext;
File file = new File(folder, name);
try { //
copyStreams(new FileInputStream(inFile), new FileOutputStream(file)); // Copied
} catch (Exception e) {
Log.e(TAG, "Error copying files:", e);
return null;
}
try {
info.db.getDatabase().beginTransaction();
ContentValues values = new ContentValues();
values.put("id", info.id());
values.put("path", file.getAbsolutePath());
values.put("name", name);
values.put("status", 1);
info.db.getDatabase().insert("uploads", null, values);
info.db.getDatabase().setTransactionSuccessful();
scheduleAutoSync(app);
return name;
} catch (Exception e) {
Log.e(TAG, "Error getting file:", e);
} finally {
info.db.getDatabase().endTransaction();
}
return null;
}
public PJSONObject getData(String application) {
AppInfo info = getInfo(application);
if (null == info.db) {
Log.e(TAG, "No DB: " + application);
return null;
}
try { // JSON Errors
JSONObject object = info.schema.optJSONObject("_data");
if (null != object) { // Have data
return new PJSONObject(object.toString());
}
return null; // No data
} catch (Exception e) {
Log.e(TAG, "Error getting data", e);
}
return null;
}
}