com.eTilbudsavis.etasdk.SyncManager.java Source code

Java tutorial

Introduction

Here is the source code for com.eTilbudsavis.etasdk.SyncManager.java

Source

/*******************************************************************************
* Copyright 2014 eTilbudsavis
* 
* 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.eTilbudsavis.etasdk;

import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Stack;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import android.os.Handler;
import android.os.HandlerThread;
import android.os.Process;

import com.eTilbudsavis.etasdk.SessionManager.OnSessionChangeListener;
import com.eTilbudsavis.etasdk.log.EtaLog;
import com.eTilbudsavis.etasdk.log.SyncLog;
import com.eTilbudsavis.etasdk.model.Share;
import com.eTilbudsavis.etasdk.model.Shoppinglist;
import com.eTilbudsavis.etasdk.model.ShoppinglistItem;
import com.eTilbudsavis.etasdk.model.User;
import com.eTilbudsavis.etasdk.model.interfaces.SyncState;
import com.eTilbudsavis.etasdk.network.Delivery;
import com.eTilbudsavis.etasdk.network.EtaError;
import com.eTilbudsavis.etasdk.network.EtaError.Code;
import com.eTilbudsavis.etasdk.network.Request;
import com.eTilbudsavis.etasdk.network.Request.Method;
import com.eTilbudsavis.etasdk.network.RequestQueue;
import com.eTilbudsavis.etasdk.network.Response.Listener;
import com.eTilbudsavis.etasdk.network.impl.HandlerDelivery;
import com.eTilbudsavis.etasdk.network.impl.JsonArrayRequest;
import com.eTilbudsavis.etasdk.network.impl.JsonObjectRequest;
import com.eTilbudsavis.etasdk.utils.Api;
import com.eTilbudsavis.etasdk.utils.Api.Endpoint;
import com.eTilbudsavis.etasdk.utils.Api.Param;
import com.eTilbudsavis.etasdk.utils.ListUtils;
import com.eTilbudsavis.etasdk.utils.Utils;

/**
 * The {@link SyncManager} class performs asynchronous synchronization with the
 * eTilbudsavis API, to propagate all {@link Shoppinglist} and {@link ShoppinglistItem}
 * changes that a user may have done in the {@link DbHelper database}.
 * 
 * <p>
 * Notifications about {@link Shoppinglist} and {@link ShoppinglistItem}
 * changes, are relayed through the subscriber system in the {@link ListManager}.
 * </p>
 * 
 * <p>
 * There are four types of synchronization that will be performed:
 * 
 * <ul>
 * <li>
 * Local changes - Sending local changes to the API has precedence over the other
 * types of synchronization.
 * </li>
 * 
 * <li>
 * {@link Shoppinglist#getModified()} - This is the default action, this will be
 * performed if no other type of synchronization is chosen. This in practice
 * means that this will be performed on almost every iteration. If there is any
 * changes in the modified, both the {@link Shoppinglist} and it's
 * {@link ShoppinglistItem ShoppinglistItems} will be synchronized.
 * </li>
 * 
 * <li>
 * {@link Shoppinglist} - Shoppinglists will be performed on every 3rd
 * iteration the {@link SyncManager} does. As there are less chance of adding,
 * and removing {@link Shoppinglist Shoppinglists} than that of having a change
 * to an existing {@link Shoppinglist} (which will be handled by the synchronization
 * option above.
 * </li>
 * 
 * <li>
 * Full sync loop - The full sync loop is performed on every 10th iteration, and
 * ensures that the state is completely up to date. The local state can become
 * decoupled from the server state in certain situations, where we are not able
 * to determine and fix the situation in any other way, e.g.: If two devices
 * makes a change to a list at the exact same second, and both devices therefore
 * has a valid/correct {@link Shoppinglist#getModified() modified}.
 * </li>
 * </ul>
 * 
 * </p>
 * 
 * <p>
 * When {@link #onPause()} is triggered, all local pending changes are pushed to
 * the API if possible to ensure a correct state on the server (and other devices).
 * </p>
 * @author Danny Hvam - danny@etilbudsavis.dk
 *
 */
public class SyncManager {

    public static final String TAG = Eta.TAG_PREFIX + SyncManager.class.getSimpleName();

    private static final boolean SAVE_NETWORK_LOG = false;
    private static final boolean LOG_SYNC = false;
    private static final boolean LOG = false;

    /** Supported sync speeds for {@link SyncManager} */
    public interface SyncSpeed {
        int SLOW = 10000;
        int MEDIUM = 6000;
        int FAST = 3000;
    }

    /** The current sync speed */
    private int mSyncSpeed = 3000;

    /** Sync iteration counter */
    private int mSyncCount = 0;

    /** Has sync got the first sync */
    private boolean mHasFirstSync = false;

    private Stack<Request<?>> mCurrentRequests = new Stack<Request<?>>();

    /** Reference to the {@link Eta} object */
    private Eta mEta;

    /** Thread running the options */
    HandlerThread mThread;

    /** The Handler instantiated on the sync Thread */
    private Handler mHandler;

    /** A user object, for identifying the current user during a request cycle */
    private User mUser;

