Java tutorial
/* * Copyright (c) 2015-present, Parse, LLC. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. */ package com.parse; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.text.TextUtils; import android.util.Pair; import com.parse.OfflineQueryLogic.ConstraintMatcher; import org.json.JSONException; import org.json.JSONObject; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.WeakHashMap; import bolts.Capture; import bolts.Continuation; import bolts.Task; /** package */ class OfflineStore { /** * SQLite has a max of 999 SQL variables in a single statement. */ private static final int MAX_SQL_VARIABLES = 999; /** * Extends the normal JSON -> ParseObject decoding to also deal with placeholders for new objects * that have been saved offline. */ private class OfflineDecoder extends ParseDecoder { // A map of UUID -> Task that will be finished once the given ParseObject is loaded. // The Tasks should all be finished before decode is called. private Map<String, Task<ParseObject>> offlineObjects; private OfflineDecoder(Map<String, Task<ParseObject>> offlineObjects) { this.offlineObjects = offlineObjects; } @Override public Object decode(Object object) { // If we see an offline id, make sure to decode it. if (object instanceof JSONObject && ((JSONObject) object).optString("__type").equals("OfflineObject")) { String uuid = ((JSONObject) object).optString("uuid"); return offlineObjects.get(uuid).getResult(); } /* * Embedded objects can't show up here, because we never stored them that way offline. */ return super.decode(object); } } /** * An encoder that can encode objects that are available offline. After using this encoder, you * must call whenFinished() and wait for its result to be finished before the results of the * encoding will be valid. */ private class OfflineEncoder extends ParseEncoder { private ParseSQLiteDatabase db; private ArrayList<Task<Void>> tasks = new ArrayList<>(); private final Object tasksLock = new Object(); /** * Creates an encoder. * * @param db * A database connection to use. */ public OfflineEncoder(ParseSQLiteDatabase db) { this.db = db; } /** * The results of encoding an object with this encoder will not be valid until the task returned * by this method is finished. */ public Task<Void> whenFinished() { return Task.whenAll(tasks).continueWithTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> ignore) throws Exception { synchronized (tasksLock) { // It might be better to return an aggregate error here. for (Task<Void> task : tasks) { if (task.isFaulted() || task.isCancelled()) { return task; } } tasks.clear(); return Task.forResult((Void) null); } } }); } /** * Implements an encoding strategy for Parse Objects that uses offline ids when necessary. */ @Override public JSONObject encodeRelatedObject(ParseObject object) { try { if (object.getObjectId() != null) { JSONObject result = new JSONObject(); result.put("__type", "Pointer"); result.put("objectId", object.getObjectId()); result.put("className", object.getClassName()); return result; } final JSONObject result = new JSONObject(); result.put("__type", "OfflineObject"); synchronized (tasksLock) { tasks.add(getOrCreateUUIDAsync(object, db).onSuccess(new Continuation<String, Void>() { @Override public Void then(Task<String> task) throws Exception { result.put("uuid", task.getResult()); return null; } })); } return result; } catch (JSONException e) { // This can literally never happen. throw new RuntimeException(e); } } } // Lock for all members of the store. final private Object lock = new Object(); // Helper for accessing the database. final private OfflineSQLiteOpenHelper helper; /** * In-memory map of UUID -> ParseObject. This is used so that we can always return the same * instance for a given object. The only objects in this map are ones that are in the database. */ final private WeakValueHashMap<String, ParseObject> uuidToObjectMap = new WeakValueHashMap<>(); /** * In-memory map of ParseObject -> UUID. This is used to that when we see an unsaved ParseObject * that's already in the database, we can update the same record in the database. It stores a Task * instead of the String, because one thread may want to reserve the spot. Once the task is * finished, there will be a row for this UUID in the database. */ final private WeakHashMap<ParseObject, Task<String>> objectToUuidMap = new WeakHashMap<>(); /** * In-memory set of ParseObjects that have been fetched from the local database already. If the * object is in the map, a fetch of it has been started. If the value is a finished task, then the * fetch was completed. */ final private WeakHashMap<ParseObject, Task<ParseObject>> fetchedObjects = new WeakHashMap<>(); /** * Used by the static method to create the singleton. */ /* package */ OfflineStore(Context context) { this(new OfflineSQLiteOpenHelper(context)); } /* package */ OfflineStore(OfflineSQLiteOpenHelper helper) { this.helper = helper; } /** * Gets the UUID for the given object, if it has one. Otherwise, creates a new UUID for the object * and adds a new row to the database for the object with no data. */ private Task<String> getOrCreateUUIDAsync(final ParseObject object, ParseSQLiteDatabase db) { final String newUUID = UUID.randomUUID().toString(); final Task<String>.TaskCompletionSource tcs = Task.create(); synchronized (lock) { Task<String> uuidTask = objectToUuidMap.get(object); if (uuidTask != null) { return uuidTask; } // The object doesn't have a UUID yet, so we're gonna have to make one. objectToUuidMap.put(object, tcs.getTask()); uuidToObjectMap.put(newUUID, object); fetchedObjects.put(object, tcs.getTask().onSuccess(new Continuation<String, ParseObject>() { @Override public ParseObject then(Task<String> task) throws Exception { return object; } })); } /* * We need to put a placeholder row in the database so that later on, the save can just be an * update. This could be a pointer to an object that itself never gets saved offline, in which * case the consumer will just have to deal with that. */ ContentValues values = new ContentValues(); values.put(OfflineSQLiteOpenHelper.KEY_UUID, newUUID); values.put(OfflineSQLiteOpenHelper.KEY_CLASS_NAME, object.getClassName()); db.insertOrThrowAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, values) .continueWith(new Continuation<Void, Void>() { @Override public Void then(Task<Void> task) throws Exception { // This will signal that the UUID does represent a row in the database. tcs.setResult(newUUID); return null; } }); return tcs.getTask(); } /** * Gets an unfetched pointer to an object in the db, based on its uuid. The object may or may not * be in memory, but it must be in the database. If it is already in memory, that instance will be * returned. Since this is only for creating pointers to objects that are referenced by other * objects in the data store, that's a fair assumption. * * @param uuid * The object to retrieve. * @param db * The database instance to retrieve from. * @return The object with that UUID. */ private <T extends ParseObject> Task<T> getPointerAsync(final String uuid, ParseSQLiteDatabase db) { synchronized (lock) { @SuppressWarnings("unchecked") T existing = (T) uuidToObjectMap.get(uuid); if (existing != null) { return Task.forResult(existing); } } /* * We want to just return the pointer, but we have to look in the database to know if there's * something with this classname and object id already. */ String[] select = { OfflineSQLiteOpenHelper.KEY_CLASS_NAME, OfflineSQLiteOpenHelper.KEY_OBJECT_ID }; String where = OfflineSQLiteOpenHelper.KEY_UUID + " = ?"; String[] args = { uuid }; return db.queryAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, select, where, args) .onSuccess(new Continuation<Cursor, T>() { @Override public T then(Task<Cursor> task) throws Exception { Cursor cursor = task.getResult(); cursor.moveToFirst(); if (cursor.isAfterLast()) { cursor.close(); throw new IllegalStateException("Attempted to find non-existent uuid " + uuid); } synchronized (lock) { // We need to check again since another task might have come around and added it to // the map. //TODO (grantland): Maybe we should insert a Task that is resolved when the query // completes like we do in getOrCreateUUIDAsync? @SuppressWarnings("unchecked") T existing = (T) uuidToObjectMap.get(uuid); if (existing != null) { return existing; } String className = cursor.getString(0); String objectId = cursor.getString(1); cursor.close(); @SuppressWarnings("unchecked") T pointer = (T) ParseObject.createWithoutData(className, objectId); /* * If it doesn't have an objectId, we don't really need the UUID, and this simplifies * some other logic elsewhere if we only update the map for new objects. */ if (objectId == null) { uuidToObjectMap.put(uuid, pointer); objectToUuidMap.put(pointer, Task.forResult(uuid)); } return pointer; } } }); } /** * Runs a ParseQuery against the store's contents. * * @return The objects that match the query's constraints. */ /* package for OfflineQueryLogic */ <T extends ParseObject> Task<List<T>> findAsync(ParseQuery.State<T> query, ParseUser user, ParsePin pin, ParseSQLiteDatabase db) { return findAsync(query, user, pin, false, db); } /** * Runs a ParseQuery against the store's contents. May cause any instances of T to get fetched * from the offline database. TODO(klimt): Should the query consider objects that are in memory, * but not in the offline store? * * @param query The query. * @param user The user making the query. * @param pin (Optional) The pin we are querying across. If null, all pins. * @param isCount True if we are doing a count. * @param db The SQLiteDatabase. * @param <T> Subclass of ParseObject. * @return The objects that match the query's constraints. */ private <T extends ParseObject> Task<List<T>> findAsync(final ParseQuery.State<T> query, final ParseUser user, final ParsePin pin, final boolean isCount, final ParseSQLiteDatabase db) { /* * This is currently unused, but is here to allow future querying across objects that are in the * process of being deleted eventually. */ final boolean includeIsDeletingEventually = false; final OfflineQueryLogic queryLogic = new OfflineQueryLogic(this); final List<T> results = new ArrayList<>(); Task<Cursor> queryTask; if (pin == null) { String table = OfflineSQLiteOpenHelper.TABLE_OBJECTS; String[] select = { OfflineSQLiteOpenHelper.KEY_UUID }; String where = OfflineSQLiteOpenHelper.KEY_CLASS_NAME + "=?"; if (!includeIsDeletingEventually) { where += " AND " + OfflineSQLiteOpenHelper.KEY_IS_DELETING_EVENTUALLY + "=0"; } String[] args = { query.className() }; queryTask = db.queryAsync(table, select, where, args); } else { Task<String> uuidTask = objectToUuidMap.get(pin); if (uuidTask == null) { // Pin was never saved locally, therefore there won't be any results. return Task.forResult(results); } queryTask = uuidTask.onSuccessTask(new Continuation<String, Task<Cursor>>() { @Override public Task<Cursor> then(Task<String> task) throws Exception { String uuid = task.getResult(); String table = OfflineSQLiteOpenHelper.TABLE_OBJECTS + " A " + " INNER JOIN " + OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES + " B " + " ON A." + OfflineSQLiteOpenHelper.KEY_UUID + "=B." + OfflineSQLiteOpenHelper.KEY_UUID; String[] select = { "A." + OfflineSQLiteOpenHelper.KEY_UUID }; String where = OfflineSQLiteOpenHelper.KEY_CLASS_NAME + "=?" + " AND " + OfflineSQLiteOpenHelper.KEY_KEY + "=?"; if (!includeIsDeletingEventually) { where += " AND " + OfflineSQLiteOpenHelper.KEY_IS_DELETING_EVENTUALLY + "=0"; } String[] args = { query.className(), uuid }; return db.queryAsync(table, select, where, args); } }); } return queryTask.onSuccessTask(new Continuation<Cursor, Task<Void>>() { @Override public Task<Void> then(Task<Cursor> task) throws Exception { Cursor cursor = task.getResult(); List<String> uuids = new ArrayList<>(); for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) { uuids.add(cursor.getString(0)); } cursor.close(); // Find objects that match the where clause. final ConstraintMatcher<T> matcher = queryLogic.createMatcher(query, user); Task<Void> checkedAllObjects = Task.forResult(null); for (final String uuid : uuids) { final Capture<T> object = new Capture<>(); checkedAllObjects = checkedAllObjects.onSuccessTask(new Continuation<Void, Task<T>>() { @Override public Task<T> then(Task<Void> task) throws Exception { return getPointerAsync(uuid, db); } }).onSuccessTask(new Continuation<T, Task<T>>() { @Override public Task<T> then(Task<T> task) throws Exception { object.set(task.getResult()); return fetchLocallyAsync(object.get(), db); } }).onSuccessTask(new Continuation<T, Task<Boolean>>() { @Override public Task<Boolean> then(Task<T> task) throws Exception { if (!object.get().isDataAvailable()) { return Task.forResult(false); } return matcher.matchesAsync(object.get(), db); } }).onSuccess(new Continuation<Boolean, Void>() { @Override public Void then(Task<Boolean> task) { if (task.getResult()) { results.add(object.get()); } return null; } }); } return checkedAllObjects; } }).onSuccessTask(new Continuation<Void, Task<List<T>>>() { @Override public Task<List<T>> then(Task<Void> task) throws Exception { // Sort by any sort operators. OfflineQueryLogic.sort(results, query); // Apply the skip. List<T> trimmedResults = results; int skip = query.skip(); if (!isCount && skip >= 0) { skip = Math.min(query.skip(), trimmedResults.size()); trimmedResults = trimmedResults.subList(skip, trimmedResults.size()); } // Trim to the limit. int limit = query.limit(); if (!isCount && limit >= 0 && trimmedResults.size() > limit) { trimmedResults = trimmedResults.subList(0, limit); } // Fetch the includes. Task<Void> fetchedIncludesTask = Task.forResult(null); for (final T object : trimmedResults) { fetchedIncludesTask = fetchedIncludesTask.onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { return OfflineQueryLogic.fetchIncludesAsync(OfflineStore.this, object, query, db); } }); } final List<T> finalTrimmedResults = trimmedResults; return fetchedIncludesTask.onSuccess(new Continuation<Void, List<T>>() { @Override public List<T> then(Task<Void> task) throws Exception { return finalTrimmedResults; } }); } }); } /** * Gets the data for the given object from the offline database. Returns a task that will be * completed if data for the object was available. If the object is not in the cache, the task * will be faulted, with a CACHE_MISS error. * * @param object * The object to fetch. * @param db * A database connection to use. */ /* package for OfflineQueryLogic */ <T extends ParseObject> Task<T> fetchLocallyAsync(final T object, final ParseSQLiteDatabase db) { final Task<T>.TaskCompletionSource tcs = Task.create(); Task<String> uuidTask; synchronized (lock) { if (fetchedObjects.containsKey(object)) { /* * The object has already been fetched from the offline store, so any data that's in there * is already reflected in the in-memory version. There's nothing more to do. */ //noinspection unchecked return (Task<T>) fetchedObjects.get(object); } /* * Put a placeholder so that anyone else who attempts to fetch this object will just wait for * this call to finish doing it. */ //noinspection unchecked fetchedObjects.put(object, (Task<ParseObject>) tcs.getTask()); uuidTask = objectToUuidMap.get(object); } String className = object.getClassName(); String objectId = object.getObjectId(); /* * If this gets set, then it will contain data from the offline store that needs to be merged * into the existing object in memory. */ Task<String> jsonStringTask = Task.forResult(null); if (objectId == null) { // This Object has never been saved to Parse. if (uuidTask == null) { /* * This object was not pulled from the data store or previously saved to it, so there's * nothing that can be fetched from it. This isn't an error, because it's really convenient * to try to fetch objects from the offline store just to make sure they are up-to-date, and * we shouldn't force developers to specially handle this case. */ } else { /* * This object is a new ParseObject that is known to the data store, but hasn't been * fetched. The only way this could happen is if the object had previously been stored in * the offline store, then the object was removed from memory (maybe by rebooting), and then * a object with a pointer to it was fetched, so we only created the pointer. We need to * pull the data out of the database using the UUID. */ final String[] select = { OfflineSQLiteOpenHelper.KEY_JSON }; final String where = OfflineSQLiteOpenHelper.KEY_UUID + " = ?"; final Capture<String> uuid = new Capture<>(); jsonStringTask = uuidTask.onSuccessTask(new Continuation<String, Task<Cursor>>() { @Override public Task<Cursor> then(Task<String> task) throws Exception { uuid.set(task.getResult()); String[] args = { uuid.get() }; return db.queryAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, select, where, args); } }).onSuccess(new Continuation<Cursor, String>() { @Override public String then(Task<Cursor> task) throws Exception { Cursor cursor = task.getResult(); cursor.moveToFirst(); if (cursor.isAfterLast()) { cursor.close(); throw new IllegalStateException("Attempted to find non-existent uuid " + uuid.get()); } String json = cursor.getString(0); cursor.close(); return json; } }); } } else { if (uuidTask != null) { /* * This object is an existing ParseObject, and we must've already pulled its data out of the * offline store, or else we wouldn't know its UUID. This should never happen. */ tcs.setError(new IllegalStateException("This object must have already been " + "fetched from the local datastore, but isn't marked as fetched.")); synchronized (lock) { // Forget we even tried to fetch this object, so that retries will actually... retry. fetchedObjects.remove(object); } return tcs.getTask(); } /* * We've got a pointer to an existing ParseObject, but we've never pulled its data out of the * offline store. Since fetching from the server forces a fetch from the offline store, that * means this is a pointer. We need to try to find any existing entry for this object in the * database. */ String[] select = { OfflineSQLiteOpenHelper.KEY_JSON, OfflineSQLiteOpenHelper.KEY_UUID }; String where = String.format("%s = ? AND %s = ?", OfflineSQLiteOpenHelper.KEY_CLASS_NAME, OfflineSQLiteOpenHelper.KEY_OBJECT_ID); String[] args = { className, objectId }; jsonStringTask = db.queryAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, select, where, args) .onSuccess(new Continuation<Cursor, String>() { @Override public String then(Task<Cursor> task) throws Exception { Cursor cursor = task.getResult(); cursor.moveToFirst(); if (cursor.isAfterLast()) { /* * This is a pointer that came from Parse that references an object that has * never been saved in the offline store before. This just means there's no data * in the store that needs to be merged into the object. */ cursor.close(); throw new ParseException(ParseException.CACHE_MISS, "This object is not available in the offline cache."); } // we should fetch its data and record its UUID for future reference. String jsonString = cursor.getString(0); String newUUID = cursor.getString(1); cursor.close(); synchronized (lock) { /* * It's okay to put this object into the uuid map. No one will try to fetch * it, because it's already in the fetchedObjects map. And no one will try to * save to it without fetching it first, so everything should be just fine. */ objectToUuidMap.put(object, Task.forResult(newUUID)); uuidToObjectMap.put(newUUID, object); } return jsonString; } }); } return jsonStringTask.onSuccessTask(new Continuation<String, Task<Void>>() { @Override public Task<Void> then(Task<String> task) throws Exception { String jsonString = task.getResult(); if (jsonString == null) { /* * This means we tried to fetch an object from the database that was never actually saved * locally. This probably means that its parent object was saved locally and we just * created a pointer to this object. This should be considered a cache miss. */ return Task.forError(new ParseException(ParseException.CACHE_MISS, "Attempted to fetch an object offline which was never saved to the offline cache.")); } final JSONObject json; try { /* * We can assume that whatever is in the database is the last known server state. The only * things to maintain from the in-memory object are any changes since the object was last * put in the database. */ json = new JSONObject(jsonString); } catch (JSONException e) { return Task.forError(e); } // Fetch all the offline objects before we decode. final Map<String, Task<ParseObject>> offlineObjects = new HashMap<>(); (new ParseTraverser() { @Override protected boolean visit(Object object) { if (object instanceof JSONObject && ((JSONObject) object).optString("__type").equals("OfflineObject")) { String uuid = ((JSONObject) object).optString("uuid"); offlineObjects.put(uuid, getPointerAsync(uuid, db)); } return true; } }).setTraverseParseObjects(false).setYieldRoot(false).traverse(json); return Task.whenAll(offlineObjects.values()).onSuccess(new Continuation<Void, Void>() { @Override public Void then(Task<Void> task) throws Exception { object.mergeREST(object.getState(), json, new OfflineDecoder(offlineObjects)); return null; } }); } }).continueWithTask(new Continuation<Void, Task<T>>() { @Override public Task<T> then(Task<Void> task) throws Exception { if (task.isCancelled()) { tcs.setCancelled(); } else if (task.isFaulted()) { tcs.setError(task.getError()); } else { tcs.setResult(object); } return tcs.getTask(); } }); } /** * Gets the data for the given object from the offline database. Returns a task that will be * completed if data for the object was available. If the object is not in the cache, the task * will be faulted, with a CACHE_MISS error. * * @param object * The object to fetch. */ /* package */ <T extends ParseObject> Task<T> fetchLocallyAsync(final T object) { return runWithManagedConnection(new SQLiteDatabaseCallable<Task<T>>() { @Override public Task<T> call(ParseSQLiteDatabase db) { return fetchLocallyAsync(object, db); } }); } /** * Stores a single object in the local database. If the object is a pointer, isn't dirty, and has * an objectId already, it may not be saved, since it would provide no useful data. * * @param object * The object to save. * @param db * A database connection to use. */ private Task<Void> saveLocallyAsync(final String key, final ParseObject object, final ParseSQLiteDatabase db) { // If this is just a clean, unfetched pointer known to Parse, then there is nothing to save. if (object.getObjectId() != null && !object.isDataAvailable() && !object.hasChanges() && !object.hasOutstandingOperations()) { return Task.forResult(null); } final Capture<String> uuidCapture = new Capture<>(); // Make sure we have a UUID for the object to be saved. return getOrCreateUUIDAsync(object, db).onSuccessTask(new Continuation<String, Task<Void>>() { @Override public Task<Void> then(Task<String> task) throws Exception { String uuid = task.getResult(); uuidCapture.set(uuid); return updateDataForObjectAsync(uuid, object, db); } }).onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { final ContentValues values = new ContentValues(); values.put(OfflineSQLiteOpenHelper.KEY_KEY, key); values.put(OfflineSQLiteOpenHelper.KEY_UUID, uuidCapture.get()); return db.insertWithOnConflict(OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES, values, SQLiteDatabase.CONFLICT_IGNORE); } }); } /** * Stores an object (and optionally, every object it points to recursively) in the local database. * If any of the objects have not been fetched from Parse, they will not be stored. However, if * they have changed data, the data will be retained. To get the objects back later, you can use a * ParseQuery with a cache policy that uses the local cache, or you can create an unfetched * pointer with ParseObject.createWithoutData() and then call fetchFromLocalDatastore() on it. If you modify * the object after saving it locally, such as by fetching it or saving it, those changes will * automatically be applied to the cache. * * Any objects previously stored with the same key will be removed from the local database. * * @param object Root object * @param includeAllChildren {@code true} to recursively save all pointers. * @param db DB connection * @return A Task that will be resolved when saving is complete */ private Task<Void> saveLocallyAsync(final ParseObject object, final boolean includeAllChildren, final ParseSQLiteDatabase db) { final ArrayList<ParseObject> objectsInTree = new ArrayList<>(); // Fetch all objects locally in case they are being re-added if (!includeAllChildren) { objectsInTree.add(object); } else { (new ParseTraverser() { @Override protected boolean visit(Object object) { if (object instanceof ParseObject) { objectsInTree.add((ParseObject) object); } return true; } }).setYieldRoot(true).setTraverseParseObjects(true).traverse(object); } return saveLocallyAsync(object, objectsInTree, db); } private Task<Void> saveLocallyAsync(final ParseObject object, List<ParseObject> children, final ParseSQLiteDatabase db) { final List<ParseObject> objects = children != null ? new ArrayList<>(children) : new ArrayList<ParseObject>(); if (!objects.contains(object)) { objects.add(object); } // Call saveLocallyAsync for each of them individually. final List<Task<Void>> tasks = new ArrayList<>(); for (ParseObject obj : objects) { tasks.add(fetchLocallyAsync(obj, db).makeVoid()); } return Task.whenAll(tasks).continueWithTask(new Continuation<Void, Task<String>>() { @Override public Task<String> then(Task<Void> task) throws Exception { return objectToUuidMap.get(object); } }).onSuccessTask(new Continuation<String, Task<Void>>() { @Override public Task<Void> then(Task<String> task) throws Exception { String uuid = task.getResult(); if (uuid == null) { // The root object was never stored in the offline store, so nothing to unpin. return null; } // Delete all objects locally corresponding to the key we're trying to use in case it was // used before (overwrite) return unpinAsync(uuid, db); } }).onSuccessTask(new Continuation<Void, Task<String>>() { @Override public Task<String> then(Task<Void> task) throws Exception { return getOrCreateUUIDAsync(object, db); } }).onSuccessTask(new Continuation<String, Task<Void>>() { @Override public Task<Void> then(Task<String> task) throws Exception { String uuid = task.getResult(); // Call saveLocallyAsync for each of them individually. final List<Task<Void>> tasks = new ArrayList<>(); for (ParseObject obj : objects) { tasks.add(saveLocallyAsync(uuid, obj, db)); } return Task.whenAll(tasks); } }); } private Task<Void> unpinAsync(final ParseObject object, final ParseSQLiteDatabase db) { Task<String> uuidTask = objectToUuidMap.get(object); if (uuidTask == null) { // The root object was never stored in the offline store, so nothing to unpin. return Task.forResult(null); } return uuidTask.continueWithTask(new Continuation<String, Task<Void>>() { @Override public Task<Void> then(Task<String> task) throws Exception { final String uuid = task.getResult(); if (uuid == null) { // The root object was never stored in the offline store, so nothing to unpin. return Task.forResult(null); } return unpinAsync(uuid, db); } }); } private Task<Void> unpinAsync(final String key, final ParseSQLiteDatabase db) { final List<String> uuidsToDelete = new LinkedList<>(); // A continueWithTask that ends with "return task" is essentially a try-finally. return Task.forResult((Void) null).continueWithTask(new Continuation<Void, Task<Cursor>>() { @Override public Task<Cursor> then(Task<Void> task) throws Exception { // Fetch all uuids from Dependencies for key=? grouped by uuid having a count of 1 String sql = "SELECT " + OfflineSQLiteOpenHelper.KEY_UUID + " FROM " + OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES + " WHERE " + OfflineSQLiteOpenHelper.KEY_KEY + "=? AND " + OfflineSQLiteOpenHelper.KEY_UUID + " IN (" + " SELECT " + OfflineSQLiteOpenHelper.KEY_UUID + " FROM " + OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES + " GROUP BY " + OfflineSQLiteOpenHelper.KEY_UUID + " HAVING COUNT(" + OfflineSQLiteOpenHelper.KEY_UUID + ")=1" + ")"; String[] args = { key }; return db.rawQueryAsync(sql, args); } }).onSuccessTask(new Continuation<Cursor, Task<Void>>() { @Override public Task<Void> then(Task<Cursor> task) throws Exception { // DELETE FROM Objects Cursor cursor = task.getResult(); while (cursor.moveToNext()) { uuidsToDelete.add(cursor.getString(0)); } cursor.close(); return deleteObjects(uuidsToDelete, db); } }).onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { // DELETE FROM Dependencies String where = OfflineSQLiteOpenHelper.KEY_KEY + "=?"; String[] args = { key }; return db.deleteAsync(OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES, where, args); } }).onSuccess(new Continuation<Void, Void>() { @Override public Void then(Task<Void> task) throws Exception { synchronized (lock) { // Remove uuids from memory for (String uuid : uuidsToDelete) { ParseObject object = uuidToObjectMap.get(uuid); if (object != null) { objectToUuidMap.remove(object); uuidToObjectMap.remove(uuid); } } } return null; } }); } private Task<Void> deleteObjects(final List<String> uuids, final ParseSQLiteDatabase db) { if (uuids.size() <= 0) { return Task.forResult(null); } // SQLite has a max 999 SQL variables in a statement, so we need to split it up into manageable // chunks. We can do this because we're already in a transaction. if (uuids.size() > MAX_SQL_VARIABLES) { return deleteObjects(uuids.subList(0, MAX_SQL_VARIABLES), db) .onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { return deleteObjects(uuids.subList(MAX_SQL_VARIABLES, uuids.size()), db); } }); } String[] placeholders = new String[uuids.size()]; for (int i = 0; i < placeholders.length; i++) { placeholders[i] = "?"; } String where = OfflineSQLiteOpenHelper.KEY_UUID + " IN (" + TextUtils.join(",", placeholders) + ")"; // dynamic args String[] args = uuids.toArray(new String[uuids.size()]); return db.deleteAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, where, args); } /** * Takes an object that has been fetched from the database before and updates it with whatever * data is in memory. This will only be used when data comes back from the server after a fetch or * a save. */ /* package */ Task<Void> updateDataForObjectAsync(final ParseObject object) { Task<ParseObject> fetched; // Make sure the object is fetched. synchronized (lock) { fetched = fetchedObjects.get(object); if (fetched == null) { return Task .forError(new IllegalStateException("An object cannot be updated if it wasn't fetched.")); } } return fetched.continueWithTask(new Continuation<ParseObject, Task<Void>>() { @Override public Task<Void> then(Task<ParseObject> task) throws Exception { if (task.isFaulted()) { // Catch CACHE_MISS //noinspection ThrowableResultOfMethodCallIgnored if (task.getError() instanceof ParseException && ((ParseException) task.getError()).getCode() == ParseException.CACHE_MISS) { return Task.forResult(null); } return task.makeVoid(); } return helper.getWritableDatabaseAsync() .continueWithTask(new Continuation<ParseSQLiteDatabase, Task<Void>>() { @Override public Task<Void> then(Task<ParseSQLiteDatabase> task) throws Exception { final ParseSQLiteDatabase db = task.getResult(); return db.beginTransactionAsync() .onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { return updateDataForObjectAsync(object, db) .onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { return db.setTransactionSuccessfulAsync(); } }).continueWithTask(new Continuation<Void, Task<Void>>() { // } finally { @Override public Task<Void> then(Task<Void> task) throws Exception { db.endTransactionAsync(); db.closeAsync(); return task; } }); } }); } }); } }); } private Task<Void> updateDataForObjectAsync(final ParseObject object, final ParseSQLiteDatabase db) { // Make sure the object has a UUID. Task<String> uuidTask; synchronized (lock) { uuidTask = objectToUuidMap.get(object); if (uuidTask == null) { // It was fetched, but it has no UUID. That must mean it isn't actually in the database. return Task.forResult(null); } } return uuidTask.onSuccessTask(new Continuation<String, Task<Void>>() { @Override public Task<Void> then(Task<String> task) throws Exception { String uuid = task.getResult(); return updateDataForObjectAsync(uuid, object, db); } }); } private Task<Void> updateDataForObjectAsync(final String uuid, final ParseObject object, final ParseSQLiteDatabase db) { // Now actually encode the object as JSON. OfflineEncoder encoder = new OfflineEncoder(db); final JSONObject json = object.toRest(encoder); return encoder.whenFinished().onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { // Put the JSON in the database. String className = object.getClassName(); String objectId = object.getObjectId(); int isDeletingEventually = json.getInt(ParseObject.KEY_IS_DELETING_EVENTUALLY); final ContentValues values = new ContentValues(); values.put(OfflineSQLiteOpenHelper.KEY_CLASS_NAME, className); values.put(OfflineSQLiteOpenHelper.KEY_JSON, json.toString()); if (objectId != null) { values.put(OfflineSQLiteOpenHelper.KEY_OBJECT_ID, objectId); } values.put(OfflineSQLiteOpenHelper.KEY_IS_DELETING_EVENTUALLY, isDeletingEventually); String where = OfflineSQLiteOpenHelper.KEY_UUID + " = ?"; String[] args = { uuid }; return db.updateAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, values, where, args).makeVoid(); } }); } /* package */ Task<Void> deleteDataForObjectAsync(final ParseObject object) { return helper.getWritableDatabaseAsync() .continueWithTask(new Continuation<ParseSQLiteDatabase, Task<Void>>() { @Override public Task<Void> then(Task<ParseSQLiteDatabase> task) throws Exception { final ParseSQLiteDatabase db = task.getResult(); return db.beginTransactionAsync().onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { return deleteDataForObjectAsync(object, db) .onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { return db.setTransactionSuccessfulAsync(); } }).continueWithTask(new Continuation<Void, Task<Void>>() { // } finally { @Override public Task<Void> then(Task<Void> task) throws Exception { db.endTransactionAsync(); db.closeAsync(); return task; } }); } }); } }); } private Task<Void> deleteDataForObjectAsync(final ParseObject object, final ParseSQLiteDatabase db) { final Capture<String> uuid = new Capture<>(); // Make sure the object has a UUID. Task<String> uuidTask; synchronized (lock) { uuidTask = objectToUuidMap.get(object); if (uuidTask == null) { // It was fetched, but it has no UUID. That must mean it isn't actually in the database. return Task.forResult(null); } } uuidTask = uuidTask.onSuccessTask(new Continuation<String, Task<String>>() { @Override public Task<String> then(Task<String> task) throws Exception { uuid.set(task.getResult()); return task; } }); // If the object was the root of a pin, unpin it. Task<Void> unpinTask = uuidTask.onSuccessTask(new Continuation<String, Task<Cursor>>() { @Override public Task<Cursor> then(Task<String> task) throws Exception { // Find all the roots for this object. String[] select = { OfflineSQLiteOpenHelper.KEY_KEY }; String where = OfflineSQLiteOpenHelper.KEY_UUID + "=?"; String[] args = { uuid.get() }; return db.queryAsync(OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES, select, where, args); } }).onSuccessTask(new Continuation<Cursor, Task<Void>>() { @Override public Task<Void> then(Task<Cursor> task) throws Exception { // Try to unpin this object from the pin label if it's a root of the ParsePin. Cursor cursor = task.getResult(); List<String> uuids = new ArrayList<>(); for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) { uuids.add(cursor.getString(0)); } cursor.close(); List<Task<Void>> tasks = new ArrayList<>(); for (final String uuid : uuids) { Task<Void> unpinTask = getPointerAsync(uuid, db) .onSuccessTask(new Continuation<ParseObject, Task<ParsePin>>() { @Override public Task<ParsePin> then(Task<ParseObject> task) throws Exception { ParsePin pin = (ParsePin) task.getResult(); return fetchLocallyAsync(pin, db); } }).continueWithTask(new Continuation<ParsePin, Task<Void>>() { @Override public Task<Void> then(Task<ParsePin> task) throws Exception { ParsePin pin = task.getResult(); List<ParseObject> modified = pin.getObjects(); if (modified == null || !modified.contains(object)) { return task.makeVoid(); } modified.remove(object); if (modified.size() == 0) { return unpinAsync(uuid, db); } pin.setObjects(modified); return saveLocallyAsync(pin, true, db); } }); tasks.add(unpinTask); } return Task.whenAll(tasks); } }); // Delete the object from the Local Datastore in case it wasn't the root of a pin. return unpinTask.onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { String where = OfflineSQLiteOpenHelper.KEY_UUID + "=?"; String[] args = { uuid.get() }; return db.deleteAsync(OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES, where, args); } }).onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { String where = OfflineSQLiteOpenHelper.KEY_UUID + "=?"; String[] args = { uuid.get() }; return db.deleteAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, where, args); } }).onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { synchronized (lock) { // Clean up //TODO (grantland): we should probably clean up uuidToObjectMap and objectToUuidMap, but // getting the uuid requires a task and things might get a little funky... fetchedObjects.remove(object); } return task; } }); } //region ParsePin private Task<ParsePin> getParsePin(final String name, ParseSQLiteDatabase db) { ParseQuery.State<ParsePin> query = new ParseQuery.State.Builder<>(ParsePin.class) .whereEqualTo(ParsePin.KEY_NAME, name).build(); /* We need to call directly to the OfflineStore since we don't want/need a user to query for * ParsePins */ return findAsync(query, null, null, db).onSuccess(new Continuation<List<ParsePin>, ParsePin>() { @Override public ParsePin then(Task<List<ParsePin>> task) throws Exception { ParsePin pin = null; if (task.getResult() != null && task.getResult().size() > 0) { pin = task.getResult().get(0); } //TODO (grantland): What do we do if there are more than 1 result? if (pin == null) { pin = ParseObject.create(ParsePin.class); pin.setName(name); } return pin; } }); } /* package */ <T extends ParseObject> Task<Void> pinAllObjectsAsync(final String name, final List<T> objects, final boolean includeChildren) { return runWithManagedTransaction(new SQLiteDatabaseCallable<Task<Void>>() { @Override public Task<Void> call(ParseSQLiteDatabase db) { return pinAllObjectsAsync(name, objects, includeChildren, db); } }); } private <T extends ParseObject> Task<Void> pinAllObjectsAsync(final String name, final List<T> objects, final boolean includeChildren, final ParseSQLiteDatabase db) { if (objects == null || objects.size() == 0) { return Task.forResult(null); } return getParsePin(name, db).onSuccessTask(new Continuation<ParsePin, Task<Void>>() { @Override public Task<Void> then(Task<ParsePin> task) throws Exception { ParsePin pin = task.getResult(); //TODO (grantland): change to use relations. currently the related PO are only getting saved // offline as pointers. // ParseRelation<ParseObject> relation = pin.getRelation(KEY_OBJECTS); // relation.add(object); // Hack to store collections in a pin List<ParseObject> modified = pin.getObjects(); if (modified == null) { modified = new ArrayList<ParseObject>(objects); } else { for (ParseObject object : objects) { if (!modified.contains(object)) { modified.add(object); } } } pin.setObjects(modified); if (includeChildren) { return saveLocallyAsync(pin, true, db); } return saveLocallyAsync(pin, pin.getObjects(), db); } }); } /* package */ <T extends ParseObject> Task<Void> unpinAllObjectsAsync(final String name, final List<T> objects) { return runWithManagedTransaction(new SQLiteDatabaseCallable<Task<Void>>() { @Override public Task<Void> call(ParseSQLiteDatabase db) { return unpinAllObjectsAsync(name, objects, db); } }); } private <T extends ParseObject> Task<Void> unpinAllObjectsAsync(String name, final List<T> objects, final ParseSQLiteDatabase db) { if (objects == null || objects.size() == 0) { return Task.forResult(null); } return getParsePin(name, db).onSuccessTask(new Continuation<ParsePin, Task<Void>>() { @Override public Task<Void> then(Task<ParsePin> task) throws Exception { ParsePin pin = task.getResult(); //TODO (grantland): change to use relations. currently the related PO are only getting saved // offline as pointers. // ParseRelation<ParseObject> relation = pin.getRelation(KEY_OBJECTS); // relation.remove(object); // Hack to store collections in a pin List<ParseObject> modified = pin.getObjects(); if (modified == null) { // Unpin a pin that doesn't exist. Wat? return Task.forResult(null); } modified.removeAll(objects); if (modified.size() == 0) { return unpinAsync(pin, db); } pin.setObjects(modified); return saveLocallyAsync(pin, true, db); } }); } /* package */ Task<Void> unpinAllObjectsAsync(final String name) { return runWithManagedTransaction(new SQLiteDatabaseCallable<Task<Void>>() { @Override public Task<Void> call(ParseSQLiteDatabase db) { return unpinAllObjectsAsync(name, db); } }); } private Task<Void> unpinAllObjectsAsync(final String name, final ParseSQLiteDatabase db) { return getParsePin(name, db).continueWithTask(new Continuation<ParsePin, Task<Void>>() { @Override public Task<Void> then(Task<ParsePin> task) throws Exception { if (task.isFaulted()) { return task.makeVoid(); } ParsePin pin = task.getResult(); return unpinAsync(pin, db); } }); } /* package */ <T extends ParseObject> Task<List<T>> findFromPinAsync(final String name, final ParseQuery.State<T> state, final ParseUser user) { return runWithManagedConnection(new SQLiteDatabaseCallable<Task<List<T>>>() { @Override public Task<List<T>> call(ParseSQLiteDatabase db) { return findFromPinAsync(name, state, user, db); } }); } private <T extends ParseObject> Task<List<T>> findFromPinAsync(final String name, final ParseQuery.State<T> state, final ParseUser user, final ParseSQLiteDatabase db) { Task<ParsePin> task; if (name != null) { task = getParsePin(name, db); } else { task = Task.forResult(null); } return task.onSuccessTask(new Continuation<ParsePin, Task<List<T>>>() { @Override public Task<List<T>> then(Task<ParsePin> task) throws Exception { ParsePin pin = task.getResult(); return findAsync(state, user, pin, false, db); } }); } /* package */ <T extends ParseObject> Task<Integer> countFromPinAsync(final String name, final ParseQuery.State<T> state, final ParseUser user) { return runWithManagedConnection(new SQLiteDatabaseCallable<Task<Integer>>() { @Override public Task<Integer> call(ParseSQLiteDatabase db) { return countFromPinAsync(name, state, user, db); } }); } private <T extends ParseObject> Task<Integer> countFromPinAsync(final String name, final ParseQuery.State<T> state, final ParseUser user, final ParseSQLiteDatabase db) { Task<ParsePin> task; if (name != null) { task = getParsePin(name, db); } else { task = Task.forResult(null); } return task.onSuccessTask(new Continuation<ParsePin, Task<Integer>>() { @Override public Task<Integer> then(Task<ParsePin> task) throws Exception { ParsePin pin = task.getResult(); return findAsync(state, user, pin, true, db).onSuccess(new Continuation<List<T>, Integer>() { @Override public Integer then(Task<List<T>> task) throws Exception { return task.getResult().size(); } }); } }); } //endregion //region Single Instance /** * In-memory map of (className, objectId) -> ParseObject. This is used so that we can always * return the same instance for a given object. Objects in this map may or may not be in the * database. */ private final WeakValueHashMap<Pair<String, String>, ParseObject> classNameAndObjectIdToObjectMap = new WeakValueHashMap<>(); /** * This should be called by the ParseObject constructor notify the store that there is an object * with this className and objectId. */ /* package */ void registerNewObject(ParseObject object) { synchronized (lock) { String objectId = object.getObjectId(); if (objectId != null) { String className = object.getClassName(); Pair<String, String> classNameAndObjectId = Pair.create(className, objectId); classNameAndObjectIdToObjectMap.put(classNameAndObjectId, object); } } } /* package */ void unregisterObject(ParseObject object) { synchronized (lock) { String objectId = object.getObjectId(); if (objectId != null) { classNameAndObjectIdToObjectMap.remove(Pair.create(object.getClassName(), objectId)); } } } /** * This should only ever be called from ParseObject.createWithoutData(). * * @return a pair of ParseObject and Boolean. The ParseObject is the object. The Boolean is true * iff the object was newly created. */ /* package */ ParseObject getObject(String className, String objectId) { if (objectId == null) { throw new IllegalStateException("objectId cannot be null."); } Pair<String, String> classNameAndObjectId = Pair.create(className, objectId); // This lock should never be held by anyone doing disk or database access. synchronized (lock) { return classNameAndObjectIdToObjectMap.get(classNameAndObjectId); } } /** * When an object is finished saving, it gets an objectId. Then it should call this method to * clean up the bookeeping around ids. */ /* package */ void updateObjectId(ParseObject object, String oldObjectId, String newObjectId) { if (oldObjectId != null) { if (oldObjectId.equals(newObjectId)) { return; } throw new RuntimeException("objectIds cannot be changed in offline mode."); } String className = object.getClassName(); Pair<String, String> classNameAndNewObjectId = Pair.create(className, newObjectId); synchronized (lock) { // See if there's already an entry for the new object id. ParseObject existing = classNameAndObjectIdToObjectMap.get(classNameAndNewObjectId); if (existing != null && existing != object) { throw new RuntimeException( "Attempted to change an objectId to one that's " + "already known to the Offline Store."); } // Okay, all clear to add the new reference. classNameAndObjectIdToObjectMap.put(classNameAndNewObjectId, object); } } //endregion /** * Wraps SQLite operations with a managed SQLite connection. */ private <T> Task<T> runWithManagedConnection(final SQLiteDatabaseCallable<Task<T>> callable) { return helper.getWritableDatabaseAsync().onSuccessTask(new Continuation<ParseSQLiteDatabase, Task<T>>() { @Override public Task<T> then(Task<ParseSQLiteDatabase> task) throws Exception { final ParseSQLiteDatabase db = task.getResult(); return callable.call(db).continueWithTask(new Continuation<T, Task<T>>() { @Override public Task<T> then(Task<T> task) throws Exception { db.closeAsync(); return task; } }); } }); } /** * Wraps SQLite operations with a managed SQLite connection and transaction. */ private Task<Void> runWithManagedTransaction(final SQLiteDatabaseCallable<Task<Void>> callable) { return helper.getWritableDatabaseAsync().onSuccessTask(new Continuation<ParseSQLiteDatabase, Task<Void>>() { @Override public Task<Void> then(Task<ParseSQLiteDatabase> task) throws Exception { final ParseSQLiteDatabase db = task.getResult(); return db.beginTransactionAsync().onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { return callable.call(db).onSuccessTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { return db.setTransactionSuccessfulAsync(); } }).continueWithTask(new Continuation<Void, Task<Void>>() { @Override public Task<Void> then(Task<Void> task) throws Exception { db.endTransactionAsync(); db.closeAsync(); return task; } }); } }); } }); } private interface SQLiteDatabaseCallable<T> { T call(ParseSQLiteDatabase db); } /* * Methods for testing. */ /** * Clears all in-memory caches so that data must be retrieved from disk. */ void simulateReboot() { synchronized (lock) { uuidToObjectMap.clear(); objectToUuidMap.clear(); classNameAndObjectIdToObjectMap.clear(); fetchedObjects.clear(); } } /** * Clears the database on disk. */ void clearDatabase(Context context) { helper.clearDatabase(context); } }