com.cloudant.sync.cordova.CloudantSyncPlugin.java Source code

Java tutorial

Introduction

Here is the source code for com.cloudant.sync.cordova.CloudantSyncPlugin.java

Source

/*
 * Copyright (c) 2015-2017 IBM Corp. All rights reserved.
 * <p>
 * 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
 * <p>
 * http://www.apache.org/licenses/LICENSE-2.0
 * <p>
 * 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.cloudant.sync.cordova;

import android.content.Context;
import android.util.Base64;
import android.util.Log;

import com.cloudant.http.HttpConnectionRequestInterceptor;
import com.cloudant.http.HttpConnectionResponseInterceptor;
import com.cloudant.sync.documentstore.Attachment;
import com.cloudant.sync.documentstore.ConflictResolver;
import com.cloudant.sync.documentstore.DocumentStore;
import com.cloudant.sync.documentstore.DocumentBody;
import com.cloudant.sync.documentstore.DocumentBodyFactory;
import com.cloudant.sync.documentstore.DocumentRevision;
import com.cloudant.sync.documentstore.UnsavedStreamAttachment;
import com.cloudant.sync.query.FieldSort;
import com.cloudant.sync.query.Query;
import com.cloudant.sync.query.QueryResult;
import com.cloudant.sync.replication.Replicator;
import com.cloudant.sync.replication.ReplicatorBuilder;

import org.apache.commons.io.IOUtils;
import org.apache.cordova.CallbackContext;
import org.apache.cordova.CordovaPlugin;
import org.apache.cordova.PluginResult;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

public class CloudantSyncPlugin extends CordovaPlugin {
    private static final String TAG = CloudantSyncPlugin.class.getCanonicalName();

    private static final String ACTION_SET_DOCUMENT_STORE_PATH = "createDatastoreManager";
    private static final String ACTION_OPEN_DOCUMENT_STORE = "openDatastore";
    private static final String ACTION_CLOSE_DOCUMENT_STORE = "closeDatastore";
    private static final String ACTION_DELETE_DOCUMENT_STORE = "deleteDatastore";
    private static final String ACTION_CREATE_OR_UPDATE_DOCUMENT_FROM_REVISION = "createOrUpdateDocumentFromRevision";
    private static final String ACTION_GET_DOCUMENT = "getDocument";
    private static final String ACTION_DELETE_DOCUMENT_FROM_REVISION = "deleteDocumentFromRevision";
    private static final String ACTION_ENSURE_INDEXED = "ensureIndexed";
    private static final String ACTION_DELETE_INDEX_NAMED = "deleteIndexNamed";
    private static final String ACTION_FIND = "find";
    private static final String ACTION_CREATE_REPLICATOR = "createReplicator";
    private static final String ACTION_DESTROY_REPLICATOR = "destroyReplicator";
    private static final String ACTION_START_REPLICATION = "startReplication";
    private static final String ACTION_STOP_REPLICATION = "stopReplication";
    private static final String ACTION_GET_REPLICATION_STATUS = "getReplicationStatus";
    private static final String ACTION_UNLOCK_INTERCEPTOR = "unlockInterceptor";
    private static final String ACTION_GET_CONFLICTED_DOCUMENT_IDS = "getConflictedDocumentIds";
    private static final String ACTION_RESOLVE_CONFLICTS_FOR_DOCUMENT = "resolveConflictsForDocument";
    private static final String ACTION_RETURN_RESOLVED_DOCUMENT = "returnResolvedDocument";

    private static final String DOCUMENT_STORE_NAME = "name";

    private static final String DOC_ID = "_id";
    private static final String DOC_REV = "_rev";
    private static final String DOC_DELETED = "_deleted";
    private static final String DOC_ATTACHMENTS = "_attachments";
    private static final String DOC_ATTACHMENTS_CONTENT_TYPE = "content_type";
    private static final String DOC_ATTACHMENTS_DATA = "data";

    private static final String REPLICATOR_TOKEN = "token";
    private static final String REPLICATOR_DOCUMENT_STORE = "datastore";
    private static final String REPLICATOR_URI = "uri";
    private static final String REPLICATOR_TYPE = "type";

    private static final String SQLITEDATABASE_CANONICAL_NAME = "net.sqlcipher.database.SQLiteDatabase";
    private static final String SQLITEDATABASE_LOADLIBS_METHOD_NAME = "loadLibs";

    private static final String DEFAULT_DOCUMENT_STORE_DIR_NAME = "CloudantSync";

    private static Map<String, DocumentStore> documentStores = Collections
            .synchronizedMap(new HashMap<String, DocumentStore>());
    private static Map<String, Query> queries = Collections.synchronizedMap(new HashMap<String, Query>());
    private static Map<Integer, Replicator> replicators = Collections
            .synchronizedMap(new HashMap<Integer, Replicator>());
    private static Map<Integer, SyncPluginInterceptor> interceptors = Collections
            .synchronizedMap(new HashMap<Integer, SyncPluginInterceptor>());
    private static Map<Integer, String> documentStorePaths = Collections
            .synchronizedMap(new HashMap<Integer, String>());
    private static Map<String, ConflictResolverWrapper> resolverMap = Collections
            .synchronizedMap(new HashMap<String, ConflictResolverWrapper>());

    private class ConflictResolverWrapper implements ConflictResolver {

        private CallbackContext callbackContext;
        private DocumentRevision documentRevision;
        private boolean conflictResolutionComplete;

        ConflictResolverWrapper(CallbackContext callbackContext) {
            this.callbackContext = callbackContext;
        }

        void setRevision(DocumentRevision revision) {
            synchronized (this) {
                documentRevision = revision;
                conflictResolutionComplete = true;
                this.notifyAll();
            }
        }

        public DocumentRevision resolve(String docId, List<? extends DocumentRevision> conflicts) {
            try {
                JSONArray jsonConflicts = new JSONArray();
                for (DocumentRevision docRev : conflicts) {
                    jsonConflicts.put(buildJSON(docRev, false));
                }

                JSONObject jsonObject = new JSONObject();
                jsonObject.put("docId", docId);
                jsonObject.put("conflicts", jsonConflicts);
                jsonObject.put("resolverId", callbackContext.getCallbackId());

                // Send the conflicts back to the javascript so that the conflicts can
                // be resolved in the javascript callback. Keep the callback alive
                // so we can also notify the caller of the end of conflict resolution.
                PluginResult r = new PluginResult(PluginResult.Status.OK, jsonObject);
                r.setKeepCallback(true);
                callbackContext.sendPluginResult(r);

                // Wait for the javascript to call us back with the result of the
                // conflict resolution.
                try {
                    synchronized (this) {
                        while (!this.conflictResolutionComplete) {
                            this.wait();
                        }
                    }
                } catch (InterruptedException e) {
                    Log.e(TAG, "Interrupted while waiting for callback during conflict resolution for: " + docId,
                            e);
                    return null;
                }

                // Update the document with the revised info we got back from the javascript.
                if (this.documentRevision != null) {
                    for (DocumentRevision docRev : conflicts) {
                        if (docRev.getRevision().equals(this.documentRevision.getRevision())) {
                            docRev.setBody(this.documentRevision.getBody());
                            docRev.setAttachments(this.documentRevision.getAttachments());
                            return docRev;
                        }
                    }
                    Log.e(TAG, "Unable to find revision matching " + this.documentRevision.getRevision()
                            + " in conflicts for: " + docId);
                }
                return this.documentRevision;
            } catch (JSONException e) {
                Log.e(TAG, "JSONException occurred while attempting to resolve conflicts for: " + docId, e);
            } catch (IOException e) {
                Log.e(TAG, "IOException occurred while attempting to resolve conflicts for: " + docId, e);
            }
            return null;
        }
    }

    /**
     * Executes the request and returns PluginResult.
     *
     * @param action          The action to execute.
     * @param args            JSONArray of arguments for the plugin.
     * @param callbackContext The callback id used when calling back into JavaScript.
     * @return True if the action was valid, false if not.
     */
    public boolean execute(String action, JSONArray args, final CallbackContext callbackContext)
            throws JSONException {
        if (ACTION_SET_DOCUMENT_STORE_PATH.equals(action)) {
            setDocumentStorePath(args, callbackContext);
        } else if (ACTION_OPEN_DOCUMENT_STORE.equals(action)) {
            final int documentStorePathId = args.getInt(0);
            final String documentStoreName = JSONObject.NULL.equals(args.get(1)) ? null : args.getString(1);

            openDocumentStore(documentStorePathId, documentStoreName, callbackContext);

        } else if (ACTION_CLOSE_DOCUMENT_STORE.equals(action)) {
            closeDocumentStore(args, callbackContext);
        } else if (ACTION_DELETE_DOCUMENT_STORE.equals(action)) {
            final String name = JSONObject.NULL.equals(args.get(1)) ? null : args.getString(1);

            deleteDocumentStore(name, callbackContext);

        } else if (ACTION_CREATE_OR_UPDATE_DOCUMENT_FROM_REVISION.equals(action)) {
            final String documentStoreName = JSONObject.NULL.equals(args.get(0)) ? null : args.getString(0);
            final JSONObject docRev = JSONObject.NULL.equals(args.get(1)) ? null : args.getJSONObject(1);
            final boolean isCreate = JSONObject.NULL.equals(args.get(2)) ? null : args.getBoolean(2);

            createOrUpdateDocumentFromRevision(documentStoreName, docRev, callbackContext, isCreate);

        } else if (ACTION_GET_DOCUMENT.equals(action)) {
            final String documentStoreName = JSONObject.NULL.equals(args.get(0)) ? null : args.getString(0);
            final String docId = JSONObject.NULL.equals(args.get(1)) ? null : args.getString(1);

            getDocument(documentStoreName, docId, callbackContext);

        } else if (ACTION_DELETE_DOCUMENT_FROM_REVISION.equals(action)) {
            final String documentStoreName = JSONObject.NULL.equals(args.get(0)) ? null : args.getString(0);
            final JSONObject docRev = JSONObject.NULL.equals(args.get(1)) ? null : args.getJSONObject(1);

            deleteDocumentFromRevision(documentStoreName, docRev, callbackContext);

        } else if (ACTION_ENSURE_INDEXED.equals(action)) {
            final String documentStoreName = JSONObject.NULL.equals(args.get(0)) ? null : args.getString(0);
            final String indexName = JSONObject.NULL.equals(args.get(1)) ? null : args.getString(1);
            final JSONArray fields = JSONObject.NULL.equals(args.get(2)) ? null : args.getJSONArray(2);

            ensureIndexed(documentStoreName, fields, indexName, callbackContext);

        } else if (ACTION_DELETE_INDEX_NAMED.equals(action)) {
            final String documentStoreName = JSONObject.NULL.equals(args.get(0)) ? null : args.getString(0);
            final String indexName = JSONObject.NULL.equals(args.get(1)) ? null : args.getString(1);

            deleteIndexNamed(documentStoreName, indexName, callbackContext);

        } else if (ACTION_FIND.equals(action)) {
            final String documentStoreName = JSONObject.NULL.equals(args.get(0)) ? null : args.getString(0);
            final JSONObject query = JSONObject.NULL.equals(args.get(1)) ? null : args.getJSONObject(1);

            find(documentStoreName, query, callbackContext);

        } else if (ACTION_CREATE_REPLICATOR.equals(action)) {
            final JSONObject replicatorJson = JSONObject.NULL.equals(args.get(0)) ? new JSONObject()
                    : args.getJSONObject(0);
            final JSONObject documentStore = JSONObject.NULL.equals(replicatorJson.get(REPLICATOR_DOCUMENT_STORE))
                    ? new JSONObject()
                    : replicatorJson.getJSONObject(REPLICATOR_DOCUMENT_STORE);
            final String documentStoreName = JSONObject.NULL.equals(documentStore.get(DOCUMENT_STORE_NAME)) ? null
                    : documentStore.getString(DOCUMENT_STORE_NAME);
            final String remoteUrl = JSONObject.NULL.equals(replicatorJson.get(REPLICATOR_URI)) ? null
                    : replicatorJson.getString(REPLICATOR_URI);
            final String type = JSONObject.NULL.equals(replicatorJson.get(REPLICATOR_TYPE)) ? null
                    : replicatorJson.getString(REPLICATOR_TYPE);
            final Integer timestamp = JSONObject.NULL.equals(replicatorJson.get(REPLICATOR_TOKEN)) ? null
                    : replicatorJson.getInt(REPLICATOR_TOKEN);

            createReplicator(documentStoreName, remoteUrl, type, timestamp, callbackContext);

        } else if (ACTION_DESTROY_REPLICATOR.equals(action)) {
            final JSONObject replicatorJson = JSONObject.NULL.equals(args.get(0)) ? new JSONObject()
                    : args.getJSONObject(0);
            final Integer token = JSONObject.NULL.equals(replicatorJson.get(REPLICATOR_TOKEN)) ? null
                    : replicatorJson.getInt(REPLICATOR_TOKEN);

            destroyReplicator(token, callbackContext);

        } else if (ACTION_START_REPLICATION.equals(action)) {
            final JSONObject replicatorJson = JSONObject.NULL.equals(args.get(0)) ? new JSONObject()
                    : args.getJSONObject(0);
            final Integer token = JSONObject.NULL.equals(replicatorJson.get(REPLICATOR_TOKEN)) ? null
                    : replicatorJson.getInt(REPLICATOR_TOKEN);

            startReplication(token, callbackContext);

        } else if (ACTION_GET_REPLICATION_STATUS.equals(action)) {
            final JSONObject replicatorJson = JSONObject.NULL.equals(args.get(0)) ? new JSONObject()
                    : args.getJSONObject(0);
            final Integer token = JSONObject.NULL.equals(replicatorJson.get(REPLICATOR_TOKEN)) ? null
                    : replicatorJson.getInt(REPLICATOR_TOKEN);

            getReplicationStatus(token, callbackContext);

        } else if (ACTION_STOP_REPLICATION.equals(action)) {
            final JSONObject replicatorJson = JSONObject.NULL.equals(args.get(0)) ? new JSONObject()
                    : args.getJSONObject(0);
            final Integer token = JSONObject.NULL.equals(replicatorJson.get(REPLICATOR_TOKEN)) ? null
                    : replicatorJson.getInt(REPLICATOR_TOKEN);

            stopReplication(token, callbackContext);

        } else if (ACTION_UNLOCK_INTERCEPTOR.equals(action)) {
            final Integer token = JSONObject.NULL.equals(args.get(0)) ? null : args.getInt(0);
            final String type = JSONObject.NULL.equals(args.get(1)) ? null : args.getString(1);
            final JSONObject httpContext = JSONObject.NULL.equals(args.get(2)) ? null : args.getJSONObject(2);
            final Integer timeout = JSONObject.NULL.equals(args.get(3)) ? null : args.getInt(3);
            final String uuid = JSONObject.NULL.equals(args.get(4)) ? null : args.getString(4);

            unlockInterceptor(token, type, httpContext, timeout, uuid, callbackContext);
        } else if (ACTION_GET_CONFLICTED_DOCUMENT_IDS.equals(action)) {
            final String documentStoreName = JSONObject.NULL.equals(args.get(0)) ? null : args.getString(0);

            getConflictedDocumentIds(documentStoreName, callbackContext);
        } else if (ACTION_RESOLVE_CONFLICTS_FOR_DOCUMENT.equals(action)) {
            final String documentStoreName = JSONObject.NULL.equals(args.get(0)) ? null : args.getString(0);
            final String documentId = JSONObject.NULL.equals(args.get(1)) ? null : args.getString(1);

            resolveConflictsForDocument(documentStoreName, documentId, callbackContext);
        } else if (ACTION_RETURN_RESOLVED_DOCUMENT.equals(action)) {
            final JSONObject docRev = JSONObject.NULL.equals(args.get(0)) ? null : args.getJSONObject(0);
            final String resolverId = JSONObject.NULL.equals(args.get(1)) ? null : args.getString(1);

            returnResolvedDocument(docRev, resolverId, callbackContext);
        } else {
            return false;
        }
        return true;
    }

    ////////////////////////////////////////////////////////////////////////////////////////////////
    ////////////////////////////////////////////////////////////////////////////////////////////////
    ////////////////////////// JavaScript - Java mapped methods ////////////////////////////////////
    ////////////////////////////////////////////////////////////////////////////////////////////////
    ////////////////////////////////////////////////////////////////////////////////////////////////

    private void setDocumentStorePath(JSONArray args, final CallbackContext callbackContext) throws JSONException {
        final String path;
        if (JSONObject.NULL.equals(args.get(0))) {
            Context context = cordova.getActivity().getApplicationContext();
            File fp = context.getDir(DEFAULT_DOCUMENT_STORE_DIR_NAME, Context.MODE_PRIVATE);
            path = fp.getAbsolutePath();
        } else {
            path = args.getString(0);
        }

        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                int id = documentStorePaths.size();
                // Auto-boxing id.
                documentStorePaths.put(id, path);
                JSONObject response = new JSONObject();
                try {
                    response.put("id", id);
                } catch (JSONException e) {
                    // This should never happen, terminate
                    throw new RuntimeException(e);
                }
                callbackContext.success(response);
            }
        });
    }

    /**
     * Opens a DocumentStore with the specified name
     * @param documentStoreName - The name of the DocumentStore to open
     * @param callbackContext - The javascript callback to execute when complete or errored
     */
    private void openDocumentStore(final int documentStorePathId, final String documentStoreName,
            final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                try {
                    String path = documentStorePaths.get(documentStorePathId);
                    DocumentStore ds = DocumentStore.getInstance(new File(path, documentStoreName));
                    documentStores.put(documentStoreName, ds);
                    JSONObject r = new JSONObject();
                    r.put("name", documentStoreName);
                    callbackContext.success(r);
                } catch (Exception e) {
                    callbackContext.error(e.getMessage());
                }
            }
        });
    }

    private void closeDocumentStore(final JSONArray args, final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                try {
                    DocumentStore ds = documentStores.remove(args.getString(0));
                    if (ds == null) {
                        callbackContext.error("DocumentStore is not open");
                        return;
                    }

                    ds.close();
                    callbackContext.success();
                } catch (Exception e) {
                    callbackContext.error("DocumentStore could not be closed");
                }
            }
        });
    }

    /**
     * Deletes a DocumentStore with the specified name
     * @param documentStoreName - The name of the document store to delete
     * @param callbackContext - The javascript callback to execute when complete or errored
     */
    private void deleteDocumentStore(final String documentStoreName, final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                if (documentStoreName == null) {
                    callbackContext.error("DocumentStore name cannot be null");
                } else {

                    // Clear from cache
                    try {
                        DocumentStore ds = getDocumentStore(documentStoreName);
                        ds.delete();
                    } catch (Exception e) {
                        Log.e(TAG, "Error deleting from disk DocumentStore: " + documentStoreName, e);
                    }

                    documentStores.remove(documentStoreName);
                    queries.remove(documentStoreName);

                    callbackContext.success();
                }
            }
        });
    }

    /**
     * Creates or updates a document from a given revision
     * @param documentStoreName - The name of the DocumentStore
     * @param docRev - The JSON document revision to save
     * @param callbackContext - The javascript callback to execute when complete or errored
     * @param isCreate - if true, indicates we are creating a document and if false, indicates
     *        we are updating a document.
     */
    private void createOrUpdateDocumentFromRevision(final String documentStoreName, final JSONObject docRev,
            final CallbackContext callbackContext, final boolean isCreate) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                try {
                    DocumentStore ds = getDocumentStore(documentStoreName);
                    DocumentRevision rev = buildDocRevision(docRev);

                    DocumentRevision result;

                    if (isCreate) {
                        result = ds.database().create(rev);
                    } else {
                        result = ds.database().update(rev);
                    }
                    JSONObject r = buildJSON(result, isCreate);
                    callbackContext.success(r);
                } catch (Exception e) {
                    callbackContext.error(e.getMessage());
                }
            }
        });
    }

    /**
     * Fetches a document revision
     * @param documentStoreName - The name of the DocumentStore
     * @param docId - The ID of the document to fetch
     * @param callbackContext - The javascript callback to execute when complete or errored
     */
    private void getDocument(final String documentStoreName, final String docId,
            final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                try {
                    DocumentStore ds = getDocumentStore(documentStoreName);
                    DocumentRevision result = ds.database().read(docId);
                    JSONObject r = buildJSON(result, false);
                    callbackContext.success(r);
                } catch (Exception e) {
                    callbackContext.error(e.getMessage());
                }
            }
        });
    }

    /**
     * Deletes a document revision
     * @param documentStoreName - The name of the DocumentStore
     * @param docRev - The ID of the document to delete
     * @param callbackContext - The javascript callback to execute when complete or errored
     */
    private void deleteDocumentFromRevision(final String documentStoreName, final JSONObject docRev,
            final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                try {
                    DocumentStore ds = getDocumentStore(documentStoreName);
                    DocumentRevision rev = buildDocRevision(docRev);

                    DocumentRevision deletedRevision = ds.database().delete(rev);
                    callbackContext.success(buildJSON(deletedRevision, false));
                } catch (Exception e) {
                    callbackContext.error(e.getMessage());
                }
            }
        });
    }

    /**
     * Adds a single, possibly compound, index for the given field names
     * @param documentStoreName - The name of the DocumentStore
     * @param fields - The list of fields to index
     * @param indexName - The name of the index
     * @param callbackContext - The javascript callback to execute when complete or errored
     */
    private void ensureIndexed(final String documentStoreName, final JSONArray fields, final String indexName,
            final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                try {
                    if (indexName == null || fields == null) {
                        throw new Exception("indexName and fields cannot be null");
                    }

                    List<FieldSort> indexFields = new ArrayList<FieldSort>();
                    for (int i = 0; i < fields.length(); i++) {
                        indexFields.add(new FieldSort((String) fields.get(i)));
                    }

                    DocumentStore ds = getDocumentStore(documentStoreName);
                    ds.query().createJsonIndex(indexFields, indexName);
                    callbackContext.success(indexName);
                } catch (Exception e) {
                    callbackContext.error(e.getMessage());
                }
            }
        });
    }

    /**
     * Deletes an index
     * @param documentStoreName - The name of the DocumentStore
     * @param indexName - The name of the index to delete
     * @param callbackContext - The javascript callback to execute when complete or errored
     */
    private void deleteIndexNamed(final String documentStoreName, final String indexName,
            final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                try {
                    if (indexName == null) {
                        throw new Exception("indexName cannot be null");
                    }

                    DocumentStore ds = getDocumentStore(documentStoreName);
                    ds.query().deleteIndex(indexName);
                    PluginResult r = new PluginResult(PluginResult.Status.OK, true);
                    callbackContext.sendPluginResult(r);
                } catch (Exception e) {
                    callbackContext.error(e.getMessage());
                }
            }
        });
    }

    /**
     * Queries the DocumentStore
     * @param documentStoreName - The name of the DocumentStore
     * @param query - The Cloudant Query to execute
     * @param callbackContext - The javascript callback to execute when complete or errored
     */
    private void find(final String documentStoreName, final JSONObject query,
            final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                try {
                    if (query == null) {
                        throw new Exception("query object cannot be null");
                    }

                    CloudantQuery q = new CloudantQuery(convertJSONtoMap(query));
                    DocumentStore ds = getDocumentStore(documentStoreName);
                    List<FieldSort> sortSpec = null;
                    if (q.getSort() != null) {
                        sortSpec = new ArrayList<FieldSort>();
                        for (Map<String, String> sortMap : q.getSort()) {
                            for (Map.Entry<String, String> entry : sortMap.entrySet()) {
                                FieldSort.Direction direction;
                                if ("asc".equals(entry.getValue())) {
                                    direction = FieldSort.Direction.ASCENDING;
                                } else if ("desc".equals(entry.getValue())) {
                                    direction = FieldSort.Direction.DESCENDING;
                                } else {
                                    throw new Exception("Sort direction must be either \"asc\" or "
                                            + "\"desc\". Got: " + entry.getValue());
                                }
                                sortSpec.add(new FieldSort(entry.getKey(), direction));
                            }
                        }
                    }
                    QueryResult qr = ds.query().find(q.getSelector(), q.getSkip(), q.getLimit(), q.getFields(),
                            sortSpec);

                    JSONArray r = new JSONArray();
                    if (qr != null) {
                        for (DocumentRevision rev : qr) {
                            JSONObject jsonDoc = buildJSON(rev, false);
                            r.put(jsonDoc);
                        }
                    }
                    callbackContext.success(r);
                } catch (Exception e) {
                    callbackContext.error(e.getMessage());
                }
            }
        });
    }

    /**
     * Creates a new Replicator
     * @param documentStoreName - The name of the DocumentStore
     * @param remoteURI - The remote database URI
     * @param type - The type of replication to be performed
     * @param token - The unique token id of the Replicator
     * @param callbackContext - The javascript callback to execute when complete or errored
     */
    private void createReplicator(final String documentStoreName, final String remoteURI, final String type,
            final Integer token, final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                try {
                    DocumentStore ds = getDocumentStore(documentStoreName);

                    if (remoteURI == null || type == null || token == null) {
                        throw new Exception("Replicator uri, type, and token must not be null");
                    }

                    URI uri;
                    try {
                        uri = new URI(remoteURI);
                    } catch (URISyntaxException e) {
                        throw new Exception("Invalid uri: " + remoteURI);
                    }

                    Replicator replicator;
                    final SyncPluginInterceptor interceptor = new SyncPluginInterceptor(callbackContext);
                    if (type.equals("push")) {
                        replicator = ReplicatorBuilder.push().to(uri).from(ds)
                                .addRequestInterceptors((HttpConnectionRequestInterceptor) interceptor)
                                .addResponseInterceptors((HttpConnectionResponseInterceptor) interceptor).build();
                    } else if (type.equals("pull")) {
                        replicator = ReplicatorBuilder.pull().from(uri).to(ds)
                                .addRequestInterceptors((HttpConnectionRequestInterceptor) interceptor)
                                .addResponseInterceptors((HttpConnectionResponseInterceptor) interceptor).build();
                    } else {
                        throw new Exception("Replicator 'type' must be either 'push' or 'pull'. Received: " + type);
                    }

                    if (replicator == null) {
                        throw new Exception("Failed to create " + type + " Replicator. Builder returned null");
                    }

                    replicator.getEventBus().register(new SyncPluginListener(callbackContext));

                    replicators.put(token, replicator);
                    interceptors.put(token, interceptor);

                    PluginResult pluginResult = new PluginResult(PluginResult.Status.OK);
                    pluginResult.setKeepCallback(true);
                    callbackContext.sendPluginResult(pluginResult);

                } catch (Exception e) {
                    callbackContext.error(e.getMessage());
                }
            }
        });
    }

    /**
     * Removes a Replicator from the replicators cache
     * @param token - The unique token id of the Replicator
     * @param callbackContext - The javascript callback to execute when complete or errored
     */
    private void destroyReplicator(final Integer token, final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                Replicator replicator = replicators.remove(token);
                interceptors.remove(token);

                if (replicator != null) {
                    callbackContext.success();
                } else {
                    callbackContext.error("Replicator with token " + token + " already destroyed.");
                }
            }
        });
    }

    /**
     * Starts replication
     * @param token - The unique token id of the Replicator
     * @param callbackContext - The javascript callback to execute when complete or errored
     */
    private void startReplication(final Integer token, final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                Replicator replicator = replicators.get(token);

                if (replicator == null) {
                    callbackContext.error("Cannot start replicator with timestamp: " + token + ". Does not exist.");
                } else {
                    replicator.start();
                    callbackContext.success();
                }
            }
        });
    }

    /**
     * Gets current replication state
     * @param token - The unique token id of the Replicator
     * @param callbackContext - The javascript callback to execute when complete or errored
     */
    private void getReplicationStatus(final Integer token, final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                Replicator replicator = replicators.get(token);

                if (replicator == null) {
                    callbackContext.error(
                            "Cannot get status for replicator with timestamp: " + token + ". Does not exist.");
                } else {
                    callbackContext.success(convertReplicationStateToString(replicator.getState()));
                }
            }
        });
    }

    /**
     * Stops replication
     * @param token - The unique token id of the Replicator
     * @param callbackContext - The javascript callback to execute when complete or errored
     */
    private void stopReplication(final Integer token, final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                Replicator replicator = replicators.get(token);

                if (replicator == null) {
                    callbackContext.error("Cannot stop replicator with token: " + token + ". Does not exist.");
                } else {
                    replicator.stop();
                    callbackContext.success(convertReplicationStateToString(Replicator.State.STOPPING));
                }
            }
        });
    }

    private void unlockInterceptor(final Integer token, final String type, final JSONObject httpContext,
            final Integer timeout, final String uuid, final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                SyncPluginInterceptor interceptor = interceptors.get(token);

                if (interceptor == null) {
                    callbackContext.error("Cannot unlock interceptor with token: " + token + ". Does not exist.");
                } else {
                    if (timeout != null) {
                        Log.e(TAG, type + " interceptors for replicator with token " + token
                                + " timed out in the JavaScript layer after " + timeout + "ms.");
                    }

                    interceptor.updateContext(uuid, httpContext);

                    callbackContext.success();
                }
            }
        });
    }

    /**
     * Gets the IDs of documents with conflicts
     * @param documentStoreName - The name of the DocumentStore
     * @param callbackContext - The javascript callback to execute when complete or errored
     */
    private void getConflictedDocumentIds(final String documentStoreName, final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                try {
                    DocumentStore ds = getDocumentStore(documentStoreName);

                    Iterable<String> conflicts = ds.database().getConflictedIds();

                    JSONArray r = new JSONArray();
                    for (String id : conflicts) {
                        r.put(id);
                    }
                    callbackContext.success(r);
                } catch (Exception e) {
                    callbackContext.error(e.getMessage());
                }
            }
        });
    }

    private void resolveConflictsForDocument(final String documentStoreName, final String documentId,
            final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                try {
                    DocumentStore ds = getDocumentStore(documentStoreName);

                    ConflictResolverWrapper conflictResolver = new ConflictResolverWrapper(callbackContext);

                    // Store the conflictResolver in a map indexed by a unique ID that can be passed around
                    // to retrieve the conflictResolver from other callbacks. The callbackId is unique
                    // to each invocation of this method so we'll use that.
                    resolverMap.put(callbackContext.getCallbackId(), conflictResolver);
                    ds.database().resolveConflicts(documentId, conflictResolver);
                    PluginResult r = new PluginResult(PluginResult.Status.OK);
                    callbackContext.sendPluginResult(r);

                } catch (Exception e) {
                    callbackContext.error(e.getMessage());
                }
            }
        });
    }

    private void returnResolvedDocument(final JSONObject docRev, final String resolverId,
            final CallbackContext callbackContext) {
        cordova.getThreadPool().execute(new Runnable() {
            @Override
            public void run() {
                try {
                    ConflictResolverWrapper conflictResolver = resolverMap.remove(resolverId);
                    DocumentRevision revision = docRev == null ? null : buildDocRevision(docRev);
                    conflictResolver.setRevision(revision);

                    PluginResult r = new PluginResult(PluginResult.Status.OK);
                    callbackContext.sendPluginResult(r);
                } catch (Exception e) {
                    callbackContext.error(e.getMessage());
                }
            }
        });
    }

    ////////////////////////////////////////////////////////////////////////////////////////////////
    ////////////////////////////////////////////////////////////////////////////////////////////////
    ////////////////////////// Internal utility methods ////////////////////////////////////////////
    ////////////////////////////////////////////////////////////////////////////////////////////////
    ////////////////////////////////////////////////////////////////////////////////////////////////

    /**
     * @param name - The DocumentStore name
     * @return - The DocumentStore object stored in the documentStores Map
     * @throws Exception - If no DocumentStore value is associated with key 'name'
     */
    private DocumentStore getDocumentStore(String name) throws Exception {
        if (name == null) {
            throw new Exception("Name cannot be null");
        }

        DocumentStore store = documentStores.get(name);

        if (store == null) {
            throw new Exception("No DocumentStore found with name: " + name);
        }

        return store;
    }

    /**
     * @param obj - The JSONObject to transform
     * @return - The Map from the converted JSONObject
     * @throws JSONException
     */
    private byte[] getDocumentBodyBytesFromJSONObject(JSONObject obj) throws JSONException {
        // These special fields are handled separately from the rest of the body
        obj.remove(DOC_ID);
        obj.remove(DOC_REV);
        obj.remove(DOC_DELETED);

        return obj.toString().getBytes(Charset.forName("UTF-8"));
    }

    /**
     * @param docRevisionJSON - The JSONObject to transform
     * @return - The DocumentRevision from the converted JSONObject
     * @throws Exception
     */
    private DocumentRevision buildDocRevision(JSONObject docRevisionJSON) throws Exception {
        if (docRevisionJSON == null)
            throw new Exception("Document revision cannot be null");

        String docId = null;
        String docRev = null;
        DocumentBody body;

        if (docRevisionJSON.has(DOC_ID)) {
            docId = (String) docRevisionJSON.get(DOC_ID);
        }

        if (docRevisionJSON.has(DOC_REV)) {
            docRev = (String) docRevisionJSON.get(DOC_REV);
        }

        DocumentRevision revision = new DocumentRevision(docId, docRev);

        if (docRevisionJSON.has(DOC_DELETED) && docRevisionJSON.getBoolean(DOC_DELETED)) {
            revision.setDeleted();
        }

        Map<String, Attachment> attachmentMap = null;
        if (docRevisionJSON.has(DOC_ATTACHMENTS)) {

            JSONObject attachments = docRevisionJSON.getJSONObject(DOC_ATTACHMENTS);
            docRevisionJSON.remove(DOC_ATTACHMENTS);
            attachmentMap = new HashMap<String, Attachment>();

            Iterator<String> keys = attachments.keys();
            while (keys.hasNext()) {
                String name = keys.next();
                JSONObject attachment = attachments.getJSONObject(name);

                String contentType = attachment.getString(DOC_ATTACHMENTS_CONTENT_TYPE);
                String data = attachment.getString("data");
                byte[] bytes = Base64.decode(data, Base64.NO_WRAP);

                UnsavedStreamAttachment streamAttachment = new UnsavedStreamAttachment(
                        new ByteArrayInputStream(bytes), contentType);
                attachmentMap.put(name, streamAttachment);
            }
            revision.setAttachments(attachmentMap);
        }

        revision.setBody(DocumentBodyFactory.create(getDocumentBodyBytesFromJSONObject(docRevisionJSON)));

        return revision;
    }

    /**
     * @param rev - The DocumentRevision to transform
     * @return - The JSONObject from the converted DocumentRevision
     * @throws JSONException
     * @throws IOException
     */
    private JSONObject buildJSON(DocumentRevision rev, boolean isCreate) throws JSONException, IOException {
        // Create the basic document body in the result object
        JSONObject result = new JSONObject(rev.getBody().asMap());

        // Add the ID and rev
        result.put(DOC_ID, rev.getId());
        result.put(DOC_REV, rev.getRevision());

        if (!isCreate) {
            result.put(DOC_DELETED, rev.isDeleted());
        }

        if (!rev.isDeleted()) {
            Map<String, Attachment> attachmentMap = rev.getAttachments();
            if (attachmentMap != null && !attachmentMap.isEmpty()) {
                JSONObject attachments = new JSONObject();

                for (Map.Entry<String, Attachment> entry : attachmentMap.entrySet()) {
                    Attachment attachment = entry.getValue();

                    InputStream is = attachment.getInputStream();
                    byte[] bytes = IOUtils.toByteArray(is);
                    String data = Base64.encodeToString(bytes, Base64.NO_WRAP);

                    JSONObject attachmentJSON = new JSONObject();
                    attachmentJSON.put(DOC_ATTACHMENTS_CONTENT_TYPE, attachment.type);
                    attachmentJSON.put(DOC_ATTACHMENTS_DATA, data);

                    attachments.put(entry.getKey(), attachmentJSON);
                }

                result.put(DOC_ATTACHMENTS, attachments);
            }
        }

        return result;
    }

    /**
     * This transformer method performs a deeper conversion
     *
     * @param json - The JSONObject to transform
     * @return - The Map from the converted JSONObject
     * @throws JSONException
     */
    private Map<String, Object> convertJSONtoMap(JSONObject json) throws JSONException {
        Map<String, Object> ret = new HashMap<String, Object>();

        if (json != JSONObject.NULL) {
            ret = toMap(json);
        }

        return ret;
    }

    /**
     * Utility method for convertJSONtoMap(). This converts nested JSONObjects & JSONArrays to Maps and Lists
     *
     * @param json - The JSONObject to transform
     * @return - The Map from the converted JSONObject
     * @throws JSONException
     */
    private Map<String, Object> toMap(JSONObject json) throws JSONException {
        Map<String, Object> ret = new HashMap<String, Object>();

        Iterator<String> iter = json.keys();

        while (iter.hasNext()) {
            String key = iter.next();
            Object value = json.get(key);

            if (value instanceof JSONObject) {
                value = toMap((JSONObject) value);
            } else if (value instanceof JSONArray) {
                value = toList((JSONArray) value);
            }
            ret.put(key, value);
        }

        return ret;
    }

    /**
     * Utility method for convertJSONtoMap(). This converts nested JSONObjects & JSONArrays to Maps and Lists
     *
     * @param json - The JSONArray to transform
     * @return - The List from the converted JSONArray
     * @throws JSONException
     */
    private List<Object> toList(JSONArray json) throws JSONException {
        List<Object> ret = new ArrayList<Object>();

        for (int i = 0; i < json.length(); i++) {
            Object value = json.get(i);

            if (value instanceof JSONObject) {
                value = toMap((JSONObject) value);
            } else if (value instanceof JSONArray) {
                value = toList((JSONArray) value);
            }
            ret.add(value);
        }

        return ret;
    }

    /**
     * @param state - The Replicator State to transform
     * @return - The String from the converted Replicator State
     */
    private String convertReplicationStateToString(Replicator.State state) {
        switch (state) {
        case COMPLETE:
            return "Complete";
        case ERROR:
            return "Error";
        case PENDING:
            return "Pending";
        case STARTED:
            return "Started";
        case STOPPED:
            return "Stopped";
        case STOPPING:
            return "Stopping";
        default:
            return "Unknown";
        }
    }

}