    /** 
     * Variable to determine if offline lists should automatically be
     * synchronized if certain criterias are met.
     */
    private boolean mMigrateOfflineLists = false;

    /** 
     * A tag for identifying all requests originating from this {@link SyncManager}
     * in the {@link RequestQueue}
     */
    private Object mRequestTag = new Object();

    /** The notification object, used to combine and collect notifications */
    private ListNotification mNotification = new ListNotification(true);

    private Delivery mDelivery;

    /** Listening for session changes, starting and stopping sync as needed */
    private OnSessionChangeListener sessionListener = new OnSessionChangeListener() {

        public void onChange() {
            if (mUser == null || mUser.getUserId() != mEta.getUser().getUserId()) {
                mHasFirstSync = false;
                mSyncCount = 0;
                forceSync();
            }
        }
    };

    /** The actual sync loop running every x seconds*/
    private Runnable mSyncLoop = new Runnable() {

        public void run() {

            mUser = mEta.getUser();

            // If it's an offline user, then just quit it
            if (!mUser.isLoggedIn()) {
                SyncLog.sync(TAG, "SyncManager(" + mSyncCount + ") - skip-loop-cycle (NotLoggedIn)");
                return;
            }

            /* 
             * Prepare for next iteration if app is still resumed.
             * By not doing a return statement we allow for a final sync, and
             * sending local changes to server
             */
            if (mEta.isResumed()) {
                mHandler.postDelayed(mSyncLoop, mSyncSpeed);
            }

            // Only do an update, if there are no pending transactions, and we are online
            if (!mCurrentRequests.isEmpty()) {
                SyncLog.sync(TAG, "SyncManager(" + mSyncCount + ") - skip-loop-cycle (ReqInFlight)");
                return;
            }
            if (!mEta.isOnline()) {
                SyncLog.sync(TAG, "SyncManager(" + mSyncCount + ") - skip-loop-cycle (Offline)");
                return;
            }
            if (isPaused()) {
                SyncLog.sync(TAG, "SyncManager(" + mSyncCount + ") - skip-loop-cycle (Paused)");
                return;
            }

            // Perform the actual sync cycle
            performSyncCycle(mUser);

        }

    };

    private void performSyncCycle(User user) {

        // If there are local changes to a list, then syncLocalListChanges will handle it: return
        List<Shoppinglist> lists = DbHelper.getInstance().getLists(mEta.getUser(), true);
        if (syncLocalListChanges(lists, user)) {
            SyncLog.sync(TAG, "SyncManager(" + mSyncCount + ") - syncLocalListChanges");
            return;
        }

        // If there are changes to any items, then syncLocalItemChanges will handle it: return
        boolean hasLocalChanges = false;
        for (Shoppinglist sl : lists) {
            hasLocalChanges = syncLocalItemChanges(sl, user) || hasLocalChanges;
            hasLocalChanges = syncLocalShareChanges(sl, user) || hasLocalChanges;
        }

        // Skip further sync if we just posted our own changes
        if (hasLocalChanges) {
            //         pauseSync();
            //         mHandler.postDelayed(new Runnable() {
            //            
            //            public void run() {
            //               resumeSync();
            //            }
            //         }, 3500);
            SyncLog.sync(TAG, "SyncManager(" + mSyncCount + ") - hasLocalChanges");
            return;
        }

        // Finally ready to get server changes
        if (mSyncCount % 3 == 0) {

            // Get a new set of lists
            SyncLog.sync(TAG, "SyncManager(" + mSyncCount + ") - syncAllLists");
            syncLists(user);

        } else if (mSyncCount % 10 == 0) {

            SyncLog.sync(TAG, "SyncManager(" + mSyncCount + ") - syncAllItems");
            /* Because we update modified on lists, on all changes, we might
             * have a situation where two devices have set the same modified
             * on a list, and therefore won't try to get a new list of items
             * So we force the SDK to get a new set once in a while */
            List<Shoppinglist> localLists = DbHelper.getInstance().getLists(user);
            for (Shoppinglist sl : localLists) {
                syncItems(sl, user);
            }

        } else {

            SyncLog.sync(TAG, "SyncManager(" + mSyncCount + ") - checkModified");
            // Base case, just check if there is changes
            syncListsModified(user);

        }
        mSyncCount++;

    }

    Object RESUME_LOCK = new Object();

    boolean isPaused = false;

    public boolean isPaused() {
        synchronized (RESUME_LOCK) {
            return isPaused;
        }
    }

    public void pauseSync() {
        synchronized (RESUME_LOCK) {
            isPaused = true;
        }
    }

    public void resumeSync() {
        synchronized (RESUME_LOCK) {
            isPaused = false;
        }
    }

    /**
     * Default constructor for the {@link SyncManager}
     * @param eta An Eta instance
     */
    public SyncManager(Eta eta) {
        mEta = eta;
        SyncLog.setLog(LOG);
        SyncLog.setLogSync(LOG_SYNC);
        // Create a new thread for a handler, so that i can later post content to that thread.
        mThread = new HandlerThread(TAG, Process.THREAD_PRIORITY_BACKGROUND);
        mThread.start();
        mHandler = new Handler(mThread.getLooper());
        mDelivery = new HandlerDelivery(mHandler);
    }

