net.sf.diningout.content.SyncAdapter.java Source code

Java tutorial

Introduction

Here is the source code for net.sf.diningout.content.SyncAdapter.java

Source

/*
 * Copyright 2013-2015 pushbit <pushbit@gmail.com>
 *
 * This file is part of Dining Out.
 *
 * Dining Out is free software: you can redistribute it and/or modify it under the terms of the GNU
 * General Public License as published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * Dining Out is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
 * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with Dining Out. If not,
 * see <http://www.gnu.org/licenses/>.
 */

package net.sf.diningout.content;

import android.accounts.Account;
import android.app.PendingIntent;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SyncResult;
import android.database.CursorJoiner;
import android.database.sqlite.SQLiteConstraintException;
import android.net.Uri;
import android.os.Bundle;
import android.os.RemoteException;
import android.os.SystemClock;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.Email;
import android.provider.ContactsContract.RawContacts;
import android.support.v4.content.LocalBroadcastManager;
import android.text.TextUtils;
import android.util.Log;

import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.PendingResult;
import com.google.android.gms.common.api.Status;
import com.google.android.gms.iid.InstanceID;
import com.google.android.gms.location.Geofence;
import com.google.android.gms.location.GeofenceStatusCodes;
import com.google.android.gms.location.GeofencingRequest;
import com.google.common.hash.Hashing;
import com.google.common.io.BaseEncoding;

import net.sf.diningout.R;
import net.sf.diningout.accounts.Accounts;
import net.sf.diningout.app.FriendColorService;
import net.sf.diningout.app.Notifications;
import net.sf.diningout.app.RestaurantGeofencingEventService;
import net.sf.diningout.app.RestaurantService;
import net.sf.diningout.app.RestaurantService.Result;
import net.sf.diningout.app.RestaurantsRefreshService;
import net.sf.diningout.app.ReviewsService;
import net.sf.diningout.data.Init;
import net.sf.diningout.data.Restaurant;
import net.sf.diningout.data.Review;
import net.sf.diningout.data.Sync;
import net.sf.diningout.data.Synced;
import net.sf.diningout.data.Syncing;
import net.sf.diningout.data.User;
import net.sf.diningout.net.AuthToken;
import net.sf.diningout.net.Server;
import net.sf.diningout.provider.Contract.Columns;
import net.sf.diningout.provider.Contract.Contacts;
import net.sf.diningout.provider.Contract.Restaurants;
import net.sf.diningout.provider.Contract.ReviewDrafts;
import net.sf.diningout.provider.Contract.ReviewDraftsJoinRestaurants;
import net.sf.diningout.provider.Contract.Reviews;
import net.sf.diningout.provider.Contract.ReviewsJoinRestaurants;
import net.sf.diningout.provider.Contract.Syncs;
import net.sf.sprockets.content.Content;
import net.sf.sprockets.database.Cursors;
import net.sf.sprockets.database.EasyCursor;
import net.sf.sprockets.gms.gcm.Gcm;
import net.sf.sprockets.gms.location.Locations;
import net.sf.sprockets.net.Uris;
import net.sf.sprockets.preference.Prefs;
import net.sf.sprockets.sql.SQLite;
import net.sf.sprockets.util.Elements;

import java.io.IOException;
import java.util.List;

import retrofit.RetrofitError;

