co.easimart.EasimartObject Maven / Gradle / Ivy
package co.easimart;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.lang.reflect.Member;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.Lock;
import bolts.Capture;
import bolts.Continuation;
import bolts.Task;
/**
* The {@code EasimartObject} is a local representation of data that can be saved and retrieved from
* the Easimart cloud.
*
* The basic workflow for creating new data is to construct a new {@code EasimartObject}, use
* {@link #put(String, Object)} to fill it with data, and then use {@link #saveInBackground()} to
* persist to the cloud.
*
* The basic workflow for accessing existing data is to use a {@link EasimartQuery} to specify which
* existing data to retrieve.
*/
public class EasimartObject {
/* package */ static String server = "https://api.parse.com";
private static final String AUTO_CLASS_NAME = "_Automatic";
/* package */ static final String VERSION_NAME = "1.11.1-SNAPSHOT";
/*
REST JSON Keys
*/
private static final String KEY_OBJECT_ID = "objectId";
private static final String KEY_CLASS_NAME = "className";
private static final String KEY_ACL = "ACL";
private static final String KEY_CREATED_AT = "createdAt";
private static final String KEY_UPDATED_AT = "updatedAt";
/*
Internal JSON Keys - Used to store internal data when persisting {@code EasimartObject}s locally.
*/
private static final String KEY_COMPLETE = "__complete";
private static final String KEY_OPERATIONS = "__operations";
/* package */ static final String KEY_IS_DELETING_EVENTUALLY = "__isDeletingEventually";
// Because Grantland messed up naming this... We'll only try to read from this for backward
// compat, but I think we can be safe to assume any deleteEventuallys from long ago are obsolete
// and not check after a while
private static final String KEY_IS_DELETING_EVENTUALLY_OLD = "isDeletingEventually";
private static final Map, String> classNames =
new ConcurrentHashMap<>();
private static final Map> objectTypes =
new ConcurrentHashMap<>();
private static EasimartObjectController getObjectController() {
return EasimartCorePlugins.getInstance().getObjectController();
}
private static LocalIdManager getLocalIdManager() {
return EasimartCorePlugins.getInstance().getLocalIdManager();
}
/** package */ static class State {
public static Init> newBuilder(String className) {
if ("_User".equals(className)) {
return new EasimartUser.State.Builder();
}
return new Builder(className);
}
/** package */ static abstract class Init {
private final String className;
private String objectId;
private long createdAt = -1;
private long updatedAt = -1;
private boolean isComplete;
/* package */ Map serverData = new HashMap<>();
public Init(String className) {
this.className = className;
}
/* package */ Init(State state) {
className = state.className();
objectId = state.objectId();
createdAt = state.createdAt();
updatedAt = state.updatedAt();
for (String key : state.keySet()) {
serverData.put(key, state.get(key));
}
isComplete = state.isComplete();
}
/* package */ abstract T self();
/* package */ abstract S build();
public T objectId(String objectId) {
this.objectId = objectId;
return self();
}
public T createdAt(Date createdAt) {
this.createdAt = createdAt.getTime();
return self();
}
public T createdAt(long createdAt) {
this.createdAt = createdAt;
return self();
}
public T updatedAt(Date updatedAt) {
this.updatedAt = updatedAt.getTime();
return self();
}
public T updatedAt(long updatedAt) {
this.updatedAt = updatedAt;
return self();
}
public T isComplete(boolean complete) {
isComplete = complete;
return self();
}
public T put(String key, Object value) {
serverData.put(key, value);
return self();
}
public T remove(String key) {
serverData.remove(key);
return self();
}
public T clear() {
objectId = null;
createdAt = -1;
updatedAt = -1;
isComplete = false;
serverData.clear();
return self();
}
/**
* Applies a {@code State} on top of this {@code Builder} instance.
*
* @param other The {@code State} to apply over this instance.
* @return A new {@code Builder} instance.
*/
public T apply(State other) {
if (other.objectId() != null) {
objectId(other.objectId());
}
if (other.createdAt() > 0) {
createdAt(other.createdAt());
}
if (other.updatedAt() > 0) {
updatedAt(other.updatedAt());
}
isComplete(isComplete || other.isComplete());
for (String key : other.keySet()) {
put(key, other.get(key));
}
return self();
}
public T apply(EasimartOperationSet operations) {
for (String key : operations.keySet()) {
EasimartFieldOperation operation = operations.get(key);
Object oldValue = serverData.get(key);
Object newValue = operation.apply(oldValue, key);
if (newValue != null) {
put(key, newValue);
} else {
remove(key);
}
}
return self();
}
}
/* package */ static class Builder extends Init {
public Builder(String className) {
super(className);
}
public Builder(State state) {
super(state);
}
@Override
/* package */ Builder self() {
return this;
}
public State build() {
return new State(this);
}
}
private final String className;
private final String objectId;
private final long createdAt;
private final long updatedAt;
private final Map serverData;
private final boolean isComplete;
/* package */ State(Init> builder) {
className = builder.className;
objectId = builder.objectId;
createdAt = builder.createdAt;
updatedAt = builder.updatedAt > 0
? builder.updatedAt
: createdAt;
serverData = Collections.unmodifiableMap(new HashMap<>(builder.serverData));
isComplete = builder.isComplete;
}
@SuppressWarnings("unchecked")
public > T newBuilder() {
return (T) new Builder(this);
}
public String className() {
return className;
}
public String objectId() {
return objectId;
}
public long createdAt() {
return createdAt;
}
public long updatedAt() {
return updatedAt;
}
public boolean isComplete() {
return isComplete;
}
public Object get(String key) {
return serverData.get(key);
}
public Set keySet() {
return serverData.keySet();
}
@Override
public String toString() {
return String.format(Locale.US, "%s@%s[" +
"className=%s, objectId=%s, createdAt=%d, updatedAt=%d, isComplete=%s, " +
"serverData=%s]",
getClass().getName(),
Integer.toHexString(hashCode()),
className,
objectId,
createdAt,
updatedAt,
isComplete,
serverData);
}
}
/* package */ final Object mutex = new Object();
/* package */ final TaskQueue taskQueue = new TaskQueue();
private State state;
/* package */ final LinkedList operationSetQueue;
// Cached State
private final Map estimatedData;
private String localId;
private final EasimartMulticastDelegate saveEvent = new EasimartMulticastDelegate<>();
/* package */ boolean isDeleted;
//TODO (grantland): Derive this off the EventuallyPins as opposed to +/- count.
/* package */ int isDeletingEventually;
private static final ThreadLocal isCreatingPointerForObjectId =
new ThreadLocal() {
@Override
protected String initialValue() {
return null;
}
};
/*
* This is used only so that we can pass it to createWithoutData as the objectId to make it create
* an unfetched pointer that has no objectId. This is useful only in the context of the offline
* store, where you can have an unfetched pointer for an object that can later be fetched from the
* store.
*/
/* package */ private static final String NEW_OFFLINE_OBJECT_ID_PLACEHOLDER =
"*** Offline Object ***";
/**
* The base class constructor to call in subclasses. Uses the class name specified with the
* {@link EasimartClassName} annotation on the subclass.
*/
protected EasimartObject() {
this(AUTO_CLASS_NAME);
}
/**
* Constructs a new {@code EasimartObject} with no data in it. A {@code EasimartObject} constructed in
* this way will not have an objectId and will not persist to the database until {@link #save()}
* is called.
*
* Class names must be alphanumerical plus underscore, and start with a letter. It is recommended
* to name classes in PascalCaseLikeThis
.
*
* @param theClassName
* The className for this {@code EasimartObject}.
*/
public EasimartObject(String theClassName) {
// We use a ThreadLocal rather than passing a parameter so that createWithoutData can do the
// right thing with subclasses. It's ugly and terrible, but it does provide the development
// experience we generally want, so... yeah. Sorry to whomever has to deal with this in the
// future. I pinky-swear we won't make a habit of this -- you believe me, don't you?
String objectIdForPointer = isCreatingPointerForObjectId.get();
if (theClassName == null) {
throw new IllegalArgumentException(
"You must specify a Easimart class name when creating a new EasimartObject.");
}
if (AUTO_CLASS_NAME.equals(theClassName)) {
theClassName = getClassName(this.getClass());
}
// If this is supposed to be created by a factory but wasn't, throw an exception.
if (this.getClass().equals(EasimartObject.class) && objectTypes.containsKey(theClassName)
&& !objectTypes.get(theClassName).isInstance(this)) {
throw new IllegalArgumentException(
"You must create this type of EasimartObject using EasimartObject.create() or the proper subclass.");
}
// If this is an unregistered subclass, throw an exception.
if (!this.getClass().equals(EasimartObject.class)
&& !this.getClass().equals(objectTypes.get(theClassName))) {
throw new IllegalArgumentException(
"You must register this EasimartObject subclass before instantiating it.");
}
operationSetQueue = new LinkedList<>();
operationSetQueue.add(new EasimartOperationSet());
estimatedData = new HashMap<>();
State.Init> builder = newStateBuilder(theClassName);
// When called from new, assume hasData for the whole object is true.
if (objectIdForPointer == null) {
setDefaultValues();
builder.isComplete(true);
} else {
if (!objectIdForPointer.equals(NEW_OFFLINE_OBJECT_ID_PLACEHOLDER)) {
builder.objectId(objectIdForPointer);
}
builder.isComplete(false);
}
// This is a new untouched object, we don't need cache rebuilding, etc.
state = builder.build();
OfflineStore store = Easimart.getLocalDatastore();
if (store != null) {
store.registerNewObject(this);
}
}
/**
* Creates a new {@code EasimartObject} based upon a class name. If the class name is a special type
* (e.g. for {@code EasimartUser}), then the appropriate type of {@code EasimartObject} is returned.
*
* @param className
* The class of object to create.
* @return A new {@code EasimartObject} for the given class name.
*/
public static EasimartObject create(String className) {
if (objectTypes.containsKey(className)) {
try {
return objectTypes.get(className).newInstance();
} catch (Exception e) {
if (e instanceof RuntimeException) {
throw (RuntimeException) e;
}
throw new RuntimeException("Failed to create instance of subclass.", e);
}
}
return new EasimartObject(className);
}
/**
* Creates a new {@code EasimartObject} based upon a subclass type. Note that the object will be
* created based upon the {@link EasimartClassName} of the given subclass type. For example, calling
* create(EasimartUser.class) may create an instance of a custom subclass of {@code EasimartUser}.
*
* @param subclass
* The class of object to create.
* @return A new {@code EasimartObject} based upon the class name of the given subclass type.
*/
@SuppressWarnings("unchecked")
public static T create(Class subclass) {
return (T) create(getClassName(subclass));
}
/**
* Creates a reference to an existing {@code EasimartObject} for use in creating associations between
* {@code EasimartObject}s. Calling {@link #isDataAvailable()} on this object will return
* {@code false} until {@link #fetchIfNeeded()} or {@link #refresh()} has been called. No network
* request will be made.
*
* @param className
* The object's class.
* @param objectId
* The object id for the referenced object.
* @return A {@code EasimartObject} without data.
*/
public static EasimartObject createWithoutData(String className, String objectId) {
OfflineStore store = Easimart.getLocalDatastore();
try {
if (objectId == null) {
isCreatingPointerForObjectId.set(NEW_OFFLINE_OBJECT_ID_PLACEHOLDER);
} else {
isCreatingPointerForObjectId.set(objectId);
}
EasimartObject object = null;
if (store != null && objectId != null) {
object = store.getObject(className, objectId);
}
if (object == null) {
object = create(className);
if (object.hasChanges()) {
throw new IllegalStateException(
"A EasimartObject subclass default constructor must not make changes "
+ "to the object that cause it to be dirty."
);
}
}
return object;
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("Failed to create instance of subclass.", e);
} finally {
isCreatingPointerForObjectId.set(null);
}
}
/**
* Creates a reference to an existing {@code EasimartObject} for use in creating associations between
* {@code EasimartObject}s. Calling {@link #isDataAvailable()} on this object will return
* {@code false} until {@link #fetchIfNeeded()} or {@link #refresh()} has been called. No network
* request will be made.
*
* @param subclass
* The {@code EasimartObject} subclass to create.
* @param objectId
* The object id for the referenced object.
* @return A {@code EasimartObject} without data.
*/
@SuppressWarnings({"unused", "unchecked"})
public static T createWithoutData(Class subclass, String objectId) {
return (T) createWithoutData(getClassName(subclass), objectId);
}
private static boolean isAccessible(Member m) {
return Modifier.isPublic(m.getModifiers())
|| (m.getDeclaringClass().getPackage().getName().equals("co.easimart")
&& !Modifier.isPrivate(m.getModifiers()) && !Modifier.isProtected(m.getModifiers()));
}
/**
* Registers a custom subclass type with the Easimart SDK, enabling strong-typing of those
* {@code EasimartObject}s whenever they appear. Subclasses must specify the {@link EasimartClassName}
* annotation and have a default constructor.
*
* @param subclass
* The subclass type to register.
*/
public static void registerSubclass(Class extends EasimartObject> subclass) {
String className = getClassName(subclass);
if (className == null) {
throw new IllegalArgumentException("No EasimartClassName annotation provided on " + subclass);
}
if (subclass.getDeclaredConstructors().length > 0) {
try {
if (!isAccessible(subclass.getDeclaredConstructor())) {
throw new IllegalArgumentException("Default constructor for " + subclass
+ " is not accessible.");
}
} catch (NoSuchMethodException e) {
throw new IllegalArgumentException("No default constructor provided for " + subclass);
}
}
Class extends EasimartObject> oldValue = objectTypes.get(className);
if (oldValue != null && subclass.isAssignableFrom(oldValue)) {
// The old class was already more descendant than the new subclass type. No-op.
return;
}
objectTypes.put(className, subclass);
if (oldValue != null && !subclass.equals(oldValue)) {
if (className.equals(getClassName(EasimartUser.class))) {
EasimartUser.getCurrentUserController().clearFromMemory();
} else if (className.equals(getClassName(EasimartInstallation.class))) {
EasimartInstallation.getCurrentInstallationController().clearFromMemory();
}
}
}
/* package for tests */ static void unregisterSubclass(Class extends EasimartObject> subclass) {
unregisterSubclass(getClassName(subclass));
}
/* package for tests */ static void unregisterSubclass(String className) {
objectTypes.remove(className);
}
/**
* Adds a task to the queue for all of the given objects.
*/
static Task enqueueForAll(final List extends EasimartObject> objects,
Continuation> taskStart) {
// The task that will be complete when all of the child queues indicate they're ready to start.
final Task.TaskCompletionSource readyToStart = Task.create();
// First, we need to lock the mutex for the queue for every object. We have to hold this
// from at least when taskStart() is called to when obj.taskQueue enqueue is called, so
// that saves actually get executed in the order they were setup by taskStart().
// The locks have to be sorted so that we always acquire them in the same order.
// Otherwise, there's some risk of deadlock.
List locks = new ArrayList<>(objects.size());
for (EasimartObject obj : objects) {
locks.add(obj.taskQueue.getLock());
}
LockSet lock = new LockSet(locks);
lock.lock();
try {
// The task produced by TaskStart
final Task fullTask;
try {
// By running this immediately, we allow everything prior to toAwait to run before waiting
// for all of the queues on all of the objects.
fullTask = taskStart.then(readyToStart.getTask());
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException(e);
}
// Add fullTask to each of the objects' queues.
final List> childTasks = new ArrayList<>();
for (EasimartObject obj : objects) {
obj.taskQueue.enqueue(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
childTasks.add(task);
return fullTask;
}
});
}
// When all of the objects' queues are ready, signal fullTask that it's ready to go on.
Task.whenAll(childTasks).continueWith(new Continuation() {
@Override
public Void then(Task task) throws Exception {
readyToStart.setResult(null);
return null;
}
});
return fullTask;
} finally {
lock.unlock();
}
}
/**
* Converts a {@code EasimartObject.State} to a {@code EasimartObject}.
*
* @param state
* The {@code EasimartObject.State} to convert from.
* @return A {@code EasimartObject} instance.
*/
/* package */ static T from(EasimartObject.State state) {
@SuppressWarnings("unchecked")
T object = (T) EasimartObject.createWithoutData(state.className(), state.objectId());
synchronized (object.mutex) {
State newState;
if (state.isComplete()) {
newState = state;
} else {
newState = object.getState().newBuilder().apply(state).build();
}
object.setState(newState);
}
return object;
}
/**
* Creates a new {@code EasimartObject} based on data from the Easimart server.
*
* @param json
* The object's data.
* @param defaultClassName
* The className of the object, if none is in the JSON.
* @param isComplete
* {@code true} if this is all of the data on the server for the object.
*/
/* package */ static T fromJSON(JSONObject json, String defaultClassName,
boolean isComplete) {
return fromJSON(json, defaultClassName, isComplete, EasimartDecoder.get());
}
/**
* Creates a new {@code EasimartObject} based on data from the Easimart server.
*
* @param json
* The object's data.
* @param defaultClassName
* The className of the object, if none is in the JSON.
* @param isComplete
* {@code true} if this is all of the data on the server for the object.
* @param decoder
* Delegate for knowing how to decode the values in the JSON.
*/
/* package */ static T fromJSON(JSONObject json, String defaultClassName,
boolean isComplete, EasimartDecoder decoder) {
String className = json.optString(KEY_CLASS_NAME, defaultClassName);
if (className == null) {
return null;
}
String objectId = json.optString(KEY_OBJECT_ID, null);
@SuppressWarnings("unchecked")
T object = (T) EasimartObject.createWithoutData(className, objectId);
State newState = object.mergeFromServer(object.getState(), json, decoder, isComplete);
object.setState(newState);
return object;
}
/**
* Method used by parse server webhooks implementation to convert raw JSON to Easimart Object
*
* Method is used by parse server webhooks implementation to create a
* new {@code EasimartObject} from the incoming json payload. The method is different from
* {@link #fromJSON(JSONObject, String, boolean)} ()} in that it calls
* {@link #build(JSONObject, EasimartDecoder)} which populates operation queue
* rather then the server data from the incoming JSON, as at external server the incoming
* JSON may not represent the actual server data. Also it handles
* {@link EasimartFieldOperations} separately.
*
* @param json
* The object's data.
* @param decoder
* Delegate for knowing how to decode the values in the JSON.
*/
/* package */ static T fromJSONPayload(
JSONObject json, EasimartDecoder decoder) {
String className = json.optString(KEY_CLASS_NAME);
if (className == null || EasimartTextUtils.isEmpty(className)) {
return null;
}
String objectId = json.optString(KEY_OBJECT_ID, null);
@SuppressWarnings("unchecked")
T object = (T) EasimartObject.createWithoutData(className, objectId);
object.build(json, decoder);
return object;
}
//region Getter/Setter helper methods
/* package */ State.Init> newStateBuilder(String className) {
return new State.Builder(className);
}
/* package */ State getState() {
synchronized (mutex) {
return state;
}
}
/**
* Updates the current state of this object as well as updates our in memory cached state.
*
* @param newState The new state.
*/
/* package */ void setState(State newState) {
synchronized (mutex) {
setState(newState, true);
}
}
private void setState(State newState, boolean notifyIfObjectIdChanges) {
synchronized (mutex) {
String oldObjectId = state.objectId();
String newObjectId = newState.objectId();
state = newState;
if (notifyIfObjectIdChanges && !EasimartTextUtils.equals(oldObjectId, newObjectId)) {
notifyObjectIdChanged(oldObjectId, newObjectId);
}
rebuildEstimatedData();
}
}
/**
* Accessor to the class name.
*/
public String getClassName() {
synchronized (mutex) {
return state.className();
}
}
/**
* This reports time as the server sees it, so that if you make changes to a {@code EasimartObject}, then
* wait a while, and then call {@link #save()}, the updated time will be the time of the
* {@link #save()} call rather than the time the object was changed locally.
*
* @return The last time this object was updated on the server.
*/
public Date getUpdatedAt() {
long updatedAt = getState().updatedAt();
return updatedAt > 0
? new Date(updatedAt)
: null;
}
/**
* This reports time as the server sees it, so that if you create a {@code EasimartObject}, then wait a
* while, and then call {@link #save()}, the creation time will be the time of the first
* {@link #save()} call rather than the time the object was created locally.
*
* @return The first time this object was saved on the server.
*/
public Date getCreatedAt() {
long createdAt = getState().createdAt();
return createdAt > 0
? new Date(createdAt)
: null;
}
//endregion
/**
* Returns a set view of the keys contained in this object. This does not include createdAt,
* updatedAt, authData, or objectId. It does include things like username and ACL.
*/
public Set keySet() {
synchronized (mutex) {
return Collections.unmodifiableSet(estimatedData.keySet());
}
}
/**
* Copies all of the operations that have been performed on another object since its last save
* onto this one.
*/
/* package */ void copyChangesFrom(EasimartObject other) {
synchronized (mutex) {
EasimartOperationSet operations = other.operationSetQueue.getFirst();
for (String key : operations.keySet()) {
performOperation(key, operations.get(key));
}
}
}
/* package */ void mergeFromObject(EasimartObject other) {
synchronized (mutex) {
// If they point to the same instance, we don't need to merge.
if (this == other) {
return;
}
State copy = other.getState().newBuilder().build();
// We don't want to notify if an objectId changed here since we utilize this method to merge
// an anonymous current user with a new EasimartUser instance that's calling signUp(). This
// doesn't make any sense and we should probably remove that code in EasimartUser.
// Otherwise, there shouldn't be any objectId changes here since this method is only otherwise
// used in fetchAll.
setState(copy, false);
}
}
/**
* Clears changes to this object's {@code key} made since the last call to {@link #save()} or
* {@link #saveInBackground()}.
*
* @param key The {@code key} to revert changes for.
*/
public void revert(String key) {
synchronized (mutex) {
if (isDirty(key)) {
currentOperations().remove(key);
rebuildEstimatedData();
}
}
}
/**
* Clears any changes to this object made since the last call to {@link #save()} or
* {@link #saveInBackground()}.
*/
public void revert() {
synchronized (mutex) {
if (isDirty()) {
currentOperations().clear();
rebuildEstimatedData();
}
}
}
/**
* Deep traversal on this object to grab a copy of any object referenced by this object. These
* instances may have already been fetched, and we don't want to lose their data when refreshing
* or saving.
*
* @return the map mapping from objectId to {@code EasimartObject} which has been fetched.
*/
private Map collectFetchedObjects() {
final Map fetchedObjects = new HashMap<>();
EasimartTraverser traverser = new EasimartTraverser() {
@Override
protected boolean visit(Object object) {
if (object instanceof EasimartObject) {
EasimartObject easimartObject = (EasimartObject) object;
State state = easimartObject.getState();
if (state.objectId() != null && state.isComplete()) {
fetchedObjects.put(state.objectId(), easimartObject);
}
}
return true;
}
};
traverser.traverse(estimatedData);
return fetchedObjects;
}
/**
* Helper method called by {@link #fromJSONPayload(JSONObject, EasimartDecoder)}
*
* The method helps webhooks implementation to build Easimart object from raw JSON payload.
* It is different from {@link #mergeFromServer(State, JSONObject, EasimartDecoder, boolean)}
* as the method saves the key value pairs (other than className, objectId, updatedAt and
* createdAt) in the operation queue rather than the server data. It also handles
* {@link EasimartFieldOperations} differently.
*
* @param json : JSON object to be converted to Easimart object
* @param decoder : Decoder to be used for Decoding JSON
*/
/* package */ void build(JSONObject json, EasimartDecoder decoder) {
try {
State.Builder builder = new State.Builder(state)
.isComplete(true);
builder.clear();
Iterator> keys = json.keys();
while (keys.hasNext()) {
String key = (String) keys.next();
/*
__className: Used by fromJSONPayload, should be stripped out by the time it gets here...
*/
if (key.equals(KEY_CLASS_NAME)) {
continue;
}
if (key.equals(KEY_OBJECT_ID)) {
String newObjectId = json.getString(key);
builder.objectId(newObjectId);
continue;
}
if (key.equals(KEY_CREATED_AT)) {
builder.createdAt(EasimartDateFormat.getInstance().parse(json.getString(key)));
continue;
}
if (key.equals(KEY_UPDATED_AT)) {
builder.updatedAt(EasimartDateFormat.getInstance().parse(json.getString(key)));
continue;
}
Object value = json.get(key);
Object decodedObject = decoder.decode(value);
if (decodedObject instanceof EasimartFieldOperation) {
performOperation(key, (EasimartFieldOperation)decodedObject);
}
else {
put(key, decodedObject);
}
}
setState(builder.build());
} catch (JSONException e) {
throw new RuntimeException(e);
}
}
/**
* Merges from JSON in REST format.
*
* Updates this object with data from the server.
*
* @see #toJSONObjectForSaving(State, EasimartOperationSet, EasimartEncoder)
*/
/* package */ State mergeFromServer(
State state, JSONObject json, EasimartDecoder decoder, boolean completeData) {
try {
// If server data is complete, consider this object to be fetched.
State.Init> builder = state.newBuilder();
if (completeData) {
builder.clear();
}
builder.isComplete(state.isComplete() || completeData);
Iterator> keys = json.keys();
while (keys.hasNext()) {
String key = (String) keys.next();
/*
__type: Returned by queries and cloud functions to designate body is a EasimartObject
__className: Used by fromJSON, should be stripped out by the time it gets here...
*/
if (key.equals("__type") || key.equals(KEY_CLASS_NAME)) {
continue;
}
if (key.equals(KEY_OBJECT_ID)) {
String newObjectId = json.getString(key);
builder.objectId(newObjectId);
continue;
}
if (key.equals(KEY_CREATED_AT)) {
builder.createdAt(EasimartDateFormat.getInstance().parse(json.getString(key)));
continue;
}
if (key.equals(KEY_UPDATED_AT)) {
builder.updatedAt(EasimartDateFormat.getInstance().parse(json.getString(key)));
continue;
}
if (key.equals(KEY_ACL)) {
EasimartACL acl = EasimartACL.createACLFromJSONObject(json.getJSONObject(key), decoder);
builder.put(KEY_ACL, acl);
continue;
}
Object value = json.get(key);
Object decodedObject = decoder.decode(value);
builder.put(key, decodedObject);
}
return builder.build();
} catch (JSONException e) {
throw new RuntimeException(e);
}
}
//region LDS-processing methods.
/**
* Convert to REST JSON for persisting in LDS.
*
* @see #mergeREST(State, org.json.JSONObject, EasimartDecoder)
*/
/* package */ JSONObject toRest(EasimartEncoder encoder) {
State state;
List operationSetQueueCopy;
synchronized (mutex) {
// mutex needed to lock access to state and operationSetQueue and operationSetQueue & children
// are mutable
state = getState();
// operationSetQueue is a List of Lists, so we'll need custom copying logic
int operationSetQueueSize = operationSetQueue.size();
operationSetQueueCopy = new ArrayList<>(operationSetQueueSize);
for (int i = 0; i < operationSetQueueSize; i++) {
EasimartOperationSet original = operationSetQueue.get(i);
EasimartOperationSet copy = new EasimartOperationSet(original);
operationSetQueueCopy.add(copy);
}
}
return toRest(state, operationSetQueueCopy, encoder);
}
/* package */ JSONObject toRest(
State state, List operationSetQueue, EasimartEncoder objectEncoder) {
// Public data goes in dataJSON; special fields go in objectJSON.
JSONObject json = new JSONObject();
try {
// REST JSON (State)
json.put(KEY_CLASS_NAME, state.className());
if (state.objectId() != null) {
json.put(KEY_OBJECT_ID, state.objectId());
}
if (state.createdAt() > 0) {
json.put(KEY_CREATED_AT,
EasimartDateFormat.getInstance().format(new Date(state.createdAt())));
}
if (state.updatedAt() > 0) {
json.put(KEY_UPDATED_AT,
EasimartDateFormat.getInstance().format(new Date(state.updatedAt())));
}
for (String key : state.keySet()) {
Object value = state.get(key);
json.put(key, objectEncoder.encode(value));
}
// Internal JSON
//TODO(klimt): We'll need to rip all this stuff out and put it somewhere else if we start
// using the REST api and want to send data to Easimart.
json.put(KEY_COMPLETE, state.isComplete());
json.put(KEY_IS_DELETING_EVENTUALLY, isDeletingEventually);
// Operation Set Queue
JSONArray operations = new JSONArray();
for (EasimartOperationSet operationSet : operationSetQueue) {
operations.put(operationSet.toRest(objectEncoder));
}
json.put(KEY_OPERATIONS, operations);
} catch (JSONException e) {
throw new RuntimeException("could not serialize object to JSON");
}
return json;
}
/**
* Merge with REST JSON from LDS.
*
* @see #toRest(EasimartEncoder)
*/
/* package */ void mergeREST(State state, JSONObject json, EasimartDecoder decoder) {
ArrayList saveEventuallyOperationSets = new ArrayList<>();
synchronized (mutex) {
try {
boolean isComplete = json.getBoolean(KEY_COMPLETE);
isDeletingEventually = EasimartJSONUtils.getInt(json, Arrays.asList(
KEY_IS_DELETING_EVENTUALLY,
KEY_IS_DELETING_EVENTUALLY_OLD
));
JSONArray operations = json.getJSONArray(KEY_OPERATIONS);
{
EasimartOperationSet newerOperations = currentOperations();
operationSetQueue.clear();
// Add and enqueue any saveEventually operations, roll forward any other operation sets
// (operation sets here are generally failed/incomplete saves).
EasimartOperationSet current = null;
for (int i = 0; i < operations.length(); i++) {
JSONObject operationSetJSON = operations.getJSONObject(i);
EasimartOperationSet operationSet = EasimartOperationSet.fromRest(operationSetJSON, decoder);
if (operationSet.isSaveEventually()) {
if (current != null) {
operationSetQueue.add(current);
current = null;
}
saveEventuallyOperationSets.add(operationSet);
operationSetQueue.add(operationSet);
continue;
}
if (current != null) {
operationSet.mergeFrom(current);
}
current = operationSet;
}
if (current != null) {
operationSetQueue.add(current);
}
// Merge the changes that were previously in memory into the updated object.
currentOperations().mergeFrom(newerOperations);
}
// We only want to merge server data if we our updatedAt is null (we're unsaved or from
// #createWithoutData) or if the JSON's updatedAt is newer than ours.
boolean mergeServerData = false;
if (state.updatedAt() < 0) {
mergeServerData = true;
} else if (json.has(KEY_UPDATED_AT)) {
Date otherUpdatedAt = EasimartDateFormat.getInstance().parse(json.getString(KEY_UPDATED_AT));
if (new Date(state.updatedAt()).compareTo(otherUpdatedAt) < 0) {
mergeServerData = true;
}
}
if (mergeServerData) {
// Clean up internal json keys
JSONObject mergeJSON = EasimartJSONUtils.create(json, Arrays.asList(
KEY_COMPLETE, KEY_IS_DELETING_EVENTUALLY, KEY_IS_DELETING_EVENTUALLY_OLD,
KEY_OPERATIONS
));
State newState = mergeFromServer(state, mergeJSON, decoder, isComplete);
setState(newState);
}
} catch (JSONException e) {
throw new RuntimeException(e);
}
}
// We cannot modify the taskQueue inside synchronized (mutex).
for (EasimartOperationSet operationSet : saveEventuallyOperationSets) {
enqueueSaveEventuallyOperationAsync(operationSet);
}
}
//endregion
private boolean hasDirtyChildren() {
synchronized (mutex) {
// We only need to consider the currently estimated children here,
// because they're the only ones that might need to be saved in a
// subsequent call to save, which is the meaning of "dirtiness".
List unsavedChildren = new ArrayList<>();
collectDirtyChildren(estimatedData, unsavedChildren, null);
return unsavedChildren.size() > 0;
}
}
/**
* Whether any key-value pair in this object (or its children) has been added/updated/removed and
* not saved yet.
*
* @return Whether this object has been altered and not saved yet.
*/
public boolean isDirty() {
return this.isDirty(true);
}
/* package */ boolean isDirty(boolean considerChildren) {
synchronized (mutex) {
return (isDeleted || getObjectId() == null || hasChanges() || (considerChildren && hasDirtyChildren()));
}
}
boolean hasChanges() {
synchronized (mutex) {
return currentOperations().size() > 0;
}
}
/**
* Returns {@code true} if this {@code EasimartObject} has operations in operationSetQueue that
* haven't been completed yet, {@code false} if there are no operations in the operationSetQueue.
*/
/* package */ boolean hasOutstandingOperations() {
synchronized (mutex) {
// > 1 since 1 is for unsaved changes.
return operationSetQueue.size() > 1;
}
}
/**
* Whether a value associated with a key has been added/updated/removed and not saved yet.
*
* @param key
* The key to check for
* @return Whether this key has been altered and not saved yet.
*/
public boolean isDirty(String key) {
synchronized (mutex) {
return currentOperations().containsKey(key);
}
}
/**
* Accessor to the object id. An object id is assigned as soon as an object is saved to the
* server. The combination of a className and an objectId uniquely identifies an object in your
* application.
*
* @return The object id.
*/
public String getObjectId() {
synchronized (mutex) {
return state.objectId();
}
}
/**
* Setter for the object id. In general you do not need to use this. However, in some cases this
* can be convenient. For example, if you are serializing a {@code EasimartObject} yourself and wish
* to recreate it, you can use this to recreate the {@code EasimartObject} exactly.
*/
public void setObjectId(String newObjectId) {
synchronized (mutex) {
String oldObjectId = state.objectId();
if (EasimartTextUtils.equals(oldObjectId, newObjectId)) {
return;
}
// We don't need to use setState since it doesn't affect our cached state.
state = state.newBuilder().objectId(newObjectId).build();
notifyObjectIdChanged(oldObjectId, newObjectId);
}
}
/**
* Returns the localId, which is used internally for serializing relations to objects that don't
* yet have an objectId.
*/
/* package */ String getOrCreateLocalId() {
synchronized (mutex) {
if (localId == null) {
if (state.objectId() != null) {
throw new IllegalStateException(
"Attempted to get a localId for an object with an objectId.");
}
localId = getLocalIdManager().createLocalId();
}
return localId;
}
}
// Sets the objectId without marking dirty.
private void notifyObjectIdChanged(String oldObjectId, String newObjectId) {
synchronized (mutex) {
// The offline store may throw if this object already had a different objectId.
OfflineStore store = Easimart.getLocalDatastore();
if (store != null) {
store.updateObjectId(this, oldObjectId, newObjectId);
}
if (localId != null) {
getLocalIdManager().setObjectId(localId, newObjectId);
localId = null;
}
}
}
private EasimartRESTObjectCommand currentSaveEventuallyCommand(
EasimartOperationSet operations, EasimartEncoder objectEncoder, String sessionToken)
throws EasimartException {
State state = getState();
/*
* Get the JSON representation of the object, and use some of the information to construct the
* command.
*/
JSONObject objectJSON = toJSONObjectForSaving(state, operations, objectEncoder);
EasimartRESTObjectCommand command = EasimartRESTObjectCommand.saveObjectCommand(
state,
objectJSON,
sessionToken);
command.enableRetrying();
return command;
}
/**
* Converts a {@code EasimartObject} to a JSON representation for saving to Easimart.
*
*
* {
* data: { // objectId plus any EasimartFieldOperations },
* classname: class name for the object
* }
*
*
* updatedAt and createdAt are not included. only dirty keys are represented in the data.
*
* @see #mergeFromServer(State state, org.json.JSONObject, EasimartDecoder, boolean)
*/
// Currently only used by saveEventually
/* package */ JSONObject toJSONObjectForSaving(
T state, EasimartOperationSet operations, EasimartEncoder objectEncoder) {
JSONObject objectJSON = new JSONObject();
try {
// Serialize the data
for (String key : operations.keySet()) {
EasimartFieldOperation operation = operations.get(key);
objectJSON.put(key, objectEncoder.encode(operation));
// TODO(grantland): Use cached value from hashedObjects if it's a set operation.
}
if (state.objectId() != null) {
objectJSON.put(KEY_OBJECT_ID, state.objectId());
}
} catch (JSONException e) {
throw new RuntimeException("could not serialize object to JSON");
}
return objectJSON;
}
/**
* Handles the result of {@code save}.
*
* Should be called on success or failure.
*/
// TODO(grantland): Remove once we convert saveEventually and EasimartUser.signUp/resolveLaziness
// to controllers
/* package */ Task handleSaveResultAsync(
final JSONObject result, final EasimartOperationSet operationsBeforeSave) {
EasimartObject.State newState = null;
if (result != null) { // Success
synchronized (mutex) {
final Map fetchedObjects = collectFetchedObjects();
EasimartDecoder decoder = new KnownEasimartObjectDecoder(fetchedObjects);
newState = EasimartObjectCoder.get().decode(getState().newBuilder().clear(), result, decoder)
.isComplete(false)
.build();
}
}
return handleSaveResultAsync(newState, operationsBeforeSave);
}
/**
* Handles the result of {@code save}.
*
* Should be called on success or failure.
*/
/* package */ Task handleSaveResultAsync(
final EasimartObject.State result, final EasimartOperationSet operationsBeforeSave) {
Task task = Task.forResult((Void) null);
final boolean success = result != null;
synchronized (mutex) {
// Find operationsBeforeSave in the queue so that we can remove it and move to the next
// operation set.
ListIterator opIterator =
operationSetQueue.listIterator(operationSetQueue.indexOf(operationsBeforeSave));
opIterator.next();
opIterator.remove();
if (!success) {
// Merge the data from the failed save into the next save.
EasimartOperationSet nextOperation = opIterator.next();
nextOperation.mergeFrom(operationsBeforeSave);
return task;
}
}
/*
* If this object is in the offline store, then we need to make sure that we pull in any dirty
* changes it may have before merging the server data into it.
*/
final OfflineStore store = Easimart.getLocalDatastore();
if (store != null) {
task = task.onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
return store.fetchLocallyAsync(EasimartObject.this).makeVoid();
}
});
}
// fetchLocallyAsync will return an error if this object isn't in the LDS yet and that's ok
task = task.continueWith(new Continuation() {
@Override
public Void then(Task task) throws Exception {
synchronized (mutex) {
State newState;
if (result.isComplete()) {
// Result is complete, so just replace
newState = result;
} else {
// Result is incomplete, so we'll need to apply it to the current state
newState = getState().newBuilder()
.apply(operationsBeforeSave)
.apply(result)
.build();
}
setState(newState);
}
return null;
}
});
if (store != null) {
task = task.onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
return store.updateDataForObjectAsync(EasimartObject.this);
}
});
}
task = task.onSuccess(new Continuation() {
@Override
public Void then(Task task) throws Exception {
saveEvent.invoke(EasimartObject.this, null);
return null;
}
});
return task;
}
/* package */ EasimartOperationSet startSave() {
synchronized (mutex) {
EasimartOperationSet currentOperations = currentOperations();
operationSetQueue.addLast(new EasimartOperationSet());
return currentOperations;
}
}
/* package */ void validateSave() {
// do nothing
}
/**
* Saves this object to the server. Typically, you should use {@link #saveInBackground} instead of
* this, unless you are managing your own threading.
*
* @throws EasimartException
* Throws an exception if the server is inaccessible.
*/
public final void save() throws EasimartException {
EasimartTaskUtils.wait(saveInBackground());
}
/**
* Saves this object to the server in a background thread. This is preferable to using {@link #save()},
* unless your code is already running from a background thread.
*
* @return A {@link bolts.Task} that is resolved when the save completes.
*/
public final Task saveInBackground() {
return EasimartUser.getCurrentUserAsync().onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
final EasimartUser current = task.getResult();
if (current == null) {
return Task.forResult(null);
}
if (!current.isLazy()) {
return Task.forResult(current.getSessionToken());
}
// The current user is lazy/unresolved. If it is attached to us via ACL, we'll need to
// resolve/save it before proceeding.
if (!isDataAvailable(KEY_ACL)) {
return Task.forResult(null);
}
final EasimartACL acl = getACL(false);
if (acl == null) {
return Task.forResult(null);
}
final EasimartUser user = acl.getUnresolvedUser();
if (user == null || !user.isCurrentUser()) {
return Task.forResult(null);
}
return user.saveAsync(null).onSuccess(new Continuation() {
@Override
public String then(Task task) throws Exception {
if (acl.hasUnresolvedUser()) {
throw new IllegalStateException("ACL has an unresolved EasimartUser. "
+ "Save or sign up before attempting to serialize the ACL.");
}
return user.getSessionToken();
}
});
}
}).onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
final String sessionToken = task.getResult();
return saveAsync(sessionToken);
}
});
}
/* package */ Task saveAsync(final String sessionToken) {
return taskQueue.enqueue(new Continuation>() {
@Override
public Task then(Task toAwait) throws Exception {
return saveAsync(sessionToken, toAwait);
}
});
}
/* package */ Task saveAsync(final String sessionToken, final Task toAwait) {
if (!isDirty()) {
return Task.forResult(null);
}
final EasimartOperationSet operations;
synchronized (mutex) {
updateBeforeSave();
validateSave();
operations = startSave();
}
Task task;
synchronized (mutex) {
// Recursively save children
/*
* TODO(klimt): Why is this estimatedData and not... I mean, what if a child is
* removed after save is called, but before the unresolved user gets resolved? It
* won't get saved.
*/
task = deepSaveAsync(estimatedData, sessionToken);
}
return task.onSuccessTask(
TaskQueue.waitFor(toAwait)
).onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
final Map fetchedObjects = collectFetchedObjects();
EasimartDecoder decoder = new KnownEasimartObjectDecoder(fetchedObjects);
return getObjectController().saveAsync(getState(), operations, sessionToken, decoder);
}
}).continueWithTask(new Continuation>() {
@Override
public Task then(final Task saveTask) throws Exception {
EasimartObject.State result = saveTask.getResult();
return handleSaveResultAsync(result, operations).continueWithTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
if (task.isFaulted() || task.isCancelled()) {
return task;
}
// We still want to propagate saveTask errors
return saveTask.makeVoid();
}
});
}
});
}
// Currently only used by EasimartPinningEventuallyQueue for saveEventually due to the limitation in
// EasimartCommandCache that it can only return JSONObject result.
/* package */ Task saveAsync(
EasimartHttpClient client,
final EasimartOperationSet operationSet,
String sessionToken) throws EasimartException {
final EasimartRESTCommand command =
currentSaveEventuallyCommand(operationSet, PointerEncoder.get(), sessionToken);
return command.executeAsync(client);
}
/**
* Saves this object to the server in a background thread. This is preferable to using {@link #save()},
* unless your code is already running from a background thread.
*
* @param callback
* {@code callback.done(e)} is called when the save completes.
*/
public final void saveInBackground(SaveCallback callback) {
EasimartTaskUtils.callbackOnMainThreadAsync(saveInBackground(), callback);
}
/* package */ void validateSaveEventually() throws EasimartException {
// do nothing
}
/**
* Saves this object to the server at some unspecified time in the future, even if Easimart is
* currently inaccessible. Use this when you may not have a solid network connection, and don't
* need to know when the save completes. If there is some problem with the object such that it
* can't be saved, it will be silently discarded. Objects saved with this method will be stored
* locally in an on-disk cache until they can be delivered to Easimart. They will be sent immediately
* if possible. Otherwise, they will be sent the next time a network connection is available.
* Objects saved this way will persist even after the app is closed, in which case they will be
* sent the next time the app is opened. If more than 10MB of data is waiting to be sent,
* subsequent calls to {@code #saveEventually()} or {@link #deleteEventually()} will cause old
* saves to be silently discarded until the connection can be re-established, and the queued
* objects can be saved.
*
* @param callback
* - A callback which will be called if the save completes before the app exits.
*/
public final void saveEventually(SaveCallback callback) {
EasimartTaskUtils.callbackOnMainThreadAsync(saveEventually(), callback);
}
/**
* Saves this object to the server at some unspecified time in the future, even if Easimart is
* currently inaccessible. Use this when you may not have a solid network connection, and don't
* need to know when the save completes. If there is some problem with the object such that it
* can't be saved, it will be silently discarded. Objects saved with this method will be stored
* locally in an on-disk cache until they can be delivered to Easimart. They will be sent immediately
* if possible. Otherwise, they will be sent the next time a network connection is available.
* Objects saved this way will persist even after the app is closed, in which case they will be
* sent the next time the app is opened. If more than 10MB of data is waiting to be sent,
* subsequent calls to {@code #saveEventually()} or {@link #deleteEventually()} will cause old
* saves to be silently discarded until the connection can be re-established, and the queued
* objects can be saved.
*
* @return A {@link bolts.Task} that is resolved when the save completes.
*/
public final Task saveEventually() {
if (!isDirty()) {
Easimart.getEventuallyQueue().fakeObjectUpdate();
return Task.forResult(null);
}
final EasimartOperationSet operationSet;
final EasimartRESTCommand command;
final Task runEventuallyTask;
synchronized (mutex) {
updateBeforeSave();
try {
validateSaveEventually();
} catch (EasimartException e) {
return Task.forError(e);
}
// TODO(klimt): Once we allow multiple saves on an object, this
// should be collecting dirty children from the estimate based on
// whatever data is going to be sent by this saveEventually, which
// won't necessarily be the current estimatedData. We should resolve
// this when the multiple save code is added.
List unsavedChildren = new ArrayList<>();
collectDirtyChildren(estimatedData, unsavedChildren, null);
String localId = null;
if (getObjectId() == null) {
localId = getOrCreateLocalId();
}
operationSet = startSave();
operationSet.setIsSaveEventually(true);
//TODO (grantland): Convert to async
final String sessionToken = EasimartUser.getCurrentSessionToken();
try {
// See [1]
command = currentSaveEventuallyCommand(operationSet, PointerOrLocalIdEncoder.get(),
sessionToken);
// TODO: Make this logic make sense once we have deepSaveEventually
command.setLocalId(localId);
// Mark the command with a UUID so that we can match it up later.
command.setOperationSetUUID(operationSet.getUUID());
// Ensure local ids are retained before saveEventually-ing children
command.retainLocalIds();
for (EasimartObject object : unsavedChildren) {
object.saveEventually();
}
} catch (EasimartException exception) {
throw new IllegalStateException("Unable to saveEventually.", exception);
}
}
// We cannot modify the taskQueue inside synchronized (mutex).
EasimartEventuallyQueue cache = Easimart.getEventuallyQueue();
runEventuallyTask = cache.enqueueEventuallyAsync(command, EasimartObject.this);
enqueueSaveEventuallyOperationAsync(operationSet);
// Release the extra retained local ids.
command.releaseLocalIds();
Task handleSaveResultTask;
if (Easimart.isLocalDatastoreEnabled()) {
// EasimartPinningEventuallyQueue calls handleSaveEventuallyResultAsync directly.
handleSaveResultTask = runEventuallyTask.makeVoid();
} else {
handleSaveResultTask = runEventuallyTask.onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
JSONObject json = task.getResult();
return handleSaveEventuallyResultAsync(json, operationSet);
}
});
}
return handleSaveResultTask;
}
/**
* Enqueues the saveEventually EasimartOperationSet in {@link #taskQueue}.
*/
private Task enqueueSaveEventuallyOperationAsync(final EasimartOperationSet operationSet) {
if (!operationSet.isSaveEventually()) {
throw new IllegalStateException(
"This should only be used to enqueue saveEventually operation sets");
}
return taskQueue.enqueue(new Continuation>() {
@Override
public Task then(Task toAwait) throws Exception {
return toAwait.continueWithTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
EasimartEventuallyQueue cache = Easimart.getEventuallyQueue();
return cache.waitForOperationSetAndEventuallyPin(operationSet, null).makeVoid();
}
});
}
});
}
/**
* Handles the result of {@code saveEventually}.
*
* In addition to normal save handling, this also notifies the saveEventually test helper.
*
* Should be called on success or failure.
*/
/* package */ Task handleSaveEventuallyResultAsync(
JSONObject json, EasimartOperationSet operationSet) {
final boolean success = json != null;
Task handleSaveResultTask = handleSaveResultAsync(json, operationSet);
return handleSaveResultTask.onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
if (success) {
Easimart.getEventuallyQueue()
.notifyTestHelper(EasimartCommandCache.TestHelper.OBJECT_UPDATED);
}
return task;
}
});
}
/**
* Called by {@link #saveInBackground()} and {@link #saveEventually(SaveCallback)}
* and guaranteed to be thread-safe. Subclasses can override this method to do any custom updates
* before an object gets saved.
*/
/* package */ void updateBeforeSave() {
// do nothing
}
/**
* Deletes this object from the server at some unspecified time in the future, even if Easimart is
* currently inaccessible. Use this when you may not have a solid network connection, and don't
* need to know when the delete completes. If there is some problem with the object such that it
* can't be deleted, the request will be silently discarded. Delete requests made with this method
* will be stored locally in an on-disk cache until they can be transmitted to Easimart. They will be
* sent immediately if possible. Otherwise, they will be sent the next time a network connection
* is available. Delete instructions saved this way will persist even after the app is closed, in
* which case they will be sent the next time the app is opened. If more than 10MB of commands are
* waiting to be sent, subsequent calls to {@code #deleteEventually()} or
* {@link #saveEventually()} will cause old instructions to be silently discarded until the
* connection can be re-established, and the queued objects can be saved.
*
* @param callback
* - A callback which will be called if the delete completes before the app exits.
*/
public final void deleteEventually(DeleteCallback callback) {
EasimartTaskUtils.callbackOnMainThreadAsync(deleteEventually(), callback);
}
/**
* Deletes this object from the server at some unspecified time in the future, even if Easimart is
* currently inaccessible. Use this when you may not have a solid network connection, and don't
* need to know when the delete completes. If there is some problem with the object such that it
* can't be deleted, the request will be silently discarded. Delete requests made with this method
* will be stored locally in an on-disk cache until they can be transmitted to Easimart. They will be
* sent immediately if possible. Otherwise, they will be sent the next time a network connection
* is available. Delete instructions saved this way will persist even after the app is closed, in
* which case they will be sent the next time the app is opened. If more than 10MB of commands are
* waiting to be sent, subsequent calls to {@code #deleteEventually()} or
* {@link #saveEventually()} will cause old instructions to be silently discarded until the
* connection can be re-established, and the queued objects can be saved.
*
* @return A {@link bolts.Task} that is resolved when the delete completes.
*/
public final Task deleteEventually() {
final EasimartRESTCommand command;
final Task runEventuallyTask;
synchronized (mutex) {
validateDelete();
isDeletingEventually += 1;
String localId = null;
if (getObjectId() == null) {
localId = getOrCreateLocalId();
}
// TODO(grantland): Convert to async
final String sessionToken = EasimartUser.getCurrentSessionToken();
// See [1]
command = EasimartRESTObjectCommand.deleteObjectCommand(
getState(), sessionToken);
command.enableRetrying();
command.setLocalId(localId);
runEventuallyTask = Easimart.getEventuallyQueue().enqueueEventuallyAsync(command, EasimartObject.this);
}
Task handleDeleteResultTask;
if (Easimart.isLocalDatastoreEnabled()) {
// EasimartPinningEventuallyQueue calls handleDeleteEventuallyResultAsync directly.
handleDeleteResultTask = runEventuallyTask.makeVoid();
} else {
handleDeleteResultTask = runEventuallyTask.onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
return handleDeleteEventuallyResultAsync();
}
});
}
return handleDeleteResultTask;
}
/**
* Handles the result of {@code deleteEventually}.
*
* Should only be called on success.
*/
/* package */ Task handleDeleteEventuallyResultAsync() {
synchronized (mutex) {
isDeletingEventually -= 1;
}
Task handleDeleteResultTask = handleDeleteResultAsync();
return handleDeleteResultTask.onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
Easimart.getEventuallyQueue()
.notifyTestHelper(EasimartCommandCache.TestHelper.OBJECT_REMOVED);
return task;
}
});
}
/**
* Handles the result of {@code fetch}.
*
* Should only be called on success.
*/
/* package */ Task handleFetchResultAsync(final EasimartObject.State result) {
Task task = Task.forResult((Void) null);
/*
* If this object is in the offline store, then we need to make sure that we pull in any dirty
* changes it may have before merging the server data into it.
*/
final OfflineStore store = Easimart.getLocalDatastore();
if (store != null) {
task = task.onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
return store.fetchLocallyAsync(EasimartObject.this).makeVoid();
}
}).continueWithTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
// Catch CACHE_MISS
if (task.getError() instanceof EasimartException
&& ((EasimartException)task.getError()).getCode() == EasimartException.CACHE_MISS) {
return null;
}
return task;
}
});
}
task = task.onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
synchronized (mutex) {
State newState;
if (result.isComplete()) {
// Result is complete, so just replace
newState = result;
} else {
// Result is incomplete, so we'll need to apply it to the current state
newState = getState().newBuilder().apply(result).build();
}
setState(newState);
}
return null;
}
});
if (store != null) {
task = task.onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
return store.updateDataForObjectAsync(EasimartObject.this);
}
}).continueWithTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
// Catch CACHE_MISS
if (task.getError() instanceof EasimartException
&& ((EasimartException) task.getError()).getCode() == EasimartException.CACHE_MISS) {
return null;
}
return task;
}
});
}
return task;
}
/**
* Refreshes this object with the data from the server. Call this whenever you want the state of
* the object to reflect exactly what is on the server.
*
* @throws EasimartException
* Throws an exception if the server is inaccessible.
*
* @deprecated Please use {@link #fetch()} instead.
*/
@Deprecated
public final void refresh() throws EasimartException {
fetch();
}
/**
* Refreshes this object with the data from the server in a background thread. This is preferable
* to using refresh(), unless your code is already running from a background thread.
*
* @param callback
* {@code callback.done(object, e)} is called when the refresh completes.
*
* @deprecated Please use {@link #fetchInBackground(GetCallback)} instead.
*/
@Deprecated
public final void refreshInBackground(RefreshCallback callback) {
EasimartTaskUtils.callbackOnMainThreadAsync(fetchInBackground(), callback);
}
/**
* Fetches this object with the data from the server. Call this whenever you want the state of the
* object to reflect exactly what is on the server.
*
* @throws EasimartException
* Throws an exception if the server is inaccessible.
* @return The {@code EasimartObject} that was fetched.
*/
public T fetch() throws EasimartException {
return EasimartTaskUtils.wait(this.fetchInBackground());
}
@SuppressWarnings("unchecked")
/* package */ Task fetchAsync(
final String sessionToken, Task toAwait) {
return toAwait.onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
State state;
Map fetchedObjects;
synchronized (mutex) {
state = getState();
fetchedObjects = collectFetchedObjects();
}
EasimartDecoder decoder = new KnownEasimartObjectDecoder(fetchedObjects);
return getObjectController().fetchAsync(state, sessionToken, decoder);
}
}).onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
EasimartObject.State result = task.getResult();
return handleFetchResultAsync(result);
}
}).onSuccess(new Continuation() {
@Override
public T then(Task task) throws Exception {
return (T) EasimartObject.this;
}
});
}
/**
* Fetches this object with the data from the server in a background thread. This is preferable to
* using fetch(), unless your code is already running from a background thread.
*
* @return A {@link bolts.Task} that is resolved when fetch completes.
*/
public final Task fetchInBackground() {
return EasimartUser.getCurrentSessionTokenAsync().onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
final String sessionToken = task.getResult();
return taskQueue.enqueue(new Continuation>() {
@Override
public Task then(Task toAwait) throws Exception {
return fetchAsync(sessionToken, toAwait);
}
});
}
});
}
/**
* Fetches this object with the data from the server in a background thread. This is preferable to
* using fetch(), unless your code is already running from a background thread.
*
* @param callback
* {@code callback.done(object, e)} is called when the fetch completes.
*/
public final void fetchInBackground(GetCallback callback) {
EasimartTaskUtils.callbackOnMainThreadAsync(this.fetchInBackground(), callback);
}
/**
* If this {@code EasimartObject} has not been fetched (i.e. {@link #isDataAvailable()} returns {@code false}),
* fetches this object with the data from the server in a background thread. This is preferable to
* using {@link #fetchIfNeeded()}, unless your code is already running from a background thread.
*
* @return A {@link bolts.Task} that is resolved when fetch completes.
*/
public final Task fetchIfNeededInBackground() {
if (isDataAvailable()) {
return Task.forResult((T) this);
}
return EasimartUser.getCurrentSessionTokenAsync().onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
final String sessionToken = task.getResult();
return taskQueue.enqueue(new Continuation>() {
@Override
public Task then(Task toAwait) throws Exception {
if (isDataAvailable()) {
return Task.forResult((T) EasimartObject.this);
}
return fetchAsync(sessionToken, toAwait);
}
});
}
});
}
/**
* If this {@code EasimartObject} has not been fetched (i.e. {@link #isDataAvailable()} returns {@code false}),
* fetches this object with the data from the server.
*
* @throws EasimartException
* Throws an exception if the server is inaccessible.
* @return The fetched {@code EasimartObject}.
*/
public T fetchIfNeeded() throws EasimartException {
return EasimartTaskUtils.wait(this.fetchIfNeededInBackground());
}
/**
* If this {@code EasimartObject} has not been fetched (i.e. {@link #isDataAvailable()} returns {@code false}),
* fetches this object with the data from the server in a background thread. This is preferable to
* using {@link #fetchIfNeeded()}, unless your code is already running from a background thread.
*
* @param callback
* {@code callback.done(object, e)} is called when the fetch completes.
*/
public final void fetchIfNeededInBackground(GetCallback callback) {
EasimartTaskUtils.callbackOnMainThreadAsync(this.fetchIfNeededInBackground(), callback);
}
// Validates the delete method
/* package */ void validateDelete() {
// do nothing
}
private Task deleteAsync(final String sessionToken, Task toAwait) {
validateDelete();
return toAwait.onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
if (state.objectId() == null) {
return task.cast(); // no reason to call delete since it doesn't exist
}
return deleteAsync(sessionToken);
}
}).onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
return handleDeleteResultAsync();
}
});
}
//TODO (grantland): I'm not sure we want direct access to this. All access to `delete` should
// enqueue on the taskQueue...
/* package */ Task deleteAsync(String sessionToken) throws EasimartException {
return getObjectController().deleteAsync(getState(), sessionToken);
}
/**
* Handles the result of {@code delete}.
*
* Should only be called on success.
*/
/* package */ Task handleDeleteResultAsync() {
Task task = Task.forResult(null);
synchronized (mutex) {
isDeleted = true;
}
final OfflineStore store = Easimart.getLocalDatastore();
if (store != null) {
task = task.continueWithTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
synchronized (mutex) {
if (isDeleted) {
store.unregisterObject(EasimartObject.this);
return store.deleteDataForObjectAsync(EasimartObject.this);
} else {
return store.updateDataForObjectAsync(EasimartObject.this);
}
}
}
});
}
return task;
}
/**
* Deletes this object on the server in a background thread. This is preferable to using
* {@link #delete()}, unless your code is already running from a background thread.
*
* @return A {@link bolts.Task} that is resolved when delete completes.
*/
public final Task deleteInBackground() {
return EasimartUser.getCurrentSessionTokenAsync().onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
final String sessionToken = task.getResult();
return taskQueue.enqueue(new Continuation>() {
@Override
public Task then(Task toAwait) throws Exception {
return deleteAsync(sessionToken, toAwait);
}
});
}
});
}
/**
* Deletes this object on the server. This does not delete or destroy the object locally.
*
* @throws EasimartException
* Throws an error if the object does not exist or if the internet fails.
*/
public final void delete() throws EasimartException {
EasimartTaskUtils.wait(deleteInBackground());
}
/**
* Deletes this object on the server in a background thread. This is preferable to using
* {@link #delete()}, unless your code is already running from a background thread.
*
* @param callback
* {@code callback.done(e)} is called when the save completes.
*/
public final void deleteInBackground(DeleteCallback callback) {
EasimartTaskUtils.callbackOnMainThreadAsync(deleteInBackground(), callback);
}
/**
* This deletes all of the objects from the given List.
*/
private static Task deleteAllAsync(
final List objects, final String sessionToken) {
if (objects.size() == 0) {
return Task.forResult(null);
}
// Create a list of unique objects based on objectIds
int objectCount = objects.size();
final List uniqueObjects = new ArrayList<>(objectCount);
final HashSet idSet = new HashSet<>();
for (int i = 0; i < objectCount; i++) {
EasimartObject obj = objects.get(i);
if (!idSet.contains(obj.getObjectId())) {
idSet.add(obj.getObjectId());
uniqueObjects.add(obj);
}
}
return enqueueForAll(uniqueObjects, new Continuation>() {
@Override
public Task then(Task toAwait) throws Exception {
return deleteAllAsync(uniqueObjects, sessionToken, toAwait);
}
});
}
private static Task deleteAllAsync(
final List uniqueObjects, final String sessionToken, Task toAwait) {
return toAwait.continueWithTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
int objectCount = uniqueObjects.size();
List states = new ArrayList<>(objectCount);
for (int i = 0; i < objectCount; i++) {
EasimartObject object = uniqueObjects.get(i);
object.validateDelete();
states.add(object.getState());
}
List> batchTasks = getObjectController().deleteAllAsync(states, sessionToken);
List> tasks = new ArrayList<>(objectCount);
for (int i = 0; i < objectCount; i++) {
Task batchTask = batchTasks.get(i);
final T object = uniqueObjects.get(i);
tasks.add(batchTask.onSuccessTask(new Continuation>() {
@Override
public Task then(final Task batchTask) throws Exception {
return object.handleDeleteResultAsync().continueWithTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
return batchTask;
}
});
}
}));
}
return Task.whenAll(tasks);
}
});
}
/**
* Deletes each object in the provided list. This is faster than deleting each object individually
* because it batches the requests.
*
* @param objects
* The objects to delete.
* @throws EasimartException
* Throws an exception if the server returns an error or is inaccessible.
*/
public static void deleteAll(List objects) throws EasimartException {
EasimartTaskUtils.wait(deleteAllInBackground(objects));
}
/**
* Deletes each object in the provided list. This is faster than deleting each object individually
* because it batches the requests.
*
* @param objects
* The objects to delete.
* @param callback
* The callback method to execute when completed.
*/
public static void deleteAllInBackground(List objects, DeleteCallback callback) {
EasimartTaskUtils.callbackOnMainThreadAsync(deleteAllInBackground(objects), callback);
}
/**
* Deletes each object in the provided list. This is faster than deleting each object individually
* because it batches the requests.
*
* @param objects
* The objects to delete.
*
* @return A {@link bolts.Task} that is resolved when deleteAll completes.
*/
public static Task deleteAllInBackground(final List objects) {
return EasimartUser.getCurrentSessionTokenAsync().onSuccessTask(new Continuation>() {
@Override
public Task then(Task task) throws Exception {
String sessionToken = task.getResult();
return deleteAllAsync(objects, sessionToken);
}
});
}
/**
* Finds all of the objects that are reachable from child, including child itself, and adds them
* to the given mutable array. It traverses arrays and json objects.
*
* @param node
* An kind object to search for children.
* @param dirtyChildren
* The array to collect the {@code EasimartObject}s into.
* @param dirtyFiles
* The array to collect the {@link EasimartFile}s into.
* @param alreadySeen
* The set of all objects that have already been seen.
* @param alreadySeenNew
* The set of new objects that have already been seen since the last existing object.
*/
private static void collectDirtyChildren(Object node,
final Collection dirtyChildren,
final Collection dirtyFiles,
final Set alreadySeen,
final Set alreadySeenNew) {
new EasimartTraverser() {
@Override
protected boolean visit(Object node) {
// If it's a file, then add it to the list if it's dirty.
if (node instanceof EasimartFile) {
if (dirtyFiles == null) {
return true;
}
EasimartFile file = (EasimartFile) node;
if (file.getUrl() == null) {
dirtyFiles.add(file);
}
return true;
}
// If it's anything other than a file, then just continue;
if (!(node instanceof EasimartObject)) {
return true;
}
if (dirtyChildren == null) {
return true;
}
// For files, we need to handle recursion manually to find cycles of new objects.
EasimartObject object = (EasimartObject) node;
Set seen = alreadySeen;
Set seenNew = alreadySeenNew;
// Check for cycles of new objects. Any such cycle means it will be
// impossible to save this collection of objects, so throw an exception.
if (object.getObjectId() != null) {
seenNew = new HashSet<>();
} else {
if (seenNew.contains(object)) {
throw new RuntimeException("Found a circular dependency while saving.");
}
seenNew = new HashSet<>(seenNew);
seenNew.add(object);
}
// Check for cycles of any object. If this occurs, then there's no
// problem, but we shouldn't recurse any deeper, because it would be
// an infinite recursion.
if (seen.contains(object)) {
return true;
}
seen = new HashSet<>(seen);
seen.add(object);
// Recurse into this object's children looking for dirty children.
// We only need to look at the child object's current estimated data,
// because that's the only data that might need to be saved now.
collectDirtyChildren(object.estimatedData, dirtyChildren, dirtyFiles, seen, seenNew);
if (object.isDirty(false)) {
dirtyChildren.add(object);
}
return true;
}
}.setYieldRoot(true).traverse(node);
}
/**
* Helper version of collectDirtyChildren so that callers don't have to add the internally used
* parameters.
*/
private static void collectDirtyChildren(Object node, Collection dirtyChildren,
Collection dirtyFiles) {
collectDirtyChildren(node, dirtyChildren, dirtyFiles,
new HashSet(),
new HashSet());
}
/**
* Returns {@code true} if this object can be serialized for saving.
*/
private boolean canBeSerialized() {
synchronized (mutex) {
final Capture result = new Capture<>(true);
// This method is only used for batching sets of objects for saveAll
// and when saving children automatically. Since it's only used to
// determine whether or not save should be called on them, it only
// needs to examine their current values, so we use estimatedData.
new EasimartTraverser() {
@Override
protected boolean visit(Object value) {
if (value instanceof EasimartFile) {
EasimartFile file = (EasimartFile) value;
if (file.isDirty()) {
result.set(false);
}
}
if (value instanceof EasimartObject) {
EasimartObject object = (EasimartObject) value;
if (object.getObjectId() == null) {
result.set(false);
}
}
// Continue to traverse only if it can still be serialized.
return result.get();
}
}.setYieldRoot(false).setTraverseEasimartObjects(true).traverse(this);
return result.get();
}
}
/**
* This saves all of the objects and files reachable from the given object. It does its work in
* multiple waves, saving as many as possible in each wave. If there's ever an error, it just
* gives up, sets error, and returns NO.
*/
private static Task deepSaveAsync(final Object object, final String sessionToken) {
Set objects = new HashSet<>();
Set files = new HashSet<>();
collectDirtyChildren(object, objects, files);
// This has to happen separately from everything else because EasimartUser.save() is
// special-cased to work for lazy users, but new users can't be created by
// EasimartMultiCommand's regular save.
Set users = new HashSet<>();
for (EasimartObject o : objects) {
if (o instanceof EasimartUser) {
EasimartUser user = (EasimartUser) o;
if (user.isLazy()) {
users.add((EasimartUser) o);
}
}
}
objects.removeAll(users);
// objects will need to wait for files to be complete since they may be nested children.
final AtomicBoolean filesComplete = new AtomicBoolean(false);
List> tasks = new ArrayList<>();
for (EasimartFile file : files) {
tasks.add(file.saveAsync(sessionToken, null, null));
}
Task filesTask = Task.whenAll(tasks).continueWith(new Continuation() {
@Override
public Void then(Task task) throws Exception {
filesComplete.set(true);
return null;
}
});
// objects will need to wait for users to be complete since they may be nested children.
final AtomicBoolean usersComplete = new AtomicBoolean(false);
tasks = new ArrayList<>();
for (final EasimartUser user : users) {
tasks.add(user.saveAsync(sessionToken));
}
Task usersTask = Task.whenAll(tasks).continueWith(new Continuation() {
@Override
public Void then(Task task) throws Exception {
usersComplete.set(true);
return null;
}
});
final Capture> remaining = new Capture<>(objects);
Task objectsTask = Task.forResult(null).continueWhile(new Callable