    /**
     * Method for determining if the first sync cycle is done.
     * 
     * <p>This is dependent on, both:
     * <ul>
     *       <li>the {@link SyncManager} having performed the first sync cycle, and </li>
     * </ul>
     * </p>
     * @return True if the first sync is complete, or there is no user to sync.
     */
    public boolean hasFirstSync() {
        return mHasFirstSync;
    }

    /**
     * Method for forcing a new synchronization iteration.
     * <p>This method will trigger the synchronization loop, so it's essentially
     * also the method for starting the {@link SyncManager} sync loop</p>
     * 
     * <p>This method should only be used in special cases, as the
     * synchronization is (within reasonably time intervals) being handled
     * automatically by the {@link SyncManager}</p>
     */
    public void forceSync() {
        // First make sure, that we do not leak memory by posting the runnable multiple times
        mHandler.removeCallbacks(mSyncLoop);
        mHandler.post(mSyncLoop);
    }

    /**
     * Method to call on all onResume events.
     * <p>This is implicitly handled by the {@link Eta} instance</p>
     */
    public void onResume() {
        mEta.getSessionManager().subscribe(sessionListener);
        forceSync();
    }

    /**
     * Method to call on all onPause events.
     * <p>This is implicitly handled by the {@link Eta} instance</p>
     */
    public void onPause() {
        forceSync();
        mEta.getSessionManager().unSubscribe(sessionListener);
        mHasFirstSync = false;
        mSyncCount = -1;
    }

    /**
     * Set synchronization interval for {@link Shoppinglist}, and {@link ShoppinglistItem}.
     * 
     * <p>Also time must be greater than or equal to 3000 (milliseconds)</p>
     * @param time A synchronization interval in milliseconds
     */
    public void setSyncSpeed(int time) {
        if (time == SyncSpeed.SLOW || time == SyncSpeed.MEDIUM || time == SyncSpeed.FAST) {
            mSyncSpeed = time;
        }
    }

    /**
     * Method for easily migrating "offline" lists to "online" lists.
     * 
     * <p>If true, the {@link SyncManager} ensures the merging of any
     * (non-empty) "offline" lists, into a new "online" user's
     * {@link Shoppinglist shoppinglists}, on the first completed sync cycle.</p>
     * <p>
     * (a user is considered a "new" if he/she haven't got any lists on the
     * server already)</p>
     * 
     * @param migrate {@code true} if to do automatic migration of offline
     *                 {@link Shoppinglist shoppinglists}, else {@code false}
     */
    public void setMigrateOfflineLists(boolean migrate) {
        mMigrateOfflineLists = migrate;
    }

    /**
     * True if "offline" {@link Shoppinglist} will be migrated on first sync with
     * a new "online" user's {@link Shoppinglist shoppinglists}.
     * @return
     */
    public boolean isMigratingOfflineLists() {
        return mMigrateOfflineLists;
    }

    private void addRequest(Request<?> r) {
        // No request from here should return a result from cache
        r.setIgnoreCache(true);
        synchronized (mCurrentRequests) {
            mCurrentRequests.add(r);
        }

        // Make sure, that requests will return to this thread
        r.setDelivery(mDelivery);

        r.setTag(mRequestTag);

        mEta.add(r);

        //      boolean isPullRequest = r.getUrl().contains("modified") || r.getUrl().endsWith("shoppinglists") || r.getUrl().endsWith("items");
        //      if (!isPullRequest && r.getMethod() != Request.Method.GET) {
        //         EtaLog.d(TAG, r.toString());
        //         r.debugNetwork(true);
        //         
        //      }

    }

    private void popRequest() {
        synchronized (mCurrentRequests) {
            try {
                mCurrentRequests.pop();
            } catch (Exception e) {
                EtaLog.e(TAG, "", e);
            }
        }
    }

    private void syncLists(final User user) {

        Listener<JSONArray> listListener = new Listener<JSONArray>() {

            public void onComplete(JSONArray response, EtaError error) {

                if (response != null) {

                    DbHelper db = DbHelper.getInstance();
                    // Get ALL lists including deleted, to avoid adding them again
                    List<Shoppinglist> localLists = db.getLists(user, true);
                    List<Shoppinglist> serverLists = Shoppinglist.fromJSON(response);

                    // Server usually returns items in the order oldest to newest (not guaranteed)
                    // We want them to be reversed
                    Collections.reverse(serverLists);

                    prepareServerList(serverLists);

                    mergeListsToDb(serverLists, localLists, user);

                    // On first iteration, check and merge lists plus notify subscribers of first sync event
                    if (mSyncCount == 1) {

                        if (mMigrateOfflineLists && serverLists.isEmpty() && localLists.isEmpty()) {
                            migrateOfflineLists();
                        }

                        mHasFirstSync = true;
                        mNotification.setFirstSync(true);

                    }

                    popRequestAndPushNotifications();

                } else {
                    popRequest();
                }
            }
        };

        JsonArrayRequest listRequest = new JsonArrayRequest(Method.GET, Endpoint.lists(mEta.getUser().getUserId()),
                listListener);
        // Offset and limit are set to default values, we want to ignore this.
        listRequest.getParameters().remove(Param.OFFSET);
        listRequest.getParameters().remove(Param.LIMIT);
        listRequest.setSaveNetworkLog(SAVE_NETWORK_LOG);
        addRequest(listRequest);

    }

