Java tutorial
/* * Copyright (C) 2009 University of Washington * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express * or implied. See the License for the specific language governing permissions and limitations under * the License. */ package com.radicaldynamic.groupinform.services; import java.net.InetAddress; import java.net.UnknownHostException; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Random; import java.util.Set; import org.ektorp.CouchDbConnector; import org.ektorp.CouchDbInstance; import org.ektorp.DbAccessException; import org.ektorp.ReplicationCommand; import org.ektorp.ReplicationStatus; import org.ektorp.http.HttpClient; import org.ektorp.http.StdHttpClient; import org.ektorp.impl.StdCouchDbConnector; import org.ektorp.impl.StdCouchDbInstance; import org.json.JSONObject; import android.app.Service; import android.content.Intent; import android.content.SharedPreferences; import android.os.Binder; import android.os.ConditionVariable; import android.os.IBinder; import android.preference.PreferenceManager; import android.util.Log; import com.radicaldynamic.gcmobile.android.preferences.PreferencesActivity; import com.radicaldynamic.groupinform.R; import com.radicaldynamic.groupinform.application.Collect; import com.radicaldynamic.groupinform.database.InformCouchDbConnector; import com.radicaldynamic.groupinform.documents.Generic; import com.radicaldynamic.groupinform.logic.AccountFolder; import com.radicaldynamic.groupinform.repositories.FormDefinitionRepo; import com.radicaldynamic.groupinform.repositories.FormInstanceRepo; /** * Database abstraction layer for CouchDB, based on Ektorp. * * This does not control the stop/start of the actual DB. * See com.couchone.couchdb.CouchService for that. */ public class DatabaseService extends Service { private static final String t = "DatabaseService: "; // Replication modes public static final int REPLICATE_PUSH = 0; public static final int REPLICATE_PULL = 1; // Minutes represented as seconds private static final int TIME_FIVE_MINUTES = 300; private static final int TIME_TEN_MINUTES = 600; // 24 hours (represented as milliseconds) private static final long TIME_24_HOURS = 86400000; // Values returned by the Couch service -- we can't connect to the localhost until these are known private String mLocalHost = null; private int mLocalPort = 0; private HttpClient mLocalHttpClient = null; private CouchDbInstance mLocalDbInstance = null; private CouchDbConnector mLocalDbConnector = null; private HttpClient mRemoteHttpClient = null; private CouchDbInstance mRemoteDbInstance = null; private CouchDbConnector mRemoteDbConnector = null; private boolean mInit = false; private boolean mConnectedToLocal = false; private boolean mConnectedToRemote = false; // This is the object that receives interactions from clients. // See RemoteService for a more complete example. private final IBinder mBinder = new LocalBinder(); private ConditionVariable mCondition; // Hash of database-to-last cleanup timestamp (used for controlled purging databases of placeholders) private Map<String, Long> mDbLastCleanup = new HashMap<String, Long>(); // Hash of database-to-last replication timestamp private Map<String, Long> mDbLastReplication = new HashMap<String, Long>(); private Runnable mTask = new Runnable() { final String tt = t + "mTask: "; public void run() { for (int i = 1; i > 0; ++i) { if (mInit == false) { try { mInit = true; if (mLocalDbInstance != null) { performHousekeeping(); synchronizeLocalDBs(); } } catch (Exception e) { if (Collect.Log.WARN) Log.w(Collect.LOGTAG, tt + "error automatically connecting to DB: " + e.toString()); } finally { mInit = false; } } // Retry connection to CouchDB every 5 minutes if (mCondition.block(TIME_FIVE_MINUTES * 1000)) break; } } }; @SuppressWarnings("serial") public class DbUnavailableException extends Exception { DbUnavailableException() { super(); } } @SuppressWarnings("serial") public class DbUnavailableWhileOfflineException extends DbUnavailableException { DbUnavailableWhileOfflineException() { super(); } } @SuppressWarnings("serial") public class DbUnavailableDueToMetadataException extends DbUnavailableException { DbUnavailableDueToMetadataException(String db) { super(); if (Collect.Log.WARN) Log.w(Collect.LOGTAG, t + "metadata missing for DB " + db); } } @Override public void onCreate() { Thread persistentConnectionThread = new Thread(null, mTask, "DatabaseService"); mCondition = new ConditionVariable(false); persistentConnectionThread.start(); } @Override public void onDestroy() { mCondition.open(); } @Override public IBinder onBind(Intent intent) { return mBinder; } @Override public int onStartCommand(Intent intent, int flags, int startId) { if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, t + "received start ID " + startId + ": " + intent); return START_STICKY; } /** * Class for clients to access. Because we know this service always * runs in the same process as its clients, we don't need to deal with * IPC. */ public class LocalBinder extends Binder { public DatabaseService getService() { return DatabaseService.this; } } // Convenience method (uses currently selected database) public CouchDbConnector getDb() { return getDb(Collect.getInstance().getInformOnlineState().getSelectedDatabase()); } public CouchDbConnector getDb(String db) { final String tt = t + "getDb(): "; AccountFolder folder = Collect.getInstance().getInformOnlineState().getAccountFolders().get(db); CouchDbConnector dbConnector; if (folder.isReplicated()) { // Local database try { open(db); } catch (DbUnavailableException e) { if (Collect.Log.WARN) Log.w(Collect.LOGTAG, tt + "unable to connect to local database server: " + e.toString()); } dbConnector = mLocalDbConnector; } else { // Remote database try { open(db); } catch (DbUnavailableException e) { if (Collect.Log.WARN) Log.w(Collect.LOGTAG, tt + "unable to connect to remote database server: " + e.toString()); } dbConnector = mRemoteDbConnector; } return dbConnector; } /* * Does a database exist on the local CouchDB instance? */ public boolean isDbLocal(String db) { final String tt = t + "isDbLocal(): "; boolean result = false; try { if (mLocalDbInstance == null) connectToLocalServer(); if (mLocalDbInstance.getAllDatabases().indexOf("db_" + db) != -1) result = true; } catch (DbAccessException e) { if (Collect.Log.WARN) Log.w(Collect.LOGTAG, tt + e.toString()); } catch (Exception e) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, tt + "unhandled exception: " + e.toString()); } return result; } /** * Open a specific database * * @param database Name of database to open * @return * @throws DbMetadataUnavailableException * @throws DbUnavailableException */ public void open(String db) throws DbUnavailableException { // If database metadata is not yet available then abort here if (db == null || Collect.getInstance().getInformOnlineState().getAccountFolders().get(db) == null) { throw new DbUnavailableDueToMetadataException(db); } if (!Collect.getInstance().getIoService().isSignedIn() && !Collect.getInstance().getInformOnlineState().getAccountFolders().get(db).isReplicated()) throw new DbUnavailableWhileOfflineException(); boolean dbToOpenIsReplicated = Collect.getInstance().getInformOnlineState().getAccountFolders().get(db) .isReplicated(); if (dbToOpenIsReplicated) { // Local database if (mConnectedToLocal) { if (mLocalDbConnector instanceof StdCouchDbConnector && mLocalDbConnector.getDatabaseName().equals("db_" + db)) { return; } } else { connectToLocalServer(); } openLocalDb(db); } else { // Remote database if (mConnectedToRemote) { if (mRemoteDbConnector instanceof StdCouchDbConnector && mRemoteDbConnector.getDatabaseName().equals("db_" + db)) { return; } } else { connectToRemoteServer(); } openRemoteDb(db); } } /* * Similar in purpose to performHousekeeping(), this method is targeted towards any database */ public void performHousekeeping(String db) throws DbUnavailableException { final String tt = t + "performHousekeeping(String): "; // Determine if this database needs to be cleaned up Long lastCleanup = mDbLastCleanup.get(db); if (lastCleanup == null || System.currentTimeMillis() / 1000 - lastCleanup >= TIME_TEN_MINUTES) { if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "beginning cleanup for " + db); mDbLastCleanup.put(db, new Long(System.currentTimeMillis() / 1000)); removePlaceholders(new FormDefinitionRepo(getDb()).getAllPlaceholders()); removePlaceholders(new FormInstanceRepo(getDb()).getAllPlaceholders()); } } synchronized public ReplicationStatus replicate(String db, int mode) { final String tt = t + "replicate(): "; if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "about to replicate " + db); // Will not replicate unless signed in if (!Collect.getInstance().getIoService().isSignedIn()) { if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "aborting replication: not signed in"); return null; } if (Collect.getInstance().getInformOnlineState().isOfflineModeEnabled()) { if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "aborting replication: offline mode is enabled"); return null; } /* * Lookup master cluster by IP. Do this instead of relying on Erlang's internal resolver * (and thus Google's public DNS). Our builds of Erlang for Android do not yet use * Android's native DNS resolver. */ String masterClusterIP = null; try { InetAddress[] clusterInetAddresses = InetAddress .getAllByName(getString(R.string.tf_default_ionline_server)); masterClusterIP = clusterInetAddresses[new Random().nextInt(clusterInetAddresses.length)] .getHostAddress(); } catch (UnknownHostException e) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, tt + "unable to lookup master cluster IP addresses: " + e.toString()); e.printStackTrace(); } // Create local instance of database boolean dbCreated = false; // User may not have connected to local database yet - start up the connection for them try { if (mLocalDbInstance == null) { connectToLocalServer(); } } catch (DbUnavailableException e) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, tt + "cannot connect to local database server"); e.printStackTrace(); } if (mLocalDbInstance.getAllDatabases().indexOf("db_" + db) == -1) { switch (mode) { case REPLICATE_PULL: if (Collect.Log.INFO) Log.i(Collect.LOGTAG, tt + "creating local database " + db); mLocalDbInstance.createDatabase("db_" + db); dbCreated = true; break; case REPLICATE_PUSH: // If the database does not exist client side then there is no point in continuing if (Collect.Log.WARN) Log.w(Collect.LOGTAG, tt + "cannot find local database " + db + " to push"); return null; } } // Configure replication direction String source = null; String target = null; String deviceId = Collect.getInstance().getInformOnlineState().getDeviceId(); String deviceKey = Collect.getInstance().getInformOnlineState().getDeviceKey(); String localServer = "http://" + mLocalHost + ":" + mLocalPort + "/db_" + db; String remoteServer = "http://" + deviceId + ":" + deviceKey + "@" + masterClusterIP + ":5984/db_" + db; // Should we use encrypted transfers? SharedPreferences settings = PreferenceManager .getDefaultSharedPreferences(Collect.getInstance().getBaseContext()); if (settings.getBoolean(PreferencesActivity.KEY_ENCRYPT_SYNCHRONIZATION, true)) { remoteServer = "https://" + deviceId + ":" + deviceKey + "@" + masterClusterIP + ":6984/db_" + db; } switch (mode) { case REPLICATE_PUSH: source = localServer; target = remoteServer; break; case REPLICATE_PULL: source = remoteServer; target = localServer; break; } ReplicationCommand cmd = new ReplicationCommand.Builder().source(source).target(target).build(); ReplicationStatus status = null; try { status = mLocalDbInstance.replicate(cmd); } catch (Exception e) { // Remove a recently created DB if the replication failed if (dbCreated) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, t + "replication exception: " + e.toString()); e.printStackTrace(); mLocalDbInstance.deleteDatabase("db_" + db); } } return status; } public void setLocalDatabaseInfo(String host, int port) { final String tt = t + "setLocalDatabaseInfo(): "; if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "set host and port to " + host + ":" + port); mLocalHost = host; mLocalPort = port; } synchronized private void connectToLocalServer() throws DbUnavailableException { final String tt = t + "connectToLocalServer(): "; if (mLocalHost == null || mLocalPort == 0) { if (Collect.Log.WARN) Log.w(Collect.LOGTAG, tt + "local host information not available; aborting connection"); mConnectedToLocal = false; return; } if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "establishing connection to " + mLocalHost + ":" + mLocalPort); try { /* * Socket timeout of 5 minutes is important, otherwise long-running replications * will fail. It is possible that we will need to extend this in the future if * it turns out to be insufficient. */ mLocalHttpClient = new StdHttpClient.Builder().host(mLocalHost).port(mLocalPort) .socketTimeout(TIME_FIVE_MINUTES * 1000).build(); mLocalDbInstance = new StdCouchDbInstance(mLocalHttpClient); mLocalDbInstance.getAllDatabases(); if (mConnectedToLocal == false) mConnectedToLocal = true; if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "connection to " + mLocalHost + " successful"); } catch (Exception e) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, tt + e.toString()); e.printStackTrace(); mConnectedToLocal = false; throw new DbUnavailableException(); } } synchronized private void connectToRemoteServer() throws DbUnavailableException { final String tt = t + "connectToRemoteServer(): "; String host = getString(R.string.tf_default_ionline_server); int port = 6984; if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "establishing connection to " + host + ":" + port); try { mRemoteHttpClient = new StdHttpClient.Builder().enableSSL(true).host(host).port(port) .socketTimeout(30 * 1000).username(Collect.getInstance().getInformOnlineState().getDeviceId()) .password(Collect.getInstance().getInformOnlineState().getDeviceKey()).build(); mRemoteDbInstance = new StdCouchDbInstance(mRemoteHttpClient); mRemoteDbInstance.getAllDatabases(); if (mConnectedToRemote == false) mConnectedToRemote = true; if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "connection to " + host + " successful"); } catch (Exception e) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, tt + "while connecting to server " + port + ": " + e.toString()); e.printStackTrace(); mConnectedToRemote = false; throw new DbUnavailableException(); } } private void openLocalDb(String db) throws DbUnavailableException { final String tt = t + "openLocalDb(): "; try { /* * We used to create the database if it did not exist HOWEVER this had unintended side effects. * * Since local databases are typically initialized on-demand the first time the user selects * them for operations, databases that were selected for replication but not yet "switched to" * would be created as empty databases if the user backed out of the folder selection screen * without specifically choosing a database. * * Because the database then existed, attempts to "switch to" the database via the folder * selection screen (and have it initialized on-demand as expected) would fail. At least, * until the system got around to creating and replicating it automatically. */ if (mLocalDbInstance.getAllDatabases().indexOf("db_" + db) == -1) { if (Collect.Log.WARN) Log.w(Collect.LOGTAG, tt + "database does not exist; failing attempt to open"); throw new DbUnavailableException(); } if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "opening database " + db); mLocalDbConnector = new InformCouchDbConnector("db_" + db, mLocalDbInstance); } catch (Exception e) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, tt + "while opening DB " + db + ": " + e.toString()); e.printStackTrace(); throw new DbUnavailableException(); } } private void openRemoteDb(String db) throws DbUnavailableException { final String tt = t + "openRemoteDb(): "; try { if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "opening database " + db); mRemoteDbConnector = new InformCouchDbConnector("db_" + db, mRemoteDbInstance); /* * This should trigger any 401:Unauthorized errors when connecting to a remote DB * (better to know about them now then to experience a crash later because we didn't trap something) */ mRemoteDbConnector.getDbInfo(); } catch (Exception e) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, tt + "while opening DB " + db + ": " + e.toString()); e.printStackTrace(); throw new DbUnavailableException(); } } /* * Perform any house keeping (e.g., removing of unused DBs, view compaction & cleanup) */ private void performHousekeeping() { final String tt = t + "performHousekeeping(): "; try { List<String> allDatabases = mLocalDbInstance.getAllDatabases(); Iterator<String> dbs = allDatabases.iterator(); while (dbs.hasNext()) { String db = dbs.next(); // Skip special databases if (!db.startsWith("_")) { // Our metadata knows nothing about the db_ prefix db = db.substring(3); AccountFolder folder = Collect.getInstance().getInformOnlineState().getAccountFolders().get(db); if (folder == null) { // Remove databases that exist locally but for which we have no metadata if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "no metatdata for " + db + " (removing)"); mLocalDbInstance.deleteDatabase("db_" + db); } else if (isDbLocal(db) && folder.isReplicated() == false) { // Purge any databases that are local but not on the replication list try { ReplicationStatus status = replicate(db, REPLICATE_PUSH); if (status != null && status.isOk()) { if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "final replication push successful, removing " + db); mLocalDbInstance.deleteDatabase("db_" + db); } } catch (Exception e) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, tt + "final replication push of " + db + " failed at " + e.toString()); e.printStackTrace(); } } } } } catch (DbAccessException e) { if (Collect.Log.WARN) Log.w(Collect.LOGTAG, tt + "database not available " + e.toString()); } catch (Exception e) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, tt + "unhandled exception " + e.toString()); e.printStackTrace(); } } /* * Evaluate and remove a set of placeholders on the basis of who created it and when it was created */ private void removePlaceholders(HashMap<String, JSONObject> placeholders) { final String tt = t + "removePlaceholders(): "; for (Map.Entry<String, JSONObject> entry : placeholders.entrySet()) { if (entry.getValue().optString("createdBy", null) == null || entry.getValue().optString("dateCreated", null) == null) { // Remove old style (unowned) placeholders immediately try { getDb().delete(entry.getKey(), entry.getValue().optString("_rev")); if (Collect.Log.VERBOSE) Log.v(Collect.LOGTAG, tt + "removed old-style placeholder " + entry.getKey()); } catch (Exception e) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, tt + "unable to remove old-style placeholder"); e.printStackTrace(); } } else if (entry.getValue().optString("createdBy") .equals(Collect.getInstance().getInformOnlineState().getDeviceId())) { // Remove placeholders owned by me immediately try { getDb().delete(entry.getKey(), entry.getValue().optString("_rev")); if (Collect.Log.VERBOSE) Log.v(Collect.LOGTAG, tt + "removed my placeholder " + entry.getKey()); } catch (Exception e) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, tt + "unable to remove my placeholder"); e.printStackTrace(); } } else { // Remove placeholders owned by other people if they are stale (older than a day) SimpleDateFormat sdf = new SimpleDateFormat(Generic.DATETIME); Calendar calendar = Calendar.getInstance(); try { calendar.setTime(sdf.parse(entry.getValue().optString("dateCreated"))); if (calendar.getTimeInMillis() - Calendar.getInstance().getTimeInMillis() > TIME_24_HOURS) { try { getDb().delete(entry.getKey(), entry.getValue().optString("_rev")); if (Collect.Log.VERBOSE) Log.v(Collect.LOGTAG, tt + "removed stale placeholder " + entry.getKey()); } catch (Exception e) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, tt + "unable to remove stale placeholder"); e.printStackTrace(); } } } catch (ParseException e1) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, tt + "unable to parse dateCreated: " + e1.toString()); e1.printStackTrace(); } } } } /* * Trigger a push/pull replication for each locally replicated database */ private void synchronizeLocalDBs() { final String tt = t + "synchronizeLocalDBs(): "; // Do we use automatic synchronization? SharedPreferences settings = PreferenceManager .getDefaultSharedPreferences(Collect.getInstance().getBaseContext()); // How often should we automatically synchronize databases? String syncInterval = settings.getString(PreferencesActivity.KEY_SYNCHRONIZATION_INTERVAL, Integer.toString(TIME_FIVE_MINUTES)); if (settings.getBoolean(PreferencesActivity.KEY_AUTOMATIC_SYNCHRONIZATION, true)) { Set<String> folderSet = Collect.getInstance().getInformOnlineState().getAccountFolders().keySet(); Iterator<String> folderIds = folderSet.iterator(); while (folderIds.hasNext()) { AccountFolder folder = Collect.getInstance().getInformOnlineState().getAccountFolders() .get(folderIds.next()); if (folder.isReplicated()) { // Determine if this database needs to be replicated Long lastUpdate = mDbLastReplication.get(folder.getId()); if (lastUpdate == null || System.currentTimeMillis() / 1000 - lastUpdate >= Integer.parseInt(syncInterval)) { mDbLastReplication.put(folder.getId(), new Long(System.currentTimeMillis() / 1000)); if (Collect.Log.INFO) Log.i(Collect.LOGTAG, tt + "about to begin automatic replication of " + folder.getName()); try { replicate(folder.getId(), REPLICATE_PULL); replicate(folder.getId(), REPLICATE_PUSH); } catch (Exception e) { if (Collect.Log.WARN) Log.w(Collect.LOGTAG, tt + "problem replicating " + folder.getId() + ": " + e.toString()); e.printStackTrace(); } } else { if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "skipping automatic replication of " + folder.getName() + ": last synchronization too recent"); } } } } else { if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, tt + "skipping (automatic synchronization disabled)"); } } }