import static android.content.ContentResolver.SYNC_EXTRAS_INITIALIZE;
import static android.content.ContentResolver.SYNC_EXTRAS_MANUAL;
import static android.content.ContentResolver.SYNC_EXTRAS_UPLOAD;
import static android.provider.BaseColumns._ID;
import static android.text.format.DateUtils.DAY_IN_MILLIS;
import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
import static com.google.android.gms.gcm.GoogleCloudMessaging.INSTANCE_ID_SCOPE;
import static com.google.android.gms.location.Geofence.NEVER_EXPIRE;
import static com.google.android.gms.location.LocationServices.GeofencingApi;
import static com.google.common.base.Charsets.UTF_8;
import static java.util.Locale.ENGLISH;
import static net.sf.diningout.data.Review.Type.PRIVATE;
import static net.sf.diningout.data.Status.ACTIVE;
import static net.sf.diningout.data.Status.DELETED;
import static net.sf.diningout.data.Sync.Action.INSERT;
import static net.sf.diningout.data.Sync.Action.UPDATE;
import static net.sf.diningout.preference.Keys.App.ACCOUNT_INITIALISED;
import static net.sf.diningout.preference.Keys.App.APP;
import static net.sf.diningout.preference.Keys.App.CLOUD_ID;
import static net.sf.diningout.preference.Keys.App.INSTALL_ID;
import static net.sf.diningout.preference.Keys.App.LAST_SYNC;
import static net.sf.diningout.preference.Keys.App.ONBOARDED;
import static net.sf.diningout.preference.Keys.SHOW_NOTIFICATIONS;
import static net.sf.diningout.provider.Contract.ACTION_CONTACTS_SYNCED;
import static net.sf.diningout.provider.Contract.ACTION_CONTACTS_SYNCING;
import static net.sf.diningout.provider.Contract.ACTION_USER_LOGGED_IN;
import static net.sf.diningout.provider.Contract.AUTHORITY;
import static net.sf.diningout.provider.Contract.AUTHORITY_URI;
import static net.sf.diningout.provider.Contract.CALL_UPDATE_RESTAURANT_LAST_VISIT;
import static net.sf.diningout.provider.Contract.CALL_UPDATE_RESTAURANT_RATING;
import static net.sf.diningout.provider.Contract.EXTRA_HAS_RESTAURANTS;
import static net.sf.diningout.provider.Contract.SYNC_EXTRAS_CONTACTS_ONLY;
import static net.sf.sprockets.app.SprocketsApplication.cr;
import static net.sf.sprockets.content.Content.SYNC_EXTRAS_DOWNLOAD;
import static net.sf.sprockets.gms.analytics.Trackers.event;
import static net.sf.sprockets.gms.analytics.Trackers.exception;
import static net.sf.sprockets.gms.location.Geofences.GEOFENCE_RADIUS;
import static net.sf.sprockets.gms.location.Geofences.GEOFENCE_TRANSITION_ALL;
import static net.sf.sprockets.gms.location.Geofences.MAX_GEOFENCES;
import static net.sf.sprockets.sql.SQLite.alias;

/**
 * Synchronises the content provider with the server.
 */
public class SyncAdapter extends AbstractThreadedSyncAdapter {
    private static final String TAG = SyncAdapter.class.getSimpleName();
    private static final String PROJECT_ID = "77419503291"; // from Google Developers Console

    /**
     * Contacts content URI specifying that the caller is a sync adapter.
     */
    private static final Uri CONTACTS_URI = Uris.callerIsSyncAdapter(Contacts.CONTENT_URI);

    /**
     * Restaurants content URI specifying that the caller is a sync adapter.
     */
    private static final Uri RESTAURANTS_URI = Uris.callerIsSyncAdapter(Restaurants.CONTENT_URI);

    /**
     * Reviews content URI specifying that the caller is a sync adapter.
     */
    private static final Uri REVIEWS_URI = Uris.callerIsSyncAdapter(Reviews.CONTENT_URI);

    /**
     * Review drafts content URI specifying that the caller is a sync adapter.
     */
    private static final Uri REVIEW_DRAFTS_URI = Uris.callerIsSyncAdapter(ReviewDrafts.CONTENT_URI);

    /**
     * Syncs content URI specifying that the caller is a sync adapter.
     */
    private static final Uri SYNCS_URI = Uris.callerIsSyncAdapter(Syncs.CONTENT_URI);

    public SyncAdapter(Context context) {
        super(context, false, false);
    }