    private void prepareServerList(List<Shoppinglist> serverList) {

        for (Shoppinglist sl : serverList) {
            /* Set the state of all shares in the serverList to SYNCED, 
             * before inserting into the DB, as this is actually correct
             */
            for (Share share : sl.getShares().values()) {
                share.setState(SyncState.SYNCED);
            }

        }

    }

    private void mergeListsToDb(List<Shoppinglist> serverList, List<Shoppinglist> localList, User user) {

        if (serverList.isEmpty() && localList.isEmpty()) {
            return;
        }

        DbHelper db = DbHelper.getInstance();

        HashMap<String, Shoppinglist> localMap = new HashMap<String, Shoppinglist>();
        HashMap<String, Shoppinglist> serverMap = new HashMap<String, Shoppinglist>();
        HashSet<String> union = new HashSet<String>();

        for (Shoppinglist sl : localList) {
            localMap.put(sl.getId(), sl);
        }

        for (Shoppinglist sl : serverList) {
            serverMap.put(sl.getId(), sl);
        }

        union.addAll(serverMap.keySet());
        union.addAll(localMap.keySet());

        for (String key : union) {

            if (localMap.containsKey(key)) {

                Shoppinglist localSl = localMap.get(key);

                if (serverMap.containsKey(key)) {

                    Shoppinglist serverSl = serverMap.get(key);

                    if (localSl.getModified().before(serverSl.getModified())) {
                        serverSl.setState(SyncState.SYNCED);
                        mNotification.edit(serverSl);
                        db.editList(serverSl, user);
                        db.cleanShares(serverSl, user);
                    } else {
                        // Don't do anything, next iteration will put local changes to API
                    }

                } else {
                    mNotification.del(localSl);
                    for (ShoppinglistItem sli : db.getItems(localSl, user)) {
                        mNotification.del(sli);
                    }
                    db.deleteItems(localSl.getId(), null, user);
                    db.deleteList(localSl, user);
                }

            } else {

                Shoppinglist add = serverMap.get(key);
                add.setState(SyncState.TO_SYNC);
                mNotification.add(add);
                db.insertList(add, user);

            }

        }

        for (Shoppinglist sl : mNotification.getAddedLists()) {
            syncItems(sl, user);
        }

        for (Shoppinglist sl : mNotification.getEditedLists()) {
            syncItems(sl, user);
        }

    }

    private void migrateOfflineLists() {

        DbHelper db = DbHelper.getInstance();

        User offlineUser = new User();
        List<Shoppinglist> offlineUserLists = db.getLists(offlineUser);

        if (offlineUserLists.isEmpty()) {
            return;
        }

        for (Shoppinglist sl : offlineUserLists) {

            List<ShoppinglistItem> noUserItems = db.getItems(sl, offlineUser);
            if (noUserItems.isEmpty()) {
                continue;
            }

            Shoppinglist tmpSl = Shoppinglist.fromName(sl.getName());
            tmpSl.setType(sl.getType());

            mEta.getListManager().addList(tmpSl);

            for (ShoppinglistItem sli : noUserItems) {
                sli.setShoppinglistId(tmpSl.getId());
                sli.setId(Utils.createUUID());
                mEta.getListManager().addItem(sli);
            }
        }

    }

    private void syncListsModified(final User user) {

        final DbHelper db = DbHelper.getInstance();
        List<Shoppinglist> localLists = db.getLists(user);

        for (final Shoppinglist sl : localLists) {

            // If they are in the state of processing, then skip
            if (sl.getState() == SyncState.SYNCING) {
                continue;
            }

            // If it obviously needs to sync, then just do it
            if (sl.getState() == SyncState.TO_SYNC) {
                // New shopping lists must always sync
                syncItems(sl, user);
                continue;
            }

            // Run the check 
            sl.setState(SyncState.SYNCING);
            db.editList(sl, user);

            Listener<JSONObject> modifiedListener = new Listener<JSONObject>() {

                public void onComplete(JSONObject response, EtaError error) {

                    if (response != null) {

                        sl.setState(SyncState.SYNCED);
                        try {
                            String modified = response.getString(Api.JsonKey.MODIFIED);
                            // If local list has been modified before the server list, then sync items
                            if (sl.getModified().before(Utils.stringToDate(modified))) {
                                // If there are changes, update items (this will update list-state in DB)
                                syncItems(sl, user);
                            } else {
                                // if no changes, just write new state to DB
                                db.editList(sl, user);
                            }
                        } catch (JSONException e) {
                            EtaLog.e(TAG, "", e);
                            // error? just write new state to DB, next iteration will fix it
                            db.editList(sl, user);
                        }
                        popRequestAndPushNotifications();

                    } else {

                        popRequest();
                        revertList(sl, user);

                    }

                }
            };

            JsonObjectRequest modifiedRequest = new JsonObjectRequest(
                    Endpoint.listModified(mEta.getUser().getUserId(), sl.getId()), modifiedListener);
            modifiedRequest.setSaveNetworkLog(SAVE_NETWORK_LOG);
            addRequest(modifiedRequest);

        }

    }

    private void syncItems(final Shoppinglist sl, final User user) {

        final DbHelper db = DbHelper.getInstance();

        sl.setState(SyncState.SYNCING);
        db.editList(sl, user);

        Listener<JSONArray> itemListener = new Listener<JSONArray>() {

            public void onComplete(JSONArray response, EtaError error) {

                if (response != null) {

                    sl.setState(SyncState.SYNCED);
                    db.editList(sl, user);

                    // Get ALL items including deleted, to avoid adding them again
                    List<ShoppinglistItem> localItems = db.getItems(sl, user, true);
                    List<ShoppinglistItem> serverItems = ShoppinglistItem.fromJSON(response);

                    // So far, we get items in reverse order, well just keep reversing it for now.
                    Collections.reverse(serverItems);

                    for (ShoppinglistItem sli : serverItems) {
                        sli.setState(SyncState.SYNCED);
                    }

                    mergeItemsToDb(serverItems, localItems, user);

                    /* fetch updated items from DB, as the state might be a bit
                     * whack after the merging of items */
                    localItems = db.getItems(sl, user);
                    ListUtils.sortItems(localItems);

                    if (Eta.getInstance().getListManager().canEdit(sl, user)) {

                        /* Update previous_id's, modified and state if needed */
                        String tmp = ListUtils.FIRST_ITEM;
                        for (ShoppinglistItem sli : localItems) {

                            if (!tmp.equals(sli.getPreviousId())) {
                                sli.setPreviousId(tmp);
                                sli.setModified(new Date());
                                sli.setState(SyncState.TO_SYNC);

                                /* If it's a new item, it's already in the added list,
                                 * then we'll override it else add it to the edited
                                 * as a new item to the edited list */
                                if (mNotification.mItemAdded.containsKey(sli.getId())) {
                                    mNotification.add(sli);
                                } else {
                                    mNotification.edit(sli);
                                }

                                db.editItem(sli, user);
                            }
                            tmp = sli.getId();
                        }
                    }

                    popRequestAndPushNotifications();

                } else {
                    popRequest();
                    revertList(sl, user);
                }

            }
        };

        JsonArrayRequest itemRequest = new JsonArrayRequest(Method.GET,
                Endpoint.listitems(mEta.getUser().getUserId(), sl.getId()), itemListener);
        // Offset and limit are set to default values, we want to ignore this.
        itemRequest.getParameters().remove(Param.OFFSET);
        itemRequest.getParameters().remove(Param.LIMIT);
        itemRequest.setSaveNetworkLog(SAVE_NETWORK_LOG);
        addRequest(itemRequest);

    }

    private void mergeItemsToDb(List<ShoppinglistItem> serverItems, List<ShoppinglistItem> localItems, User user) {

        if (serverItems.isEmpty() && localItems.isEmpty()) {
            return;
        }

        DbHelper db = DbHelper.getInstance();

        HashMap<String, ShoppinglistItem> localMap = new HashMap<String, ShoppinglistItem>();
        HashMap<String, ShoppinglistItem> serverMap = new HashMap<String, ShoppinglistItem>();
        HashSet<String> union = new HashSet<String>();

        for (ShoppinglistItem sli : localItems) {
            localMap.put(sli.getId(), sli);
        }

        for (ShoppinglistItem sli : serverItems) {
            serverMap.put(sli.getId(), sli);
        }

        union.addAll(serverMap.keySet());
        union.addAll(localMap.keySet());

        for (String key : union) {

            if (localMap.containsKey(key)) {

                ShoppinglistItem localSli = localMap.get(key);

                if (serverMap.containsKey(key)) {

                    ShoppinglistItem serverSli = serverMap.get(key);

                    if (localSli.getModified().before(serverSli.getModified())) {
                        mNotification.edit(serverSli);
                        db.editItem(serverSli, user);

                    } else if (!localSli.getMeta().toString().equals(serverSli.getMeta().toString())) {
                        // Migration code, to get comments into the DB
                        mNotification.edit(serverSli);
                        db.editItem(serverSli, user);
                    } else if (localSli.equals(serverSli)) {
                        EtaLog.d(TAG, "We have a mismatch");
                    }

                } else {
                    ShoppinglistItem delSli = localMap.get(key);
                    if (delSli.getState() == SyncState.TO_SYNC) {
                        /* 
                         * Item have been added while request was in flight
                         * ignore it for now
                         */
                    } else {
                        /* Else delete the item */
                        mNotification.del(delSli);
                        db.deleteItem(delSli, user);
                    }
                }

            } else {
                ShoppinglistItem serverSli = serverMap.get(key);
                mNotification.add(serverSli);
                db.insertItem(serverSli, user);
            }
        }

    }