    @Override
    public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider,
            SyncResult result) {
        Account selected = Accounts.selected();
        if (!account.equals(selected)) {
            if (selected != null) { // never going to sync this account that wasn't selected
                ContentResolver.setIsSyncable(account, AUTHORITY, 0);
            }
            return;
        }
        if (extras.containsKey(SYNC_EXTRAS_INITIALIZE)) {
            return; // will initialise on first normal sync
        }
        Context context = getContext();
        SharedPreferences prefs = Prefs.get(context, APP);
        try {
            if (!prefs.getBoolean(ACCOUNT_INITIALISED, false)) { // first run, log the user in
                initUser(context, provider);
            }
            if (extras.getBoolean(SYNC_EXTRAS_CONTACTS_ONLY)) {
                refreshContacts(context, provider);
                uploadContacts(context, provider);
                return;
            }
            if (!prefs.getBoolean(ONBOARDED, false)) {
                return; // don't sync yet
            }
            long now = System.currentTimeMillis(); // full upload and download daily
            boolean dailySync = now - prefs.getLong(LAST_SYNC, 0L) >= DAY_IN_MILLIS;
            if (dailySync || extras.containsKey(SYNC_EXTRAS_MANUAL)) {
                extras.putBoolean(SYNC_EXTRAS_UPLOAD, true);
                extras.putBoolean(SYNC_EXTRAS_DOWNLOAD, true);
                refreshContacts(context, provider);
            }
            if (extras.containsKey(SYNC_EXTRAS_UPLOAD) || extras.containsKey(SYNC_EXTRAS_DOWNLOAD)) {
                uploadContacts(context, provider);
                uploadRestaurants(provider);
                uploadReviews(provider);
                uploadReviewDrafts(provider);
            }
            if (extras.containsKey(SYNC_EXTRAS_DOWNLOAD)) {
                download(provider);
                prefs.edit().putLong(LAST_SYNC, now).apply();
                if (dailySync) {
                    refreshRestaurants(context, provider);
                    geofenceRestaurants(context, provider);
                }
                Notifications.sync(context);
            }
            if (!prefs.contains(CLOUD_ID)) { // get GCM registration ID and user notification key
                String id = uploadCloudId(context);
                if (!TextUtils.isEmpty(id)) {
                    prefs.edit().putString(CLOUD_ID, id).apply();
                    // todo service returns HTTP 401, probably should decouple this from cloud_id
                    // String key = getCloudNotificationKey(context, selected, id);
                    // if (!TextUtils.isEmpty(key)) {
                    //     prefs.edit().putString(CLOUD_NOTIFICATION_KEY, key).apply();
                    // }
                }
            }
        } catch (RemoteException e) {
            result.databaseError = true;
            Log.e(TAG, "syncing the ContentProvider", e);
            exception(e);
        }
    }

    /**
     * Log the user into the server and restore any contacts or restaurants.
     */
    private void initUser(Context context, ContentProviderClient cp) throws RemoteException {
        Init init = Server.init();
        Intent intent = new Intent(ACTION_USER_LOGGED_IN);
        intent.putExtra(EXTRA_HAS_RESTAURANTS, init != null && init.restaurants != null);
        LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
        if (init != null) {
            Prefs.edit(context, APP).putBoolean(ACCOUNT_INITIALISED, true).putLong(INSTALL_ID, init.installId)
                    .apply();
            if (init.users != null) {
                ContentValues vals = new ContentValues(6);
                int size = init.users.size();
                for (int i = 0; i < size; i++) {
                    cp.insert(CONTACTS_URI, Contacts.values(vals, init.users.get(i)));
                }
            }
            if (init.restaurants != null) {
                Prefs.putBoolean(context, APP, ONBOARDED, true);
                int size = init.restaurants.size();
                for (int i = 0; i < size; i++) {
                    Restaurant restaurant = init.restaurants.get(i);
                    restaurant.localId = Restaurants.add(restaurant.globalId);
                    if (restaurant.localId > 0) {
                        RestaurantService.download(restaurant.localId);
                    }
                }
            }
        }
    }

    /**
     * Insert new system contacts, delete orphaned app contacts, and synchronise any changes to
     * existing.
     */
    private void refreshContacts(Context context, ContentProviderClient cp) throws RemoteException {
        /* get system contacts */
        String[] proj = { Email.ADDRESS, ContactsContract.Contacts.LOOKUP_KEY, RawContacts.CONTACT_ID,
                ContactsContract.Contacts.DISPLAY_NAME };
        String sel = Email.IN_VISIBLE_GROUP + " = 1 AND " + Email.ADDRESS + " <> ?";
        String[] args = { Accounts.selected().name };
        EasyCursor sys = new EasyCursor(cr().query(Email.CONTENT_URI, proj, sel, args, Email.ADDRESS));
        /* get app contacts */
        proj = new String[] { Contacts.EMAIL, Contacts.ANDROID_LOOKUP_KEY, Contacts.ANDROID_ID, Contacts.NAME, _ID,
                Contacts.FOLLOWING, Contacts.STATUS_ID };
        sel = Contacts.EMAIL + " IS NOT NULL";
        EasyCursor app = new EasyCursor(cp.query(CONTACTS_URI, proj, sel, null, Contacts.EMAIL));
        /* compare and sync */
        ContentValues vals = new ContentValues();
        for (CursorJoiner.Result result : new CursorJoiner(sys, new String[] { Email.ADDRESS }, app,
                new String[] { Contacts.EMAIL })) {
            switch (result) {
            case LEFT: // new system contact, insert into app contacts
                String email = sys.getString(Email.ADDRESS);
                String hash = BaseEncoding.base64()
                        .encode(Hashing.sha512().hashString(email.toLowerCase(ENGLISH), UTF_8).asBytes());
                long id = Contacts.idForHash(hash); // do we have this contact and not know it?
                /* insert or update values */
                vals.put(Contacts.ANDROID_LOOKUP_KEY, sys.getString(ContactsContract.Contacts.LOOKUP_KEY));
                vals.put(Contacts.ANDROID_ID, sys.getLong(RawContacts.CONTACT_ID));
                String name = sys.getString(ContactsContract.Contacts.DISPLAY_NAME);
                vals.put(Contacts.NAME, name);
                vals.put(Contacts.NORMALISED_NAME, SQLite.normalise(name));
                vals.put(Contacts.EMAIL, email);
                if (id <= 0) {
                    vals.put(Contacts.EMAIL_HASH, hash);
                    vals.put(Contacts.COLOR, Contacts.defaultColor());
                    id = ContentUris.parseId(cp.insert(CONTACTS_URI, vals));
                } else {
                    cp.update(ContentUris.withAppendedId(CONTACTS_URI, id), vals, null, null);
                }
                if (id > 0) {
                    context.startService(new Intent(context, FriendColorService.class)
                            .putExtra(FriendColorService.EXTRA_ID, id));
                }
                break;
            case RIGHT: // orphaned app contact, delete unless user is following
                if (app.getInt(Contacts.FOLLOWING) == 0 && app.getInt(Contacts.STATUS_ID) == ACTIVE.id) {
                    vals.put(Contacts.STATUS_ID, DELETED.id);
                    vals.put(Contacts.DIRTY, 1);
                    cp.update(Uris.appendId(CONTACTS_URI, app), vals, null, null);
                }
                break;
            case BOTH: // matching contacts, update details in app if needed
                String s = sys.getString(ContactsContract.Contacts.LOOKUP_KEY);
                if (!s.equals(app.getString(Contacts.ANDROID_LOOKUP_KEY))) {
                    vals.put(Contacts.ANDROID_LOOKUP_KEY, s);
                }
                long l = sys.getLong(RawContacts.CONTACT_ID);
                if (l != app.getLong(Contacts.ANDROID_ID)) {
                    vals.put(Contacts.ANDROID_ID, l);
                }
                s = sys.getString(ContactsContract.Contacts.DISPLAY_NAME);
                if (!s.equals(app.getString(Contacts.NAME))) {
                    vals.put(Contacts.NAME, s);
                    vals.put(Contacts.NORMALISED_NAME, SQLite.normalise(s));
                }
                if (app.getInt(Contacts.STATUS_ID) == DELETED.id) {
                    vals.put(Contacts.STATUS_ID, ACTIVE.id);
                    vals.put(Contacts.DIRTY, 1);
                }
                if (vals.size() > 0) {
                    cp.update(Uris.appendId(CONTACTS_URI, app), vals, null, null);
                    context.startService(new Intent(context, FriendColorService.class)
                            .putExtra(FriendColorService.EXTRA_ID, app.getLong(_ID)));
                }
                break;
            }
            vals.clear();
        }
        sys.close();
        app.close();
    }

    /**
     * Upload contact changes to the server.
     */
    private void uploadContacts(Context context, ContentProviderClient cp) throws RemoteException {
        String[] proj = { _ID, Contacts.GLOBAL_ID, Contacts.EMAIL_HASH, Contacts.FOLLOWING, Contacts.STATUS_ID,
                Contacts.DIRTY, Contacts.VERSION };
        String sel = Contacts.DIRTY + " = 1";
        List<User> users = Contacts.from(cp.query(CONTACTS_URI, proj, sel, null, null));
        if (users != null) {
            LocalBroadcastManager bm = LocalBroadcastManager.getInstance(context);
            bm.sendBroadcast(new Intent(ACTION_CONTACTS_SYNCING));
            response(Server.syncContacts(users), cp, CONTACTS_URI);
            bm.sendBroadcast(new Intent(ACTION_CONTACTS_SYNCED));
        }
    }

    /**
     * Upload restaurant changes to the server.
     */
    private void uploadRestaurants(ContentProviderClient cp) throws RemoteException {
        String[] proj = { _ID, Restaurants.GLOBAL_ID, Restaurants.PLACE_ID, Restaurants.NAME, Restaurants.ADDRESS,
                Restaurants.INTL_PHONE, Restaurants.URL, Restaurants.NOTES, Restaurants.GEOFENCE_NOTIFICATIONS,
                Restaurants.STATUS_ID, Restaurants.DIRTY, Restaurants.VERSION };
        String sel = Restaurants.DIRTY + " = 1";
        List<Restaurant> restaurants = Restaurants.from(cp.query(RESTAURANTS_URI, proj, sel, null, null));
        if (restaurants != null) {
            response(Server.syncRestaurants(restaurants), cp, RESTAURANTS_URI);
        }
    }

    /**
     * Upload review changes to the server.
     */
    private void uploadReviews(ContentProviderClient cp) throws RemoteException {
        String[] proj = { alias(ReviewsJoinRestaurants.REVIEW__ID), alias(ReviewsJoinRestaurants.REVIEW_GLOBAL_ID),
                ReviewsJoinRestaurants.RESTAURANT_GLOBAL_ID + " AS " + Reviews.RESTAURANT_ID, Reviews.COMMENTS,
                alias(ReviewsJoinRestaurants.REVIEW_RATING), Reviews.WRITTEN_ON,
                alias(ReviewsJoinRestaurants.REVIEW_STATUS_ID), alias(ReviewsJoinRestaurants.REVIEW_DIRTY),
                alias(ReviewsJoinRestaurants.REVIEW_VERSION) };
        String sel = Reviews.TYPE_ID + " = ? AND " + ReviewsJoinRestaurants.REVIEW_DIRTY + " = 1";
        String[] args = { String.valueOf(PRIVATE.id) };
        List<Review> reviews = Reviews.from(cp.query(ReviewsJoinRestaurants.CONTENT_URI, proj, sel, args, null));
        if (reviews != null) {
            response(Server.syncReviews(reviews), cp, REVIEWS_URI);
        }
    }

    /**
     * Upload review draft changes to the server.
     */
    private void uploadReviewDrafts(ContentProviderClient cp) throws RemoteException {
        String[] proj = { ReviewDrafts.RESTAURANT_ID + " AS " + _ID,
                Restaurants.GLOBAL_ID + " AS " + ReviewDrafts.RESTAURANT_ID, ReviewDrafts.COMMENTS,
                alias(ReviewDraftsJoinRestaurants.REVIEW_DRAFT_RATING),
                alias(ReviewDraftsJoinRestaurants.REVIEW_DRAFT_STATUS_ID),
                alias(ReviewDraftsJoinRestaurants.REVIEW_DRAFT_DIRTY),
                alias(ReviewDraftsJoinRestaurants.REVIEW_DRAFT_VERSION) };
        String sel = ReviewDraftsJoinRestaurants.REVIEW_DRAFT_DIRTY + " = 1";
        List<Review> drafts = Reviews
                .from(cp.query(ReviewDraftsJoinRestaurants.CONTENT_URI, proj, sel, null, null));
        if (drafts != null) {
            response(Server.syncReviewDrafts(drafts), cp, REVIEW_DRAFTS_URI);
        }
    }

    /**
     * Update the synchronised object at the URI with the values received from the server.
     *
     * @param synceds can be null
     */
    private void response(List<? extends Synced> synceds, ContentProviderClient cp, Uri uri)
            throws RemoteException {
        if (synceds == null) {
            return;
        }
        ContentValues vals = new ContentValues(2);
        String sel = Columns.VERSION + " = ?";
        String[] args = new String[1];
        int size = synceds.size();
        for (int i = 0; i < size; i++) {
            Synced synced = synceds.get(i);
            if (synced.globalId > 0 && uri != REVIEW_DRAFTS_URI) {
                vals.put(Columns.GLOBAL_ID, synced.globalId);
                if (uri == RESTAURANTS_URI) {
                    Restaurants.deleteConflict(synced.localId, synced.globalId);
                }
            }
            vals.put(Columns.DIRTY, synced.dirty);
            args[0] = String.valueOf(synced.version);
            cp.update(ContentUris.withAppendedId(uri, synced.localId), vals, sel, args);
            vals.clear();
        }
    }

    /**
     * Download changes from the server and synchronise them with the content provider.
     */
    private void download(ContentProviderClient cp) throws RemoteException {
        Syncing syncing = Server.sync();
        if (syncing != null) {
            if (syncing.users != null) {
                int size = syncing.users.size();
                for (int i = 0; i < size; i++) {
                    Sync<User> sync = syncing.users.get(i);
                    if (sync.userId == 0) { // in case server starts sending changes by other users
                        syncUser(cp, sync);
                    }
                }
            }
            if (syncing.restaurants != null) {
                int size = syncing.restaurants.size();
                for (int i = 0; i < size; i++) {
                    Sync<Restaurant> sync = syncing.restaurants.get(i);
                    if (sync.userId == 0) { // in case server starts sending changes by other users
                        syncRestaurant(cp, sync);
                    }
                }
            }
            if (syncing.reviews != null) {
                int size = syncing.reviews.size();
                for (int i = 0; i < size; i++) {
                    syncReview(cp, syncing.reviews.get(i));
                }
            }
            if (syncing.reviewDrafts != null) {
                int size = syncing.reviewDrafts.size();
                for (int i = 0; i < size; i++) {
                    syncReviewDraft(cp, syncing.reviewDrafts.get(i));
                }
            }
        }
    }

    /**
     * Convert a contact when they become a user and sync remote changes to a followed user.
     */
    private void syncUser(ContentProviderClient cp, Sync<User> sync) throws RemoteException {
        User user = sync.object;
        ContentValues vals = new ContentValues(1);
        switch (sync.action) {
        case INSERT:
            user.localId = Contacts.idForHash(user.emailHash);
            if (user.localId > 0) {
                vals.put(Contacts.GLOBAL_ID, user.globalId);
                cp.update(ContentUris.withAppendedId(CONTACTS_URI, user.localId), vals, null, null);
                cp.insert(SYNCS_URI, Syncs.values(sync));
            }
            break;
        case UPDATE:
            int following = user.isFollowing ? 1 : 0;
            vals.put(Contacts.FOLLOWING, following);
            String sel = Contacts.GLOBAL_ID + " = ? AND " + Contacts.FOLLOWING + " <> ?";
            String[] args = Elements.toStrings(user.globalId, following);
            if (cp.update(CONTACTS_URI, vals, sel, args) > 0 && user.isFollowing) {
                ReviewsService.download(user.globalId);
            }
            break;
        }
    }

    /**
     * Sync remote changes to a restaurant.
     */
    private void syncRestaurant(ContentProviderClient cp, Sync<Restaurant> sync) throws RemoteException {
        Restaurant restaurant = sync.object;
        switch (sync.action) {
        case INSERT:
            ContentValues vals = Restaurants.values(restaurant);
            vals.put(Restaurants.COLOR, Restaurants.defaultColor());
            restaurant.localId = ContentUris.parseId(cp.insert(RESTAURANTS_URI, vals));
            if (restaurant.localId > 0 && restaurant.status == ACTIVE) {
                RestaurantService.download(restaurant.localId);
                try {
                    RestaurantService.photo(restaurant.localId, vals);
                } catch (IOException e) {
                    Log.e(TAG, "downloading Street View image", e);
                    exception(e);
                }
            }
            break;
        case UPDATE:
            restaurant.localId = Restaurants.idForGlobalId(restaurant.globalId);
            if (restaurant.localId > 0) {
                vals = Restaurants.values(restaurant);
                try { // while place_id has UNIQUE constraint
                    cp.update(ContentUris.withAppendedId(RESTAURANTS_URI, restaurant.localId), vals, null, null);
                } catch (SQLiteConstraintException e) {
                    Log.e(TAG, "updating restaurant from sync", e);
                    exception(e);
                }
                try {
                    RestaurantService.photo(restaurant.localId, vals);
                } catch (IOException e) {
                    Log.e(TAG, "downloading Street View image", e);
                    exception(e);
                }
            } else { // re-added on other device
                sync.action = INSERT;
                syncRestaurant(cp, sync);
            }
            break;
        }
    }

    /**
     * Sync remote changes to a review.
     */
    private void syncReview(ContentProviderClient cp, Sync<Review> sync) throws RemoteException {
        Review review = sync.object;
        switch (sync.action) {
        case INSERT:
            ReviewsService.add(review);
            if (review.localId > 0 && review.userId > 0) { // friend's review
                cp.insert(SYNCS_URI, Syncs.values(sync));
            }
            break;
        case UPDATE:
            ContentValues vals = new ContentValues(3);
            vals.put(Reviews.COMMENTS, review.comments);
            vals.put(Reviews.RATING, review.rating);
            vals.put(Reviews.STATUS_ID, review.status.id);
            String sel = Reviews.GLOBAL_ID + " = ?";
            String[] args = { String.valueOf(review.globalId) };
            cp.update(REVIEWS_URI, vals, sel, args);
            break;
        }
        /* update restaurant rating and last visit if own review */
        String id = String.valueOf(Restaurants.idForGlobalId(review.restaurantId));
        if (sync.action != INSERT) { // already called in add
            cr().call(AUTHORITY_URI, CALL_UPDATE_RESTAURANT_RATING, id, null);
        }
        if (review.userId == 0 && sync.action != UPDATE) {
            cr().call(AUTHORITY_URI, CALL_UPDATE_RESTAURANT_LAST_VISIT, id, null);
        }
    }

    /**
     * Sync remote changes to a review draft.
     */
    private void syncReviewDraft(ContentProviderClient cp, Sync<Review> sync) throws RemoteException {
        ContentValues vals = ReviewDrafts.values(sync.object);
        /* get current version and increment it */
        Uri uri = ContentUris.withAppendedId(REVIEW_DRAFTS_URI, vals.getAsLong(ReviewDrafts.RESTAURANT_ID));
        String[] proj = { ReviewDrafts.VERSION };
        long version = Cursors.firstLong(cp.query(uri, proj, null, null, null));
        if (version >= 0) {
            vals.put(ReviewDrafts.VERSION, version + 1);
            cp.update(uri, vals, null, null);
        } else {
            cp.insert(REVIEW_DRAFTS_URI, vals);
        }
    }

    /**
     * Download and update the details of Google Places that haven't been refreshed recently.
     */
    private void refreshRestaurants(Context context, ContentProviderClient cp) throws RemoteException {
        int rows = Content.getCount(context, RESTAURANTS_URI);
        int days = 30; // age when restaurants should be refreshed
        Uri uri = Uris.limit(RESTAURANTS_URI, rows / days + 1);
        String[] proj = { _ID, Restaurants.PLACE_ID };
        String sel = Restaurants.PLACE_ID + " IS NOT NULL AND " + Restaurants.PLACE_ID
                + " NOT LIKE 'NOT_FOUND_%' AND (" + Restaurants.REFRESHED_ON + " IS NULL OR "
                + Restaurants.REFRESHED_ON + " <= datetime('now', '-" + days + " days')) AND "
                + Restaurants.STATUS_ID + " = ?";
        String[] args = { String.valueOf(ACTIVE.id) };
        String order = Restaurants.REFRESHED_ON + " IS NULL DESC, " + Restaurants.REFRESHED_ON + ", " + _ID;
        EasyCursor c = new EasyCursor(cp.query(uri, proj, sel, args, order));
        Result[] results = null;
        try {
            results = RestaurantsRefreshService.refresh(c);
        } catch (IOException e) {
            Log.e(TAG, "refreshing restaurants", e);
            exception(e);
        }
        c.close();
        if (results != null) {
            for (Result result : results) {
                if (result.newReviewTimes != null) {
                    int size = result.newReviewTimes.size();
                    for (int i = 0; i < size; i++) {
                        cp.insert(SYNCS_URI,
                                Syncs.values(result.newReviewTimes.keyAt(i), result.newReviewTimes.valueAt(i)));
                    }
                }
            }
        }
    }

    /**
     * Refresh the list of restaurants that are geofenced.
     */
    private void geofenceRestaurants(Context context, ContentProviderClient cp) throws RemoteException {
        GoogleApiClient client = Locations.client(context);
        if (client == null) {
            return;
        }
        /* remove existing geofences */
        PendingIntent intent = PendingIntent.getService(context, 0,
                new Intent(context, RestaurantGeofencingEventService.class), 0);
        PendingResult<Status> result = GeofencingApi.removeGeofences(client, intent);
        Status status = result.await();
        if (!status.isSuccess()) {
            String message = GeofenceStatusCodes.getStatusCodeString(status.getStatusCode());
            Log.e(TAG, "remove geofences failed: " + message);
            event("gms", "remove geofences failed", message);
        }
        /* add geofences if user wants to be notified */
        if (!Prefs.getStringSet(context, SHOW_NOTIFICATIONS)
                .contains(context.getString(R.string.at_restaurant_notifications_value))) {
            client.disconnect();
            return;
        }
        GeofencingRequest.Builder request = new GeofencingRequest.Builder();
        Uri uri = Uris.limit(Restaurants.CONTENT_URI, MAX_GEOFENCES);
        String[] proj = { _ID, Restaurants.LATITUDE, Restaurants.LONGITUDE };
        String sel = Restaurants.LATITUDE + " IS NOT NULL AND " + Restaurants.LONGITUDE + " IS NOT NULL AND "
                + Restaurants.GEOFENCE_NOTIFICATIONS + " = 1 AND " + Restaurants.STATUS_ID + " = ?";
        String[] args = { String.valueOf(ACTIVE.id) };
        EasyCursor c = new EasyCursor(cp.query(uri, proj, sel, args, _ID + " DESC"));
        while (c.moveToNext()) {
            request.addGeofence(new Geofence.Builder().setRequestId(String.valueOf(c.getLong(_ID)))
                    .setCircularRegion(c.getDouble(Restaurants.LATITUDE), c.getDouble(Restaurants.LONGITUDE),
                            GEOFENCE_RADIUS)
                    .setTransitionTypes(GEOFENCE_TRANSITION_ALL).setLoiteringDelay(15 * (int) MINUTE_IN_MILLIS)
                    .setNotificationResponsiveness(15 * (int) MINUTE_IN_MILLIS).setExpirationDuration(NEVER_EXPIRE)
                    .build());
        }
        if (c.getCount() > 0) {
            result = GeofencingApi.addGeofences(client, request.build(), intent);
            status = result.await();
            if (!status.isSuccess()) {
                String message = GeofenceStatusCodes.getStatusCodeString(status.getStatusCode());
                Log.e(TAG, "add geofences failed: " + message);
                event("gms", "add geofences failed", message);
            }
        }
        c.close();
        client.disconnect();
    }

    /**
     * Get the device's cloud ID and upload it to the server.
     *
     * @return null if the cloud ID could not be retrieved or sent to the server successfully
     */
    private String uploadCloudId(Context context) {
        int retries = 5;
        for (int i = 0; i < retries; i++) {
            try {
                String id = InstanceID.getInstance(context).getToken(PROJECT_ID, INSTANCE_ID_SCOPE);
                Boolean synced = Server.syncCloudId(id);
                if (synced != null) {
                    return synced ? id : null;
                }
            } catch (IOException e) {
                Log.e(TAG, "getting GCM token", e);
                exception(e);
            }
            if (i + 1 < retries) {
                SystemClock.sleep((1 << i) * 1000); // wait and retry, getToken can error
                event("gms", "gcm token retry", i + 1);
            } else {
                event("gms", "couldn't get gcm token after retries", retries);
            }
        }
        return null;
    }

    /**
     * Get the user's cloud notification key.
     *
     * @return null if the notification key could not be retrieved
     */
    private String getCloudNotificationKey(Context context, Account account, String cloudId) {
        if (AuthToken.isAvailable()) {
            try {
                String name = context.getPackageName() + ':' + account.name;
                String key = Gcm.getNotificationKey(PROJECT_ID, AuthToken.get(), name, cloudId);
                Log.v(null, "key:" + key); // todo return directly from func
                return key;
            } catch (RetrofitError e) {
                Log.e(TAG, "getting GCM notification key", e);
                exception(e);
            }
        }
        return null;
    }
}