    /**
     * Method for pushing all local changes to server.
     * @return true if there was changes, else false
     */
    private boolean syncLocalListChanges(List<Shoppinglist> lists, User user) {

        int count = lists.size();

        for (Shoppinglist sl : lists) {

            switch (sl.getState()) {

            case SyncState.TO_SYNC:
                putList(sl, user);
                break;

            case SyncState.DELETE:
                delList(sl, user);
                break;

            case SyncState.ERROR:
                revertList(sl, user);
                break;

            default:
                count--;
                break;
            }

        }

        return count != 0;

    }

    /**
     * Pushes any local changes to the server.
     * @param sl to get items from
     * @return true if there was changes, else false
     */
    private boolean syncLocalItemChanges(Shoppinglist sl, User user) {

        DbHelper db = DbHelper.getInstance();
        List<ShoppinglistItem> items = db.getItems(sl, user, true);
        int count = items.size();

        for (ShoppinglistItem sli : items) {

            switch (sli.getState()) {
            case SyncState.TO_SYNC:
                putItem(sli, user);
                break;

            case SyncState.DELETE:
                delItem(sli, user);
                break;

            case SyncState.ERROR:
                revertItem(sli, user);
                break;

            default:
                count--;
                break;
            }

        }

        return count != 0;

    }

    private void putList(final Shoppinglist sl, final User user) {

        final DbHelper db = DbHelper.getInstance();

        sl.setState(SyncState.SYNCING);
        db.editList(sl, user);

        Listener<JSONObject> listListener = new Listener<JSONObject>() {

            public void onComplete(JSONObject response, EtaError error) {

                if (response != null) {

                    /* 
                     * If local isn't equal to server version, take server version.
                     * Don't push changes yet, we want to check item state too.
                     */

                    Shoppinglist serverSl = Shoppinglist.fromJSON(response);
                    Shoppinglist localSl = db.getList(serverSl.getId(), user);
                    if (localSl != null && !serverSl.getModified().equals(localSl.getModified())) {
                        serverSl.setState(SyncState.SYNCED);
                        // If server haven't delivered an prev_id, then use old id
                        serverSl.setPreviousId(
                                serverSl.getPreviousId() == null ? sl.getPreviousId() : serverSl.getPreviousId());
                        db.editList(serverSl, user);
                        mNotification.edit(serverSl);
                    }
                    popRequest();
                    syncLocalItemChanges(sl, user);

                } else {

                    popRequest();
                    if (error.getCode() == Code.NETWORK_ERROR) {
                        /* Ignore missing network, wait for next iteration */
                    } else {
                        revertList(sl, user);
                    }

                }

            }
        };

        String url = Endpoint.list(user.getUserId(), sl.getId());
        JsonObjectRequest listReq = new JsonObjectRequest(Method.PUT, url, sl.toJSON(), listListener);

        addRequest(listReq);

    }

    private void delList(final Shoppinglist sl, final User user) {

        final DbHelper db = DbHelper.getInstance();

        Listener<JSONObject> listListener = new Listener<JSONObject>() {

            public void onComplete(JSONObject response, EtaError error) {

                if (response != null) {

                    db.deleteList(sl, user);
                    db.deleteShares(sl, user);
                    db.deleteItems(sl.getId(), null, user);
                    popRequest();

                } else {

                    popRequest();

                    switch (error.getCode()) {

                    case Code.INVALID_RESOURCE_ID:
                        /* Resource already gone (or have never been synchronized)
                         * delete local version and ignore */
                        db.deleteList(sl, user);
                        break;

                    case Code.NETWORK_ERROR:
                        /* Ignore missing network, wait for next iteration */
                        break;

                    default:
                        revertList(sl, user);
                        break;

                    }

                }

            }
        };

        String url = Endpoint.list(user.getUserId(), sl.getId());

        JsonObjectRequest listReq = new JsonObjectRequest(Method.DELETE, url, null, listListener);
        listReq.getParameters().put(Param.MODIFIED, Utils.dateToString(sl.getModified()));
        addRequest(listReq);

    }

    private void revertList(final Shoppinglist sl, final User user) {

        final DbHelper db = DbHelper.getInstance();

        if (sl.getState() != SyncState.ERROR) {
            sl.setState(SyncState.ERROR);
            db.editList(sl, user);
        }

        Listener<JSONObject> listListener = new Listener<JSONObject>() {

            public void onComplete(JSONObject response, EtaError error) {

                if (response != null) {
                    Shoppinglist serverSl = Shoppinglist.fromJSON(response);
                    serverSl.setState(SyncState.SYNCED);
                    serverSl.setPreviousId(
                            serverSl.getPreviousId() == null ? sl.getPreviousId() : serverSl.getPreviousId());
                    db.editList(serverSl, user);
                    mNotification.add(serverSl);
                    syncLocalItemChanges(sl, user);
                } else {
                    db.deleteList(sl, user);
                    mNotification.del(sl);
                }
                popRequestAndPushNotifications();
            }
        };

        String url = Endpoint.list(user.getUserId(), sl.getId());
        JsonObjectRequest listReq = new JsonObjectRequest(url, listListener);

        addRequest(listReq);

    }

    private void putItem(final ShoppinglistItem sli, final User user) {

        final DbHelper db = DbHelper.getInstance();

        sli.setState(SyncState.SYNCING);
        db.editItem(sli, user);

        Listener<JSONObject> itemListener = new Listener<JSONObject>() {

            public void onComplete(JSONObject response, EtaError error) {

                if (response != null) {

                    ShoppinglistItem server = ShoppinglistItem.fromJSON(response);
                    ShoppinglistItem local = db.getItem(sli.getId(), user);

                    if (local != null && !local.getModified().equals(server.getModified())) {

                        // The server has a 'better' state, we should use this
                        server.setState(SyncState.SYNCED);
                        // If server havent delivered an prev_id, then use old id
                        if (server.getPreviousId() == null) {
                            server.setPreviousId(sli.getPreviousId());
                        }
                        db.editItem(server, user);
                        mNotification.edit(server);

                    }

                    popRequestAndPushNotifications();

                } else {

                    popRequest();

                    switch (error.getCode()) {

                    case Code.NETWORK_ERROR:
                        /* Ignore missing network, wait for next iteration */
                        break;

                    default:
                        revertItem(sli, user);
                        break;

                    }

                }

            }
        };

        String url = Endpoint.listitem(user.getUserId(), sli.getShoppinglistId(), sli.getId());
        JsonObjectRequest itemReq = new JsonObjectRequest(Method.PUT, url, sli.toJSON(), itemListener);
        //      EtaLog.d(TAG, sli.toJSON().toString());
        addRequest(itemReq);

    }

    private void delItem(final ShoppinglistItem sli, final User user) {

        final DbHelper db = DbHelper.getInstance();

        Listener<JSONObject> itemListener = new Listener<JSONObject>() {

            public void onComplete(JSONObject response, EtaError error) {

                if (response != null) {
                    db.deleteItem(sli, user);
                    popRequest();
                } else {
                    popRequest();

                    switch (error.getCode()) {

                    case Code.INVALID_RESOURCE_ID:
                        /* Resource already gone (or have never been synchronized)
                         * delete local version and ignore */
                        db.deleteItem(sli, user);
                        break;

                    case Code.NETWORK_ERROR:
                        /* Ignore missing network, wait for next iteration */
                        break;

                    default:
                        revertItem(sli, user);
                        break;

                    }

                }

            }
        };

        String url = Endpoint.listitem(user.getUserId(), sli.getShoppinglistId(), sli.getId());
        JsonObjectRequest itemReq = new JsonObjectRequest(Method.DELETE, url, null, itemListener);
        itemReq.getParameters().put(Param.MODIFIED, Utils.dateToString(sli.getModified()));
        addRequest(itemReq);

    }

    private void revertItem(final ShoppinglistItem sli, final User user) {

        final DbHelper db = DbHelper.getInstance();

        if (sli.getState() != SyncState.ERROR) {
            sli.setState(SyncState.ERROR);
            db.editItem(sli, user);
        }

        Listener<JSONObject> itemListener = new Listener<JSONObject>() {

            public void onComplete(JSONObject response, EtaError error) {

                if (response != null) {

                    // Take server response, insert it into DB, post notification
                    ShoppinglistItem serverSli = ShoppinglistItem.fromJSON(response);
                    serverSli.setState(SyncState.SYNCED);
                    serverSli.setPreviousId(
                            serverSli.getPreviousId() == null ? sli.getPreviousId() : serverSli.getPreviousId());
                    //               EtaLog.d(TAG, "affected: " + db.editItem(serverSli, user));
                    //               EtaLog.d(TAG, serverSli.toJSON().toString());
                    mNotification.edit(serverSli);

                } else {

                    // Something bad happened, delete item to keep DB sane
                    db.deleteItem(sli, user);
                    mNotification.del(sli);

                }

                /* 
                 * Update shopping list modified to match the latest date of the
                 * items, so that we get as close to API state as possible
                 */
                Shoppinglist sl = db.getList(sli.getShoppinglistId(), user);
                if (sl != null) {

                    List<ShoppinglistItem> items = db.getItems(sl, user, true);
                    if (!items.isEmpty()) {
                        Collections.sort(items, ShoppinglistItem.MODIFIED_DESCENDING);
                        ShoppinglistItem newestItem = items.get(0);
                        sl.setModified(newestItem.getModified());
                        db.editList(sl, user);
                        mNotification.edit(sl);
                    }

                }

                popRequestAndPushNotifications();

            }
        };

        String url = Endpoint.listitem(user.getUserId(), sli.getShoppinglistId(), sli.getId());
        JsonObjectRequest itemReq = new JsonObjectRequest(url, itemListener);
        addRequest(itemReq);

    }

    private boolean syncLocalShareChanges(Shoppinglist sl, User user) {

        DbHelper db = DbHelper.getInstance();
        List<Share> shares = db.getShares(sl, user, true);

        int count = shares.size();

        for (Share s : shares) {

            switch (s.getState()) {

            case SyncState.TO_SYNC:
                putShare(s, user);
                break;

            case SyncState.DELETE:
                delShare(s, user);
                break;

            case SyncState.ERROR:
                revertShare(s, user);
                break;

            default:
                count--;
                break;

            }

        }

        return count != 0;

    }

    private void putShare(final Share s, final User user) {

        final DbHelper db = DbHelper.getInstance();

        s.setState(SyncState.SYNCING);
        db.editShare(s, user);

        Listener<JSONObject> shareListener = new Listener<JSONObject>() {

            public void onComplete(JSONObject response, EtaError error) {

                if (response != null) {
                    Share serverShare = Share.fromJSON(response);
                    serverShare.setState(SyncState.SYNCED);
                    serverShare.setShoppinglistId(s.getShoppinglistId());
                    db.editShare(serverShare, user);
                    popRequest();
                } else {
                    if (error.getFailedOnField() != null) {

                        /* If it's a FailedOnField, we can't do anything, yet.
                         * Remove the share to keep the DB sane.
                         */
                        db.deleteShare(s, user);
                        // No need to edit the SL in DB, as shares are disconnected
                        Shoppinglist sl = db.getList(s.getShoppinglistId(), user);
                        if (sl != null) {
                            sl.removeShare(s);
                            mNotification.edit(sl);
                            popRequestAndPushNotifications();
                        } else {
                            popRequest();
                            /* Nothing, shoppinglist might have been deleted */
                        }

                    } else {
                        popRequest();
                        revertShare(s, user);
                    }
                }

            }
        };

        /* Hack for EDGE, where accept URL is required */
        if (s.getAcceptUrl() == null) {
            s.setAcceptUrl("https://www.etilbudsavis.dk/");
        }

        String url = Endpoint.listShareEmail(user.getUserId(), s.getShoppinglistId(), s.getEmail());
        JsonObjectRequest shareReq = new JsonObjectRequest(Method.PUT, url, s.toJSON(), shareListener);
        addRequest(shareReq);

    }

    private void delShare(final Share s, final User user) {

        Listener<JSONObject> shareListener = new Listener<JSONObject>() {

            public void onComplete(JSONObject response, EtaError error) {

                DbHelper db = DbHelper.getInstance();

                if (response != null) {

                    if (user.getEmail().equals(s.getEmail())) {

                        /* 
                         * if share.email == user.email, the user have been
                         * removed from the list, delete list, items, and shares
                         */
                        db.deleteList(s.getShoppinglistId(), user);
                        db.deleteItems(s.getShoppinglistId(), null, user);
                        db.deleteShares(s.getShoppinglistId(), user);

                    } else {

                        /* Else just remove the share in question */
                        db.deleteShare(s, user);

                    }
                    popRequest();

                } else {

                    popRequest();

                    switch (error.getCode()) {

                    case Code.NETWORK_ERROR:
                        /* Ignore missing network, wait for next iteration */
                        break;

                    case Code.INVALID_RESOURCE_ID:
                        /* Resource  gone (or have never been synchronized)
                         * delete local version and ignore */
                        db.deleteShare(s, user);
                        break;

                    default:
                        revertShare(s, user);
                        break;

                    }

                }

            }
        };

        String url = Endpoint.listShareEmail(user.getUserId(), s.getShoppinglistId(), s.getEmail());
        JsonObjectRequest shareReq = new JsonObjectRequest(Method.DELETE, url, null, shareListener);
        addRequest(shareReq);

    }

    private void revertShare(final Share s, final User user) {

        final DbHelper db = DbHelper.getInstance();

        if (s.getState() != SyncState.ERROR) {
            s.setState(SyncState.ERROR);
            db.editShare(s, user);
        }

        Listener<JSONObject> shareListener = new Listener<JSONObject>() {

            public void onComplete(JSONObject response, EtaError error) {

                if (response != null) {

                    Share serverShare = Share.fromJSON(response);
                    serverShare.setState(SyncState.SYNCED);
                    serverShare.setShoppinglistId(s.getShoppinglistId());
                    db.editShare(serverShare, user);

                    // No need to edit the SL in DB, as shares are disconnected
                    Shoppinglist sl = db.getList(s.getShoppinglistId(), user);
                    if (sl != null) {

                        sl.removeShare(s);
                        mNotification.edit(sl);
                        popRequestAndPushNotifications();

                    } else {

                        /* Nothing, shoppinglist might have been deleted */
                        popRequest();

                    }

                } else {

                    db.deleteShare(s, user);
                    popRequest();

                }

            }
        };

        String url = Endpoint.listShareEmail(user.getUserId(), s.getShoppinglistId(), s.getEmail());
        JsonObjectRequest shareReq = new JsonObjectRequest(url, shareListener);
        addRequest(shareReq);

    }

    /**
     * Pops one request off the request-stack, and sends out a notification if 
     * the stack is empty (all requests are done, and all notifications are ready)
     */
    private void popRequestAndPushNotifications() {

        popRequest();
        if (mCurrentRequests.isEmpty()) {
            boolean p = isPaused();
            //         EtaLog.d(TAG, "popRequestAndPushNotifications-paused: " + p);
            if (!p) {
                Eta.getInstance().getListManager().notifySubscribers(mNotification);
                mNotification = new ListNotification(true);
            }

        }

    }

}