Bug 1351585 - Part 1. Add Leanplum SDK source code to thirdparty module r?nalexander,maliu,sebastian draft
authorcnevinc <cnevinc@livemail.tw>
Sat, 13 May 2017 14:05:09 -0700
changeset 581065 5e91a5caae740604a86f79582c619d83a0b77828
parent 581064 99770033cab246338905837765dd87daccf7e2a3
child 581066 4f635ad9e0d6c411f32b6ea347f51c523fdf1141
push id59758
push userbmo:cnevinchen@gmail.com
push dateFri, 19 May 2017 07:56:51 +0000
reviewersnalexander, maliu, sebastian
bugs1351585
milestone55.0a1
Bug 1351585 - Part 1. Add Leanplum SDK source code to thirdparty module r?nalexander,maliu,sebastian MozReview-Commit-ID: 6r7ZGpAww2n
mobile/android/thirdparty/build.gradle
mobile/android/thirdparty/com/leanplum/ActionArgs.java
mobile/android/thirdparty/com/leanplum/ActionContext.java
mobile/android/thirdparty/com/leanplum/CacheUpdateBlock.java
mobile/android/thirdparty/com/leanplum/Leanplum.java
mobile/android/thirdparty/com/leanplum/LeanplumActivityHelper.java
mobile/android/thirdparty/com/leanplum/LeanplumApplication.java
mobile/android/thirdparty/com/leanplum/LeanplumCloudMessagingProvider.java
mobile/android/thirdparty/com/leanplum/LeanplumDeviceIdMode.java
mobile/android/thirdparty/com/leanplum/LeanplumEditorMode.java
mobile/android/thirdparty/com/leanplum/LeanplumException.java
mobile/android/thirdparty/com/leanplum/LeanplumGcmProvider.java
mobile/android/thirdparty/com/leanplum/LeanplumInbox.java
mobile/android/thirdparty/com/leanplum/LeanplumInboxMessage.java
mobile/android/thirdparty/com/leanplum/LeanplumInflater.java
mobile/android/thirdparty/com/leanplum/LeanplumLocalPushListenerService.java
mobile/android/thirdparty/com/leanplum/LeanplumLocationAccuracyType.java
mobile/android/thirdparty/com/leanplum/LeanplumManualProvider.java
mobile/android/thirdparty/com/leanplum/LeanplumPushInstanceIDService.java
mobile/android/thirdparty/com/leanplum/LeanplumPushListenerService.java
mobile/android/thirdparty/com/leanplum/LeanplumPushNotificationCustomizer.java
mobile/android/thirdparty/com/leanplum/LeanplumPushReceiver.java
mobile/android/thirdparty/com/leanplum/LeanplumPushRegistrationService.java
mobile/android/thirdparty/com/leanplum/LeanplumPushService.java
mobile/android/thirdparty/com/leanplum/LeanplumResources.java
mobile/android/thirdparty/com/leanplum/LeanplumUIEditor.java
mobile/android/thirdparty/com/leanplum/LocationManager.java
mobile/android/thirdparty/com/leanplum/Newsfeed.java
mobile/android/thirdparty/com/leanplum/NewsfeedMessage.java
mobile/android/thirdparty/com/leanplum/UIEditorBridge.java
mobile/android/thirdparty/com/leanplum/Var.java
mobile/android/thirdparty/com/leanplum/activities/LeanplumAccountAuthenticatorActivity.java
mobile/android/thirdparty/com/leanplum/activities/LeanplumActionBarActivity.java
mobile/android/thirdparty/com/leanplum/activities/LeanplumActivity.java
mobile/android/thirdparty/com/leanplum/activities/LeanplumActivityGroup.java
mobile/android/thirdparty/com/leanplum/activities/LeanplumAliasActivity.java
mobile/android/thirdparty/com/leanplum/activities/LeanplumAppCompatActivity.java
mobile/android/thirdparty/com/leanplum/activities/LeanplumExpandableListActivity.java
mobile/android/thirdparty/com/leanplum/activities/LeanplumFragmentActivity.java
mobile/android/thirdparty/com/leanplum/activities/LeanplumLauncherActivity.java
mobile/android/thirdparty/com/leanplum/activities/LeanplumListActivity.java
mobile/android/thirdparty/com/leanplum/activities/LeanplumNativeActivity.java
mobile/android/thirdparty/com/leanplum/activities/LeanplumPreferenceActivity.java
mobile/android/thirdparty/com/leanplum/activities/LeanplumTabActivity.java
mobile/android/thirdparty/com/leanplum/annotations/File.java
mobile/android/thirdparty/com/leanplum/annotations/Parser.java
mobile/android/thirdparty/com/leanplum/annotations/Variable.java
mobile/android/thirdparty/com/leanplum/callbacks/ActionCallback.java
mobile/android/thirdparty/com/leanplum/callbacks/InboxChangedCallback.java
mobile/android/thirdparty/com/leanplum/callbacks/NewsfeedChangedCallback.java
mobile/android/thirdparty/com/leanplum/callbacks/PostponableAction.java
mobile/android/thirdparty/com/leanplum/callbacks/RegisterDeviceCallback.java
mobile/android/thirdparty/com/leanplum/callbacks/RegisterDeviceFinishedCallback.java
mobile/android/thirdparty/com/leanplum/callbacks/StartCallback.java
mobile/android/thirdparty/com/leanplum/callbacks/VariableCallback.java
mobile/android/thirdparty/com/leanplum/callbacks/VariablesChangedCallback.java
mobile/android/thirdparty/com/leanplum/internal/AESCrypt.java
mobile/android/thirdparty/com/leanplum/internal/ActionArg.java
mobile/android/thirdparty/com/leanplum/internal/ActionManager.java
mobile/android/thirdparty/com/leanplum/internal/BaseActionContext.java
mobile/android/thirdparty/com/leanplum/internal/CollectionUtil.java
mobile/android/thirdparty/com/leanplum/internal/Constants.java
mobile/android/thirdparty/com/leanplum/internal/FileManager.java
mobile/android/thirdparty/com/leanplum/internal/HybiParser.java
mobile/android/thirdparty/com/leanplum/internal/JsonConverter.java
mobile/android/thirdparty/com/leanplum/internal/LeanplumInternal.java
mobile/android/thirdparty/com/leanplum/internal/LeanplumManifestHelper.java
mobile/android/thirdparty/com/leanplum/internal/LeanplumManifestParser.java
mobile/android/thirdparty/com/leanplum/internal/LeanplumMessageMatchFilter.java
mobile/android/thirdparty/com/leanplum/internal/LeanplumUIEditorWrapper.java
mobile/android/thirdparty/com/leanplum/internal/Log.java
mobile/android/thirdparty/com/leanplum/internal/OsHandler.java
mobile/android/thirdparty/com/leanplum/internal/Registration.java
mobile/android/thirdparty/com/leanplum/internal/Request.java
mobile/android/thirdparty/com/leanplum/internal/RequestFactory.java
mobile/android/thirdparty/com/leanplum/internal/ResourceQualifiers.java
mobile/android/thirdparty/com/leanplum/internal/Socket.java
mobile/android/thirdparty/com/leanplum/internal/SocketIOClient.java
mobile/android/thirdparty/com/leanplum/internal/Util.java
mobile/android/thirdparty/com/leanplum/internal/VarCache.java
mobile/android/thirdparty/com/leanplum/internal/WebSocketClient.java
mobile/android/thirdparty/com/leanplum/messagetemplates/Alert.java
mobile/android/thirdparty/com/leanplum/messagetemplates/BaseMessageDialog.java
mobile/android/thirdparty/com/leanplum/messagetemplates/BaseMessageOptions.java
mobile/android/thirdparty/com/leanplum/messagetemplates/CenterPopup.java
mobile/android/thirdparty/com/leanplum/messagetemplates/CenterPopupOptions.java
mobile/android/thirdparty/com/leanplum/messagetemplates/Confirm.java
mobile/android/thirdparty/com/leanplum/messagetemplates/HTMLOptions.java
mobile/android/thirdparty/com/leanplum/messagetemplates/HTMLTemplate.java
mobile/android/thirdparty/com/leanplum/messagetemplates/Interstitial.java
mobile/android/thirdparty/com/leanplum/messagetemplates/InterstitialOptions.java
mobile/android/thirdparty/com/leanplum/messagetemplates/MessageTemplates.java
mobile/android/thirdparty/com/leanplum/messagetemplates/OpenURL.java
mobile/android/thirdparty/com/leanplum/messagetemplates/WebInterstitial.java
mobile/android/thirdparty/com/leanplum/messagetemplates/WebInterstitialOptions.java
mobile/android/thirdparty/com/leanplum/utils/BitmapUtil.java
mobile/android/thirdparty/com/leanplum/utils/SharedPreferencesUtil.java
mobile/android/thirdparty/com/leanplum/utils/SizeUtil.java
mobile/android/thirdparty/com/leanplum/views/BackgroundImageView.java
mobile/android/thirdparty/com/leanplum/views/CloseButton.java
--- a/mobile/android/thirdparty/build.gradle
+++ b/mobile/android/thirdparty/build.gradle
@@ -25,34 +25,47 @@ android {
             manifest.srcFile 'AndroidManifest.xml'
             java {
                 srcDir '.'
 
                 if (!mozconfig.substs.MOZ_INSTALL_TRACKING) {
                     exclude 'com/adjust/**'
                 }
 
+                if (!mozconfig.substs.MOZ_ANDROID_MMA) {
+                    exclude 'com/leanplum/**'
+                }
+
                 // Exclude LeakCanary: It will be added again via a gradle dependency. This version
                 // here is only the no-op library for mach-based builds.
                 exclude 'com/squareup/leakcanary/**'
             }
         }
     }
 }
 
 dependencies {
     compile "com.android.support:support-v4:${mozconfig.substs.ANDROID_SUPPORT_LIBRARY_VERSION}"
+    if (mozconfig.substs.MOZ_ANDROID_MMA) {
+        compile "com.android.support:appcompat-v7:${mozconfig.substs.ANDROID_SUPPORT_LIBRARY_VERSION}"
+        compile "com.android.support:support-annotations:${mozconfig.substs.ANDROID_GOOGLE_PLAY_SERVICES_VERSION}"
+        compile "com.google.android.gms:play-services-ads:${mozconfig.substs.ANDROID_GOOGLE_PLAY_SERVICES_VERSION}"
+        compile "com.google.android.gms:play-services-gcm:${mozconfig.substs.ANDROID_GOOGLE_PLAY_SERVICES_VERSION}"
+    }
 }
 
 apply plugin: 'idea'
 
 idea {
     module {
         // This is cosmetic.  See the excludes in the root project.
         if (!mozconfig.substs.MOZ_INSTALL_TRACKING) {
             excludeDirs += file('com/adjust/sdk')
         }
+        if (!mozconfig.substs.MOZ_ANDROID_MMA) {
+            excludeDirs += file('com/leanplum')
+        }
     }
 }
 
 // Bug 1353055 - Strip 'vars' debugging information to agree with moz.build.
 apply from: "${topsrcdir}/mobile/android/gradle/debug_level.gradle"
 android.libraryVariants.all configureVariantDebugLevel
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/ActionArgs.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import com.leanplum.internal.ActionArg;
+import com.leanplum.internal.Log;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents arguments for a message or action.
+ *
+ * @author Andrew First
+ */
+public class ActionArgs {
+  private List<ActionArg<?>> args = new ArrayList<>();
+
+  /**
+   * Adds a basic argument of type T.
+   *
+   * @param <T> Type of the argument. Can be Boolean, Byte, Short, Integer, Long, Float, Double,
+   * Character, String, List, or Map.
+   * @param name The name of the argument.
+   * @param defaultValue The default value of the argument.
+   */
+  public <T> ActionArgs with(String name, T defaultValue) {
+    if (name == null) {
+      Log.e("with - Invalid name parameter provided.");
+      return this;
+    }
+    args.add(ActionArg.argNamed(name, defaultValue));
+    return this;
+  }
+
+  /**
+   * Adds a color argument with an integer value.
+   *
+   * @param name The name of the argument.
+   * @param defaultValue The integer value representing the color.
+   */
+  public ActionArgs withColor(String name, int defaultValue) {
+    if (name == null) {
+      Log.e("withColor - Invalid name parameter provided.");
+      return this;
+    }
+    args.add(ActionArg.colorArgNamed(name, defaultValue));
+    return this;
+  }
+
+  /**
+   * Adds a file argument.
+   *
+   * @param name The name of the argument.
+   * @param defaultFilename The path of the default file value. Use null to indicate no default
+   * value.
+   */
+  public ActionArgs withFile(String name, String defaultFilename) {
+    if (name == null) {
+      Log.e("withFile - Invalid name parameter provided.");
+      return this;
+    }
+    args.add(ActionArg.fileArgNamed(name, defaultFilename));
+    return this;
+  }
+
+  /**
+   * Adds an asset argument. Same as {@link ActionArgs#withFile} except that the filename is
+   * relative to the assets directory.
+   *
+   * @param name The name of the argument.
+   * @param defaultFilename The path of the default file value relative to the assets directory. Use
+   * null to indicate no default value.
+   */
+  public ActionArgs withAsset(String name, String defaultFilename) {
+    if (name == null) {
+      Log.e("withAsset - Invalid name parameter provided.");
+      return this;
+    }
+    args.add(ActionArg.assetArgNamed(name, defaultFilename));
+    return this;
+  }
+
+  /**
+   * Adds an action argument.
+   *
+   * @param name The name of the argument.
+   * @param defaultValue The default action name. Use null to indicate no action.
+   */
+  public ActionArgs withAction(String name, String defaultValue) {
+    if (name == null) {
+      Log.e("withAction - Invalid name parameter provided.");
+      return this;
+    }
+    args.add(ActionArg.actionArgNamed(name, defaultValue));
+    return this;
+  }
+
+  List<ActionArg<?>> getValue() {
+    return args;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/ActionContext.java
@@ -0,0 +1,551 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.support.annotation.NonNull;
+import android.text.TextUtils;
+
+import com.leanplum.internal.ActionManager;
+import com.leanplum.internal.BaseActionContext;
+import com.leanplum.internal.CollectionUtil;
+import com.leanplum.internal.Constants;
+import com.leanplum.internal.FileManager;
+import com.leanplum.internal.JsonConverter;
+import com.leanplum.internal.LeanplumInternal;
+import com.leanplum.internal.Log;
+import com.leanplum.internal.Util;
+import com.leanplum.internal.VarCache;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The context in which an action or message is executed.
+ *
+ * @author Andrew First
+ */
+public class ActionContext extends BaseActionContext implements Comparable<ActionContext> {
+  private final String name;
+  private ActionContext parentContext;
+  private final int contentVersion;
+  private String key;
+  private boolean preventRealtimeUpdating = false;
+  private ContextualValues contextualValues;
+
+  public static class ContextualValues {
+    /**
+     * Parameters from the triggering event or state.
+     */
+    public Map<String, ?> parameters;
+
+    /**
+     * Arguments from the triggering event or state.
+     */
+    public Map<String, Object> arguments;
+
+    /**
+     * The previous user attribute value.
+     */
+    public Object previousAttributeValue;
+
+    /**
+     * The current user attribute value.
+     */
+    public Object attributeValue;
+  }
+
+  public ActionContext(String name, Map<String, Object> args, String messageId) {
+    this(name, args, messageId, null, Constants.Messaging.DEFAULT_PRIORITY);
+  }
+
+  public ActionContext(String name, Map<String, Object> args, final String messageId,
+      final String originalMessageId, int priority) {
+    super(messageId, originalMessageId);
+    this.name = name;
+    this.args = args;
+    this.contentVersion = VarCache.contentVersion();
+    this.priority = priority;
+  }
+
+  public void preventRealtimeUpdating() {
+    preventRealtimeUpdating = true;
+  }
+
+  public void setContextualValues(ContextualValues values) {
+    contextualValues = values;
+  }
+
+  public ContextualValues getContextualValues() {
+    return contextualValues;
+  }
+
+  private static Map<String, Object> getDefinition(String name) {
+    Map<String, Object> definition = CollectionUtil.uncheckedCast(
+        VarCache.actionDefinitions().get(name));
+    if (definition == null) {
+      return new HashMap<>();
+    }
+    return definition;
+  }
+
+  private Map<String, Object> getDefinition() {
+    return getDefinition(name);
+  }
+
+  private Map<String, Object> defaultValues() {
+    Map<String, Object> values = CollectionUtil.uncheckedCast(getDefinition().get("values"));
+    if (values == null) {
+      return new HashMap<>();
+    }
+    return values;
+  }
+
+  private Map<String, String> kinds() {
+    Map<String, String> kinds = CollectionUtil.uncheckedCast(getDefinition().get("kinds"));
+    if (kinds == null) {
+      return new HashMap<>();
+    }
+    return kinds;
+  }
+
+  public void update() {
+    this.updateArgs(args, "", defaultValues());
+  }
+
+  @SuppressWarnings("unchecked")
+  private void updateArgs(Map<String, Object> args,
+      String prefix, Map<String, Object> defaultValues) {
+    Map<String, String> kinds = kinds();
+    for (Map.Entry<String, Object> entry : args.entrySet()) {
+      String arg = entry.getKey();
+      Object value = entry.getValue();
+      Object defaultValue = defaultValues != null ? defaultValues.get(arg) : null;
+      String kind = kinds.get(prefix + arg);
+      if ((kind == null || !kind.equals(Constants.Kinds.ACTION)) && value instanceof Map &&
+          !((Map<String, ?>) value).containsKey(Constants.Values.ACTION_ARG)) {
+        Map<String, Object> defaultValueMap = (defaultValue instanceof Map) ?
+            (Map<String, Object>) defaultValue : null;
+        this.updateArgs((Map<String, Object>) value, prefix + arg + ".", defaultValueMap);
+      } else {
+        if (kind != null && kind.equals(Constants.Kinds.FILE) ||
+            arg.contains(Constants.Values.FILE_PREFIX)) {
+          FileManager.maybeDownloadFile(false, value.toString(),
+              defaultValue != null ? defaultValue.toString() : null, null, null);
+
+          // Need to check for null because server actions like push notifications aren't
+          // defined in the SDK, and so there's no associated metadata.
+        } else if (kind == null || kind.equals(Constants.Kinds.ACTION)) {
+          Object actionArgsObj = objectNamed(prefix + arg);
+          if (!(actionArgsObj instanceof Map)) {
+            continue;
+          }
+          Map<String, Object> actionArgs = (Map<String, Object>) actionArgsObj;
+          ActionContext context = new ActionContext(
+              (String) actionArgs.get(Constants.Values.ACTION_ARG),
+              actionArgs, messageId);
+          context.update();
+        }
+      }
+    }
+  }
+
+  public String actionName() {
+    return name;
+  }
+
+  public <T> T objectNamed(String name) {
+    if (TextUtils.isEmpty(name)) {
+      Log.e("objectNamed - Invalid name parameter provided.");
+      return null;
+    }
+    try {
+      if (!preventRealtimeUpdating && VarCache.contentVersion() > contentVersion) {
+        ActionContext parent = parentContext;
+        if (parent != null) {
+          args = parent.getChildArgs(key);
+        } else if (messageId != null) {
+          // This is sort of a best effort to display the most recent version of the message, if
+          // this happens to be null, it probably means that it got changed somehow in between the
+          // time when it was activated and displayed (e.g. by forceContentUpdate), in which case
+          // we just ignore it and display the latest stable version.
+          Map<String, Object> message = CollectionUtil.uncheckedCast(VarCache.messages().get
+              (messageId));
+          if (message != null) {
+            args = CollectionUtil.uncheckedCast(message.get(Constants.Keys.VARS));
+          }
+        }
+      }
+      return VarCache.getMergedValueFromComponentArray(
+          VarCache.getNameComponents(name), args);
+    } catch (Throwable t) {
+      Util.handleException(t);
+      return null;
+    }
+  }
+
+  public String stringNamed(String name) {
+    if (TextUtils.isEmpty(name)) {
+      Log.e("stringNamed - Invalid name parameter provided.");
+      return null;
+    }
+    Object object = objectNamed(name);
+    if (object == null) {
+      return null;
+    }
+    try {
+      return fillTemplate(object.toString());
+    } catch (Throwable t) {
+      Util.handleException(t);
+      return object.toString();
+    }
+  }
+
+  private String fillTemplate(String value) {
+    if (contextualValues == null || value == null || !value.contains("##")) {
+      return value;
+    }
+    if (contextualValues.parameters != null) {
+      Map<String, ?> parameters = contextualValues.parameters;
+      for (Map.Entry<String, ?> entry : parameters.entrySet()) {
+        String placeholder = "##Parameter " + entry.getKey() + "##";
+        value = value.replace(placeholder, "" + entry.getValue());
+      }
+    }
+    if (contextualValues.previousAttributeValue != null) {
+      value = value.replace("##Previous Value##",
+          contextualValues.previousAttributeValue.toString());
+    }
+    if (contextualValues.attributeValue != null) {
+      value = value.replace("##Value##", contextualValues.attributeValue.toString());
+    }
+    return value;
+  }
+
+  private String getDefaultValue(String name) {
+    String[] components = name.split("\\.");
+    Map<String, Object> defaultValues = defaultValues();
+    for (int i = 0; i < components.length; i++) {
+      if (defaultValues == null) {
+        return null;
+      }
+      if (i == components.length - 1) {
+        Object value = defaultValues.get(components[i]);
+        return value == null ? null : value.toString();
+      }
+      defaultValues = CollectionUtil.uncheckedCast(defaultValues.get(components[i]));
+    }
+    return null;
+  }
+
+  public InputStream streamNamed(String name) {
+    try {
+      if (TextUtils.isEmpty(name)) {
+        Log.e("streamNamed - Invalid name parameter provided.");
+        return null;
+      }
+      String stringValue = stringNamed(name);
+      String defaultValue = getDefaultValue(name);
+      if ((stringValue == null || stringValue.length() == 0) &&
+          (defaultValue == null || defaultValue.length() == 0)) {
+        return null;
+      }
+      InputStream stream = FileManager.stream(false, null, null,
+          FileManager.fileValue(stringValue, defaultValue, null), defaultValue, null);
+      if (stream == null) {
+        Log.e("Could not open stream named " + name);
+      }
+      return stream;
+    } catch (Throwable t) {
+      Util.handleException(t);
+      return null;
+    }
+  }
+
+  public boolean booleanNamed(String name) {
+    if (TextUtils.isEmpty(name)) {
+      Log.e("booleanNamed - Invalid name parameter provided.");
+      return false;
+    }
+    Object object = objectNamed(name);
+    try {
+      if (object == null) {
+        return false;
+      } else if (object instanceof Boolean) {
+        return (Boolean) object;
+      }
+      return convertToBoolean(object.toString());
+    } catch (Throwable t) {
+      Util.handleException(t);
+      return false;
+    }
+  }
+
+  /**
+   * In contrast to Boolean.valueOf this function also converts 1, yes or similar string values
+   * correctly to Boolean, e.g.: "1", "yes", "true", "on" --> true; "0", "no", "false", "off" -->
+   * false; else null.
+   *
+   * @param value the text to convert to Boolean.
+   * @return Boolean
+   */
+  private static boolean convertToBoolean(String value) {
+    return "1".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value) ||
+        "true".equalsIgnoreCase(value) || "on".equalsIgnoreCase(value);
+  }
+
+  public Number numberNamed(String name) {
+    if (TextUtils.isEmpty(name)) {
+      Log.e("numberNamed - Invalid name parameter provided.");
+      return null;
+    }
+    Object object = objectNamed(name);
+    try {
+      if (object == null || TextUtils.isEmpty(object.toString())) {
+        return 0.0;
+      }
+      if (object instanceof Number) {
+        return (Number) object;
+      }
+      return Double.valueOf(object.toString());
+    } catch (Throwable t) {
+      Util.handleException(t);
+      return 0.0;
+    }
+  }
+
+  private Map<String, Object> getChildArgs(String name) {
+    Object actionArgsObj = objectNamed(name);
+    if (!(actionArgsObj instanceof Map)) {
+      return null;
+    }
+    Map<String, Object> actionArgs = CollectionUtil.uncheckedCast(actionArgsObj);
+    Map<String, Object> defaultArgs = CollectionUtil.uncheckedCast(getDefinition(
+        (String) actionArgs.get(Constants.Values.ACTION_ARG)).get("values"));
+    actionArgs = CollectionUtil.uncheckedCast(VarCache.mergeHelper(defaultArgs, actionArgs));
+    return actionArgs;
+  }
+
+  public void runActionNamed(String name) {
+    if (TextUtils.isEmpty(name)) {
+      Log.e("runActionNamed - Invalid name parameter provided.");
+      return;
+    }
+    Map<String, Object> args = getChildArgs(name);
+    if (args == null) {
+      return;
+    }
+
+    // Checks if action "Chain to Existing Message" started.
+    if (!isChainToExistingMessageStarted(args, name)) {
+      // Try to start action "Chain to a new Message".
+      Object messageAction = args.get(Constants.Values.ACTION_ARG);
+      if (messageAction != null) {
+        createActionContextForMessageId(messageAction.toString(), args, messageId, name);
+      }
+    }
+  }
+
+  /**
+   * Return true if here was an action for this message and we started it.
+   */
+  private boolean createActionContextForMessageId(String messageAction, Map<String, Object>
+      messageArgs, String messageId, String name) {
+    try {
+      ActionContext actionContext = new ActionContext(messageAction,
+          messageArgs, messageId);
+      actionContext.contextualValues = contextualValues;
+      actionContext.preventRealtimeUpdating = preventRealtimeUpdating;
+      actionContext.isRooted = isRooted;
+      actionContext.parentContext = this;
+      actionContext.key = name;
+      LeanplumInternal.triggerAction(actionContext);
+      return true;
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+    return false;
+  }
+
+  /**
+   * Return true if here was action "Chain to Existing Message" and we started it.
+   */
+  private boolean isChainToExistingMessageStarted(Map<String, Object> args, String name) {
+    if (args == null) {
+      return false;
+    }
+
+    String messageId = (String) args.get(Constants.Values.CHAIN_MESSAGE_ARG);
+    Object actionType = args.get(Constants.Values.ACTION_ARG);
+    if (messageId != null && Constants.Values.CHAIN_MESSAGE_ACTION_NAME.equals(actionType)) {
+      Map<String, Object> messages = VarCache.messages();
+      if (messages != null && messages.containsKey(messageId)) {
+        Map<String, Object> message = CollectionUtil.uncheckedCast(messages.get(messageId));
+        if (message != null) {
+          Map<String, Object> messageArgs = CollectionUtil.uncheckedCast(
+              message.get(Constants.Keys.VARS));
+          Object messageAction = message.get("action");
+          return messageAction != null && createActionContextForMessageId(messageAction.toString(),
+              messageArgs, messageId, name);
+        }
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Prefix given event with all parent actionContext names to while filtering out the string
+   * "action" (used in ExperimentVariable names but filtered out from event names).
+   *
+   * @param eventName Current event.
+   * @return Prefixed event name with all parent actions.
+   */
+  private String eventWithParentEventNames(String eventName) {
+    StringBuilder fullEventName = new StringBuilder();
+    ActionContext context = this;
+    List<ActionContext> parents = new ArrayList<>();
+    while (context.parentContext != null) {
+      parents.add(context);
+      context = context.parentContext;
+    }
+    for (int i = parents.size() - 1; i >= -1; i--) {
+      if (fullEventName.length() > 0) {
+        fullEventName.append(' ');
+      }
+      String actionName;
+      if (i > -1) {
+        actionName = parents.get(i).key;
+      } else {
+        actionName = eventName;
+      }
+      if (actionName == null) {
+        fullEventName = new StringBuilder("");
+        break;
+      }
+      actionName = actionName.replace(" action", "");
+      fullEventName.append(actionName);
+    }
+
+    return fullEventName.toString();
+  }
+
+  /**
+   * Run the action with the given variable name, and track a message event with the name.
+   *
+   * @param name Action variable name to run.
+   */
+  public void runTrackedActionNamed(String name) {
+    try {
+      if (!Constants.isNoop() && messageId != null && isRooted) {
+        if (TextUtils.isEmpty(name)) {
+          Log.e("runTrackedActionNamed - Invalid name parameter provided.");
+          return;
+        }
+        trackMessageEvent(name, 0.0, null, null);
+      }
+      runActionNamed(name);
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * Track a message event with the given parameters. Any parent event names will be prepended to
+   * given event name.
+   *
+   * @param event Name of event.
+   * @param value Value for event.
+   * @param info Info for event.
+   * @param params Dictionary of params for event.
+   */
+  public void trackMessageEvent(String event, double value, String info,
+      Map<String, Object> params) {
+    try {
+      if (!Constants.isNoop() && this.messageId != null) {
+        if (TextUtils.isEmpty(event)) {
+          Log.e("trackMessageEvent - Invalid event parameter provided.");
+          return;
+        }
+
+        event = eventWithParentEventNames(event);
+        if (TextUtils.isEmpty(event)) {
+          Log.e("trackMessageEvent - Failed to generate parent action names.");
+          return;
+        }
+
+        Map<String, String> requestArgs = new HashMap<>();
+        requestArgs.put(Constants.Params.MESSAGE_ID, messageId);
+        LeanplumInternal.track(event, value, info, params, requestArgs);
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  public void track(String event, double value, Map<String, Object> params) {
+    try {
+      if (!Constants.isNoop() && this.messageId != null) {
+        if (TextUtils.isEmpty(event)) {
+          Log.e("track - Invalid event parameter provided.");
+          return;
+        }
+        Map<String, String> requestArgs = new HashMap<>();
+        requestArgs.put(Constants.Params.MESSAGE_ID, messageId);
+        LeanplumInternal.track(event, value, null, params, requestArgs);
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  public void muteFutureMessagesOfSameKind() {
+    try {
+      ActionManager.getInstance().muteFutureMessagesOfKind(messageId);
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  public int compareTo(@NonNull ActionContext other) {
+    return priority - other.getPriority();
+  }
+
+  /**
+   * Returns path to requested file.
+   */
+  public static String filePath(String stringValue) {
+    return FileManager.fileValue(stringValue);
+  }
+
+  public static JSONObject mapToJsonObject(Map<String, ?> map) throws JSONException {
+    return JsonConverter.mapToJsonObject(map);
+  }
+
+  public static <T> Map<String, T> mapFromJson(JSONObject jsonObject) throws JSONException {
+    return JsonConverter.mapFromJson(jsonObject);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/CacheUpdateBlock.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2017, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+/**
+ * Update block that will be triggered on new content.
+ *
+ * @author Ben Marten
+ */
+public interface CacheUpdateBlock {
+  void updateCache();
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/Leanplum.java
@@ -0,0 +1,2049 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.app.Activity;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.location.Location;
+import android.os.AsyncTask;
+import android.support.v4.app.NotificationCompat;
+import android.text.TextUtils;
+
+import com.leanplum.ActionContext.ContextualValues;
+import com.leanplum.callbacks.ActionCallback;
+import com.leanplum.callbacks.RegisterDeviceCallback;
+import com.leanplum.callbacks.RegisterDeviceFinishedCallback;
+import com.leanplum.callbacks.StartCallback;
+import com.leanplum.callbacks.VariablesChangedCallback;
+import com.leanplum.internal.Constants;
+import com.leanplum.internal.FileManager;
+import com.leanplum.internal.JsonConverter;
+import com.leanplum.internal.LeanplumInternal;
+import com.leanplum.internal.LeanplumMessageMatchFilter;
+import com.leanplum.internal.LeanplumUIEditorWrapper;
+import com.leanplum.internal.Log;
+import com.leanplum.internal.OsHandler;
+import com.leanplum.internal.Registration;
+import com.leanplum.internal.Request;
+import com.leanplum.internal.Util;
+import com.leanplum.internal.Util.DeviceIdInfo;
+import com.leanplum.internal.VarCache;
+import com.leanplum.messagetemplates.MessageTemplates;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Leanplum Android SDK.
+ *
+ * @author Andrew First, Ben Marten
+ */
+public class Leanplum {
+  public static final int ACTION_KIND_MESSAGE = 1;
+  public static final int ACTION_KIND_ACTION = 1 << 1;
+
+  /**
+   * Default event name to use for Purchase events.
+   */
+  public static final String PURCHASE_EVENT_NAME = "Purchase";
+
+  private static final ArrayList<StartCallback> startHandlers = new ArrayList<>();
+  private static final ArrayList<VariablesChangedCallback> variablesChangedHandlers =
+      new ArrayList<>();
+  private static final ArrayList<VariablesChangedCallback> noDownloadsHandlers =
+      new ArrayList<>();
+  private static final ArrayList<VariablesChangedCallback> onceNoDownloadsHandlers =
+      new ArrayList<>();
+  private static RegisterDeviceCallback registerDeviceHandler;
+  private static RegisterDeviceFinishedCallback registerDeviceFinishedHandler;
+
+  private static LeanplumDeviceIdMode deviceIdMode = LeanplumDeviceIdMode.MD5_MAC_ADDRESS;
+  private static String customDeviceId;
+  private static boolean userSpecifiedDeviceId;
+  private static boolean initializedMessageTemplates = false;
+  private static boolean locationCollectionEnabled = true;
+
+  private static ScheduledExecutorService heartbeatExecutor;
+  private static final Object heartbeatLock = new Object();
+
+  private static Context context;
+
+  private static Runnable pushStartCallback;
+
+  private Leanplum() {
+  }
+
+  /**
+   * Optional. Sets the API server. The API path is of the form http[s]://hostname/servletName
+   *
+   * @param hostName The name of the API host, such as www.leanplum.com
+   * @param servletName The name of the API servlet, such as api
+   * @param ssl Whether to use SSL
+   */
+  public static void setApiConnectionSettings(String hostName, String servletName, boolean ssl) {
+    if (TextUtils.isEmpty(hostName)) {
+      Log.e("setApiConnectionSettings - Empty hostname parameter provided.");
+      return;
+    }
+    if (TextUtils.isEmpty(servletName)) {
+      Log.e("setApiConnectionSettings - Empty servletName parameter provided.");
+      return;
+    }
+
+    Constants.API_HOST_NAME = hostName;
+    Constants.API_SERVLET = servletName;
+    Constants.API_SSL = ssl;
+  }
+
+  /**
+   * Optional. Sets the socket server path for Development mode. Path is of the form hostName:port
+   *
+   * @param hostName The host name of the socket server.
+   * @param port The port to connect to.
+   */
+  public static void setSocketConnectionSettings(String hostName, int port) {
+    if (TextUtils.isEmpty(hostName)) {
+      Log.e("setSocketConnectionSettings - Empty hostName parameter provided.");
+      return;
+    }
+    if (port < 1 || port > 65535) {
+      Log.e("setSocketConnectionSettings - Invalid port parameter provided.");
+      return;
+    }
+
+    Constants.SOCKET_HOST = hostName;
+    Constants.SOCKET_PORT = port;
+  }
+
+  /**
+   * Optional. By default, Leanplum will hash file variables to determine if they're modified and
+   * need to be uploaded to the server. Use this method to override this setting.
+   *
+   * @param enabled Setting this to false will reduce startup latency in development mode, but it's
+   * possible that Leanplum will always have the most up-to-date versions of your resources.
+   * (Default: true)
+   */
+  public static void setFileHashingEnabledInDevelopmentMode(boolean enabled) {
+    Constants.hashFilesToDetermineModifications = enabled;
+  }
+
+  /**
+   * Optional. Whether to enable file uploading in development mode.
+   *
+   * @param enabled Whether or not files should be uploaded. (Default: true)
+   */
+  public static void setFileUploadingEnabledInDevelopmentMode(boolean enabled) {
+    Constants.enableFileUploadingInDevelopmentMode = enabled;
+  }
+
+  /**
+   * Optional. Enables verbose logging in development mode.
+   */
+  public static void enableVerboseLoggingInDevelopmentMode() {
+    Constants.enableVerboseLoggingInDevelopmentMode = true;
+  }
+
+  /**
+   * Optional. Adjusts the network timeouts. The default timeout is 10 seconds for requests, and 15
+   * seconds for file downloads.
+   */
+  public static void setNetworkTimeout(int seconds, int downloadSeconds) {
+    if (seconds < 0) {
+      Log.e("setNetworkTimeout - Invalid seconds parameter provided.");
+      return;
+    }
+    if (downloadSeconds < 0) {
+      Log.e("setNetworkTimeout - Invalid downloadSeconds parameter provided.");
+      return;
+    }
+
+    Constants.NETWORK_TIMEOUT_SECONDS = seconds;
+    Constants.NETWORK_TIMEOUT_SECONDS_FOR_DOWNLOADS = downloadSeconds;
+  }
+
+  /**
+   * Advanced: Whether new variables can be downloaded mid-session. By default, this is disabled.
+   * Currently, if this is enabled, new variables can only be downloaded if a push notification is
+   * sent while the app is running, and the notification's metadata hasn't be downloaded yet.
+   */
+  public static void setCanDownloadContentMidSessionInProductionMode(boolean value) {
+    Constants.canDownloadContentMidSessionInProduction = value;
+  }
+
+  /**
+   * Must call either this or {@link Leanplum#setAppIdForProductionMode} before issuing any calls to
+   * the API, including start.
+   *
+   * @param appId Your app ID.
+   * @param accessKey Your development key.
+   */
+  public static void setAppIdForDevelopmentMode(String appId, String accessKey) {
+    if (TextUtils.isEmpty(appId)) {
+      Log.e("setAppIdForDevelopmentMode - Empty appId parameter provided.");
+      return;
+    }
+    if (TextUtils.isEmpty(accessKey)) {
+      Log.e("setAppIdForDevelopmentMode - Empty accessKey parameter provided.");
+      return;
+    }
+
+    Constants.isDevelopmentModeEnabled = true;
+    Request.setAppId(appId, accessKey);
+  }
+
+  /**
+   * Must call either this or {@link Leanplum#setAppIdForDevelopmentMode} before issuing any calls
+   * to the API, including start.
+   *
+   * @param appId Your app ID.
+   * @param accessKey Your production key.
+   */
+  public static void setAppIdForProductionMode(String appId, String accessKey) {
+    if (TextUtils.isEmpty(appId)) {
+      Log.e("setAppIdForProductionMode - Empty appId parameter provided.");
+      return;
+    }
+    if (TextUtils.isEmpty(accessKey)) {
+      Log.e("setAppIdForProductionMode - Empty accessKey parameter provided.");
+      return;
+    }
+
+    Constants.isDevelopmentModeEnabled = false;
+    Request.setAppId(appId, accessKey);
+  }
+
+  /**
+   * Enable interface editing via Leanplum.com Visual Editor.
+   */
+  @Deprecated
+  public static void allowInterfaceEditing() {
+    if (Constants.isDevelopmentModeEnabled) {
+      throw new LeanplumException("Leanplum UI Editor has moved to a separate package. " +
+          "Please remove this method call and include this line in your build.gradle: " +
+          "compile 'com.leanplum:UIEditor:+'");
+    }
+  }
+
+  /**
+   * Enable screen tracking.
+   */
+  public static void trackAllAppScreens() {
+    LeanplumInternal.enableAutomaticScreenTracking();
+  }
+
+  /**
+   * Whether screen tracking is enabled or not.
+   *
+   * @return Boolean - true if enabled
+   */
+  public static boolean isScreenTrackingEnabled() {
+    return LeanplumInternal.getIsScreenTrackingEnabled();
+  }
+
+  /**
+   * Whether interface editing is enabled or not.
+   *
+   * @return Boolean - true if enabled
+   */
+  public static boolean isInterfaceEditingEnabled() {
+    return LeanplumUIEditorWrapper.isUIEditorAvailable();
+  }
+
+  /**
+   * Sets the type of device ID to use. Default: {@link LeanplumDeviceIdMode#MD5_MAC_ADDRESS}
+   */
+  public static void setDeviceIdMode(LeanplumDeviceIdMode mode) {
+    if (mode == null) {
+      Log.e("setDeviceIdMode - Invalid mode parameter provided.");
+      return;
+    }
+
+    deviceIdMode = mode;
+    userSpecifiedDeviceId = true;
+  }
+
+  /**
+   * (Advanced) Sets a custom device ID. Normally, you should use setDeviceIdMode to change the type
+   * of device ID provided.
+   */
+  public static void setDeviceId(String deviceId) {
+    if (TextUtils.isEmpty(deviceId)) {
+      Log.w("setDeviceId - Empty deviceId parameter provided.");
+    }
+
+    customDeviceId = deviceId;
+    userSpecifiedDeviceId = true;
+  }
+
+  /**
+   * Sets the application context. This should be the first call to Leanplum.
+   */
+  public static void setApplicationContext(Context context) {
+    if (context == null) {
+      Log.w("setApplicationContext - Null context parameter provided.");
+    }
+
+    Leanplum.context = context;
+  }
+
+  /**
+   * Gets the application context.
+   */
+  public static Context getContext() {
+    if (context == null) {
+      Log.e("Your application context is not set. "
+          + "You should call Leanplum.setApplicationContext(this) or "
+          + "LeanplumActivityHelper.enableLifecycleCallbacks(this) in your application's "
+          + "onCreate method, or have your application extend LeanplumApplication.");
+    }
+    return context;
+  }
+
+  /**
+   * Called when the device needs to be registered in development mode.
+   */
+  @Deprecated
+  public static void setRegisterDeviceHandler(RegisterDeviceCallback handler,
+      RegisterDeviceFinishedCallback finishHandler) {
+    if (handler == null && finishHandler == null) {
+      Log.w("setRegisterDeviceHandler - Invalid handler parameter provided.");
+    }
+
+    registerDeviceHandler = handler;
+    registerDeviceFinishedHandler = finishHandler;
+  }
+
+  /**
+   * Syncs resources between Leanplum and the current app. You should only call this once, and
+   * before {@link Leanplum#start}. syncResourcesAsync should be used instead unless file variables
+   * need to be defined early
+   */
+  public static void syncResources() {
+    if (Constants.isNoop()) {
+      return;
+    }
+    try {
+      FileManager.enableResourceSyncing(null, null, false);
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * Syncs resources between Leanplum and the current app. You should only call this once, and
+   * before {@link Leanplum#start}.
+   */
+  public static void syncResourcesAsync() {
+    if (Constants.isNoop()) {
+      return;
+    }
+    try {
+      FileManager.enableResourceSyncing(null, null, true);
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * Syncs resources between Leanplum and the current app. You should only call this once, and
+   * before {@link Leanplum#start}. syncResourcesAsync should be used instead unless file variables
+   * need to be defined early
+   *
+   * @param patternsToInclude Limit paths to only those matching at least one pattern in this list.
+   * Supply null to indicate no inclusion patterns. Paths start with the folder name within the res
+   * folder, e.g. "layout/main.xml".
+   * @param patternsToExclude Exclude paths matching at least one of these patterns. Supply null to
+   * indicate no exclusion patterns.
+   */
+  public static void syncResources(
+      List<String> patternsToInclude,
+      List<String> patternsToExclude) {
+    try {
+      FileManager.enableResourceSyncing(patternsToInclude, patternsToExclude, false);
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * Syncs resources between Leanplum and the current app. You should only call this once, and
+   * before {@link Leanplum#start}. syncResourcesAsync should be used instead unless file variables
+   * need to be defined early
+   *
+   * @param patternsToInclude Limit paths to only those matching at least one pattern in this list.
+   * Supply null to indicate no inclusion patterns. Paths start with the folder name within the res
+   * folder, e.g. "layout/main.xml".
+   * @param patternsToExclude Exclude paths matching at least one of these patterns. Supply null to
+   * indicate no exclusion patterns.
+   */
+  public static void syncResourcesAsync(
+      List<String> patternsToInclude,
+      List<String> patternsToExclude) {
+    try {
+      FileManager.enableResourceSyncing(patternsToInclude, patternsToExclude, true);
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * Returns true if resource syncing is enabled. Resource syncing may not be fully initialized.
+   */
+  public static boolean isResourceSyncingEnabled() {
+    return FileManager.isResourceSyncingEnabled();
+  }
+
+  /**
+   * Call this when your application starts. This will initiate a call to Leanplum's servers to get
+   * the values of the variables used in your app.
+   */
+  public static void start(Context context) {
+    start(context, null, null, null, null);
+  }
+
+  /**
+   * Call this when your application starts. This will initiate a call to Leanplum's servers to get
+   * the values of the variables used in your app.
+   */
+  public static void start(Context context, StartCallback callback) {
+    start(context, null, null, callback, null);
+  }
+
+  /**
+   * Call this when your application starts. This will initiate a call to Leanplum's servers to get
+   * the values of the variables used in your app.
+   */
+  public static void start(Context context, Map<String, ?> userAttributes) {
+    start(context, null, userAttributes, null, null);
+  }
+
+  /**
+   * Call this when your application starts. This will initiate a call to Leanplum's servers to get
+   * the values of the variables used in your app.
+   */
+  public static void start(Context context, String userId) {
+    start(context, userId, null, null, null);
+  }
+
+  /**
+   * Call this when your application starts. This will initiate a call to Leanplum's servers to get
+   * the values of the variables used in your app.
+   */
+  public static void start(Context context, String userId, StartCallback callback) {
+    start(context, userId, null, callback, null);
+  }
+
+  /**
+   * Call this when your application starts. This will initiate a call to Leanplum's servers to get
+   * the values of the variables used in your app.
+   */
+  public static void start(Context context, String userId, Map<String, ?> userAttributes) {
+    start(context, userId, userAttributes, null, null);
+  }
+
+  /**
+   * Call this when your application starts. This will initiate a call to Leanplum's servers to get
+   * the values of the variables used in your app.
+   */
+  public static synchronized void start(final Context context, String userId,
+      Map<String, ?> attributes, StartCallback response) {
+    start(context, userId, attributes, response, null);
+  }
+
+  static synchronized void start(final Context context, final String userId,
+      final Map<String, ?> attributes, StartCallback response, final Boolean isBackground) {
+    try {
+      OsHandler.getInstance();
+
+      if (context instanceof Activity) {
+        LeanplumActivityHelper.currentActivity = (Activity) context;
+      }
+
+      // Detect if app is in background automatically if isBackground is not set.
+      final boolean actuallyInBackground;
+      if (isBackground == null) {
+        actuallyInBackground = LeanplumActivityHelper.currentActivity == null ||
+            LeanplumActivityHelper.isActivityPaused();
+      } else {
+        actuallyInBackground = isBackground;
+      }
+
+      if (Constants.isNoop()) {
+        LeanplumInternal.setHasStarted(true);
+        LeanplumInternal.setStartSuccessful(true);
+        triggerStartResponse(true);
+        triggerVariablesChanged();
+        triggerVariablesChangedAndNoDownloadsPending();
+        VarCache.applyVariableDiffs(
+            new HashMap<String, Object>(),
+            new HashMap<String, Object>(),
+            VarCache.getUpdateRuleDiffs(),
+            VarCache.getEventRuleDiffs(),
+            new HashMap<String, Object>(),
+            new ArrayList<Map<String, Object>>());
+        LeanplumInbox.getInstance().update(new HashMap<String, LeanplumInboxMessage>(), 0, false);
+        return;
+      }
+
+      if (response != null) {
+        addStartResponseHandler(response);
+      }
+
+      if (context != null) {
+        Leanplum.setApplicationContext(context.getApplicationContext());
+      }
+
+      if (LeanplumInternal.hasCalledStart()) {
+        if (!actuallyInBackground && LeanplumInternal.hasStartedInBackground()) {
+          // Move to foreground.
+          LeanplumInternal.setStartedInBackground(false);
+          LeanplumInternal.moveToForeground();
+        } else {
+          Log.i("Already called start");
+        }
+        return;
+      }
+
+      initializedMessageTemplates = true;
+      MessageTemplates.register(Leanplum.getContext());
+
+      LeanplumInternal.setStartedInBackground(actuallyInBackground);
+
+      final Map<String, ?> validAttributes = LeanplumInternal.validateAttributes(attributes,
+          "userAttributes", true);
+      LeanplumInternal.setCalledStart(true);
+
+      if (validAttributes != null) {
+        LeanplumInternal.getUserAttributeChanges().add(validAttributes);
+      }
+
+      Request.loadToken();
+      VarCache.setSilent(true);
+      VarCache.loadDiffs();
+      VarCache.setSilent(false);
+      LeanplumInbox.getInstance().load();
+
+      // Setup class members.
+      VarCache.onUpdate(new CacheUpdateBlock() {
+        @Override
+        public void updateCache() {
+          triggerVariablesChanged();
+          if (Request.numPendingDownloads() == 0) {
+            triggerVariablesChangedAndNoDownloadsPending();
+          }
+        }
+      });
+      Request.onNoPendingDownloads(new Request.NoPendingDownloadsCallback() {
+        @Override
+        public void noPendingDownloads() {
+          triggerVariablesChangedAndNoDownloadsPending();
+        }
+      });
+
+      // Reduce latency by running the rest of the start call in a background thread.
+      Util.executeAsyncTask(new AsyncTask<Void, Void, Void>() {
+        @Override
+        protected Void doInBackground(Void... params) {
+          try {
+            startHelper(userId, validAttributes, actuallyInBackground);
+          } catch (Throwable t) {
+            Util.handleException(t);
+          }
+          return null;
+        }
+      });
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  private static void startHelper(
+      String userId, final Map<String, ?> attributes, final boolean isBackground) {
+    LeanplumPushService.onStart();
+
+    Boolean limitAdTracking = null;
+    String deviceId = Request.deviceId();
+    if (deviceId == null) {
+      if (!userSpecifiedDeviceId && Constants.defaultDeviceId != null) {
+        deviceId = Constants.defaultDeviceId;
+      } else if (customDeviceId != null) {
+        deviceId = customDeviceId;
+      } else {
+        DeviceIdInfo deviceIdInfo = Util.getDeviceId(deviceIdMode);
+        deviceId = deviceIdInfo.id;
+        limitAdTracking = deviceIdInfo.limitAdTracking;
+      }
+      Request.setDeviceId(deviceId);
+    }
+
+    if (userId == null) {
+      userId = Request.userId();
+      if (userId == null) {
+        userId = Request.deviceId();
+      }
+    }
+    Request.setUserId(userId);
+
+    // Setup parameters.
+    String versionName = Util.getVersionName();
+    if (versionName == null) {
+      versionName = "";
+    }
+
+    TimeZone localTimeZone = TimeZone.getDefault();
+    Date now = new Date();
+    int timezoneOffsetSeconds = localTimeZone.getOffset(now.getTime()) / 1000;
+
+    HashMap<String, Object> params = new HashMap<>();
+    params.put(Constants.Params.INCLUDE_DEFAULTS, Boolean.toString(false));
+    if (isBackground) {
+      params.put(Constants.Params.BACKGROUND, Boolean.toString(true));
+    }
+    params.put(Constants.Params.VERSION_NAME, versionName);
+    params.put(Constants.Params.DEVICE_NAME, Util.getDeviceName());
+    params.put(Constants.Params.DEVICE_MODEL, Util.getDeviceModel());
+    params.put(Constants.Params.DEVICE_SYSTEM_NAME, Util.getSystemName());
+    params.put(Constants.Params.DEVICE_SYSTEM_VERSION, Util.getSystemVersion());
+    params.put(Constants.Keys.TIMEZONE, localTimeZone.getID());
+    params.put(Constants.Keys.TIMEZONE_OFFSET_SECONDS, Integer.toString(timezoneOffsetSeconds));
+    params.put(Constants.Keys.LOCALE, Util.getLocale());
+    params.put(Constants.Keys.COUNTRY, Constants.Values.DETECT);
+    params.put(Constants.Keys.REGION, Constants.Values.DETECT);
+    params.put(Constants.Keys.CITY, Constants.Values.DETECT);
+    params.put(Constants.Keys.LOCATION, Constants.Values.DETECT);
+    if (Boolean.TRUE.equals(limitAdTracking)) {
+      params.put(Constants.Params.LIMIT_TRACKING, limitAdTracking.toString());
+    }
+    if (attributes != null) {
+      params.put(Constants.Params.USER_ATTRIBUTES, JsonConverter.toJson(attributes));
+    }
+    if (Constants.isDevelopmentModeEnabled) {
+      params.put(Constants.Params.DEV_MODE, Boolean.TRUE.toString());
+    }
+
+    // Get the current inbox messages on the device.
+    params.put(Constants.Params.INBOX_MESSAGES, LeanplumInbox.getInstance().messagesIds());
+
+    Util.initializePreLeanplumInstall(params);
+
+    // Issue start API call.
+    Request req = Request.post(Constants.Methods.START, params);
+    req.onApiResponse(new Request.ApiResponseCallback() {
+      @Override
+      public void response(List<Map<String, Object>> requests, JSONObject response) {
+        Leanplum.handleApiResponse(response, requests);
+      }
+    });
+
+    if (isBackground) {
+      req.sendEventually();
+    } else {
+      req.sendIfConnected();
+    }
+
+    LeanplumInternal.triggerStartIssued();
+  }
+
+  private static void handleApiResponse(JSONObject response, List<Map<String, Object>> requests) {
+    boolean hasStartResponse = false;
+    JSONObject lastStartResponse = null;
+
+    // Find and handle the last start response.
+    try {
+      int numResponses = Request.numResponses(response);
+      for (int i = requests.size() - 1; i >= 0; i--) {
+        Map<String, Object> request = requests.get(i);
+        if (Constants.Methods.START.equals(request.get(Constants.Params.ACTION))) {
+          if (i < numResponses) {
+            lastStartResponse = Request.getResponseAt(response, i);
+          }
+          hasStartResponse = true;
+          break;
+        }
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+
+    if (hasStartResponse) {
+      if (!LeanplumInternal.hasStarted()) {
+        Leanplum.handleStartResponse(lastStartResponse);
+      }
+    }
+  }
+
+  private static void handleStartResponse(JSONObject response) {
+    boolean success = Request.isResponseSuccess(response);
+    if (!success) {
+      try {
+        LeanplumInternal.setHasStarted(true);
+        LeanplumInternal.setStartSuccessful(false);
+
+        // Load the variables that were stored on the device from the last session.
+        VarCache.loadDiffs();
+
+        triggerStartResponse(false);
+      } catch (Throwable t) {
+        Util.handleException(t);
+      }
+    } else {
+      try {
+        LeanplumInternal.setHasStarted(true);
+        LeanplumInternal.setStartSuccessful(true);
+
+        JSONObject values = response.optJSONObject(Constants.Keys.VARS);
+        if (values == null) {
+          Log.e("No variable values were received from the server. " +
+              "Please contact us to investigate.");
+        }
+
+        JSONObject messages = response.optJSONObject(Constants.Keys.MESSAGES);
+        if (messages == null) {
+          Log.d("No messages received from the server.");
+        }
+
+        JSONObject regions = response.optJSONObject(Constants.Keys.REGIONS);
+        if (regions == null) {
+          Log.d("No regions received from the server.");
+        }
+
+        JSONArray variants = response.optJSONArray(Constants.Keys.VARIANTS);
+        if (variants == null) {
+          Log.d("No variants received from the server.");
+        }
+
+        String token = response.optString(Constants.Keys.TOKEN, null);
+        Request.setToken(token);
+        Request.saveToken();
+
+        applyContentInResponse(response, true);
+
+        VarCache.saveUserAttributes();
+        triggerStartResponse(true);
+
+        if (response.optBoolean(Constants.Keys.SYNC_INBOX, false)) {
+          LeanplumInbox.getInstance().downloadMessages();
+        }
+
+        if (response.optBoolean(Constants.Keys.LOGGING_ENABLED, false)) {
+          Constants.loggingEnabled = true;
+        }
+
+        // Allow bidirectional realtime variable updates.
+        if (Constants.isDevelopmentModeEnabled) {
+
+          final Context currentContext = (
+              LeanplumActivityHelper.currentActivity != context &&
+                  LeanplumActivityHelper.currentActivity != null) ?
+              LeanplumActivityHelper.currentActivity
+              : context;
+
+          // Register device.
+          if (!response.optBoolean(
+              Constants.Keys.IS_REGISTERED) && registerDeviceHandler != null) {
+            registerDeviceHandler.setResponseHandler(new RegisterDeviceCallback.EmailCallback() {
+              @Override
+              public void onResponse(String email) {
+                try {
+                  if (email != null) {
+                    Registration.registerDevice(email, new StartCallback() {
+                      @Override
+                      public void onResponse(boolean success) {
+                        if (registerDeviceFinishedHandler != null) {
+                          registerDeviceFinishedHandler.setSuccess(success);
+                          OsHandler.getInstance().post(registerDeviceFinishedHandler);
+                        }
+                        if (success) {
+                          try {
+                            LeanplumInternal.onHasStartedAndRegisteredAsDeveloper();
+                          } catch (Throwable t) {
+                            Util.handleException(t);
+                          }
+                        }
+                      }
+                    });
+                  }
+                } catch (Throwable t) {
+                  Util.handleException(t);
+                }
+              }
+            });
+            OsHandler.getInstance().post(registerDeviceHandler);
+          }
+
+          // Show device is already registered.
+          if (response.optBoolean(Constants.Keys.IS_REGISTERED_FROM_OTHER_APP)) {
+            OsHandler.getInstance().post(new Runnable() {
+              @Override
+              public void run() {
+                try {
+                  NotificationCompat.Builder mBuilder =
+                      new NotificationCompat.Builder(currentContext)
+                          .setSmallIcon(android.R.drawable.star_on)
+                          .setContentTitle("Leanplum")
+                          .setContentText("Your device is registered.");
+                  mBuilder.setContentIntent(PendingIntent.getActivity(
+                      currentContext.getApplicationContext(), 0, new Intent(), 0));
+                  NotificationManager mNotificationManager =
+                      (NotificationManager) currentContext.getSystemService(
+                          Context.NOTIFICATION_SERVICE);
+                  // mId allows you to update the notification later on.
+                  mNotificationManager.notify(0, mBuilder.build());
+                } catch (Throwable t) {
+                  Log.i("Device is registered.");
+                }
+              }
+            });
+          }
+
+          boolean isRegistered = response.optBoolean(Constants.Keys.IS_REGISTERED);
+
+          // Check for updates.
+          final String latestVersion = response.optString(Constants.Keys.LATEST_VERSION, null);
+          if (isRegistered && latestVersion != null) {
+            Log.i("An update to Leanplum Android SDK, " + latestVersion +
+                ", is available. Go to leanplum.com to download it.");
+          }
+
+          JSONObject valuesFromCode = response.optJSONObject(Constants.Keys.VARS_FROM_CODE);
+          if (valuesFromCode == null) {
+            valuesFromCode = new JSONObject();
+          }
+
+          JSONObject actionDefinitions =
+              response.optJSONObject(Constants.Params.ACTION_DEFINITIONS);
+          if (actionDefinitions == null) {
+            actionDefinitions = new JSONObject();
+          }
+
+          JSONObject fileAttributes = response.optJSONObject(Constants.Params.FILE_ATTRIBUTES);
+          if (fileAttributes == null) {
+            fileAttributes = new JSONObject();
+          }
+
+          VarCache.setDevModeValuesFromServer(
+              JsonConverter.mapFromJson(valuesFromCode),
+              JsonConverter.mapFromJson(fileAttributes),
+              JsonConverter.mapFromJson(actionDefinitions));
+
+          if (isRegistered) {
+            LeanplumInternal.onHasStartedAndRegisteredAsDeveloper();
+          }
+        }
+
+        LeanplumInternal.moveToForeground();
+        startHeartbeat();
+      } catch (Throwable t) {
+        Util.handleException(t);
+      }
+    }
+  }
+
+  /**
+   * Applies the variables, messages, or update rules in a start or getVars response.
+   *
+   * @param response The response containing content.
+   * @param alwaysApply Always apply the content regardless of whether the content changed.
+   */
+  private static void applyContentInResponse(JSONObject response, boolean alwaysApply) {
+    Map<String, Object> values = JsonConverter.mapFromJsonOrDefault(
+        response.optJSONObject(Constants.Keys.VARS));
+    Map<String, Object> messages = JsonConverter.mapFromJsonOrDefault(
+        response.optJSONObject(Constants.Keys.MESSAGES));
+    List<Map<String, Object>> updateRules = JsonConverter.listFromJsonOrDefault(
+        response.optJSONArray(Constants.Keys.UPDATE_RULES));
+    List<Map<String, Object>> eventRules = JsonConverter.listFromJsonOrDefault(
+        response.optJSONArray(Constants.Keys.EVENT_RULES));
+    Map<String, Object> regions = JsonConverter.mapFromJsonOrDefault(
+        response.optJSONObject(Constants.Keys.REGIONS));
+    List<Map<String, Object>> variants = JsonConverter.listFromJsonOrDefault(
+        response.optJSONArray(Constants.Keys.VARIANTS));
+
+    if (alwaysApply
+        || !values.equals(VarCache.getDiffs())
+        || !messages.equals(VarCache.getMessageDiffs())
+        || !updateRules.equals(VarCache.getUpdateRuleDiffs())
+        || !eventRules.equals(VarCache.getEventRuleDiffs())
+        || !regions.equals(VarCache.regions())) {
+      VarCache.applyVariableDiffs(values, messages, updateRules,
+          eventRules, regions, variants);
+    }
+  }
+
+  /**
+   * Used by wrapper SDKs like Unity to override the SDK client name and version.
+   */
+  static void setClient(String client, String sdkVersion, String defaultDeviceId) {
+    Constants.CLIENT = client;
+    Constants.LEANPLUM_VERSION = sdkVersion;
+    Constants.defaultDeviceId = defaultDeviceId;
+  }
+
+  /**
+   * Call this when your activity pauses. This is called from LeanplumActivityHelper.
+   */
+  static void pause() {
+    if (Constants.isNoop()) {
+      return;
+    }
+    if (!LeanplumInternal.hasCalledStart()) {
+      Log.e("You cannot call pause before calling start");
+      return;
+    }
+    LeanplumInternal.setIsPaused(true);
+
+    if (LeanplumInternal.isPaused()) {
+      pauseInternal();
+    } else {
+      LeanplumInternal.addStartIssuedHandler(new Runnable() {
+        @Override
+        public void run() {
+          try {
+            pauseInternal();
+          } catch (Throwable t) {
+            Util.handleException(t);
+          }
+        }
+      });
+    }
+  }
+
+  private static void pauseInternal() {
+    Request.post(Constants.Methods.PAUSE_SESSION, null).sendIfConnected();
+    pauseHeartbeat();
+  }
+
+  /**
+   * Call this when your activity resumes. This is called from LeanplumActivityHelper.
+   */
+  static void resume() {
+    if (Constants.isNoop()) {
+      return;
+    }
+    if (!LeanplumInternal.hasCalledStart()) {
+      Log.e("You cannot call resume before calling start");
+      return;
+    }
+    LeanplumInternal.setIsPaused(false);
+
+    if (LeanplumInternal.issuedStart()) {
+      resumeInternal();
+    } else {
+      LeanplumInternal.addStartIssuedHandler(new Runnable() {
+        @Override
+        public void run() {
+          try {
+            resumeInternal();
+          } catch (Throwable t) {
+            Util.handleException(t);
+          }
+        }
+      });
+    }
+  }
+
+  private static void resumeInternal() {
+    Request request = Request.post(Constants.Methods.RESUME_SESSION, null);
+    if (LeanplumInternal.hasStartedInBackground()) {
+      LeanplumInternal.setStartedInBackground(false);
+      request.sendIfConnected();
+    } else {
+      request.sendIfDelayed();
+      LeanplumInternal.maybePerformActions("resume", null,
+          LeanplumMessageMatchFilter.LEANPLUM_ACTION_FILTER_ALL, null, null);
+    }
+    resumeHeartbeat();
+  }
+
+  /**
+   * Send a heartbeat every 15 minutes while the app is running.
+   */
+  private static void startHeartbeat() {
+    synchronized (heartbeatLock) {
+      heartbeatExecutor = Executors.newSingleThreadScheduledExecutor();
+      heartbeatExecutor.scheduleAtFixedRate(new Runnable() {
+        public void run() {
+          try {
+            Request.post(Constants.Methods.HEARTBEAT, null).sendIfDelayed();
+          } catch (Throwable t) {
+            Util.handleException(t);
+          }
+        }
+      }, 15, 15, TimeUnit.MINUTES);
+    }
+  }
+
+  private static void pauseHeartbeat() {
+    synchronized (heartbeatLock) {
+      if (heartbeatExecutor != null) {
+        heartbeatExecutor.shutdown();
+      }
+    }
+  }
+
+  private static void resumeHeartbeat() {
+    startHeartbeat();
+  }
+
+  /**
+   * Call this to explicitly end the session. This should not be used in most cases, so we won't
+   * make it public for now.
+   */
+  static void stop() {
+    if (Constants.isNoop()) {
+      return;
+    }
+    if (!LeanplumInternal.hasCalledStart()) {
+      Log.e("You cannot call stop before calling start");
+      return;
+    }
+
+    if (LeanplumInternal.issuedStart()) {
+      stopInternal();
+    } else {
+      LeanplumInternal.addStartIssuedHandler(new Runnable() {
+        @Override
+        public void run() {
+          try {
+            stopInternal();
+          } catch (Throwable t) {
+            Util.handleException(t);
+          }
+        }
+      });
+    }
+  }
+
+  private static void stopInternal() {
+    Request.post(Constants.Methods.STOP, null).sendIfConnected();
+  }
+
+  /**
+   * Whether or not Leanplum has finished starting.
+   */
+  public static boolean hasStarted() {
+    return LeanplumInternal.hasStarted();
+  }
+
+  /**
+   * Returns an instance to the singleton Newsfeed object.
+   *
+   * @deprecated use {@link #getInbox} instead
+   */
+  public static Newsfeed newsfeed() {
+    return Newsfeed.getInstance();
+  }
+
+  /**
+   * Returns an instance to the singleton LeanplumInbox object.
+   */
+  public static LeanplumInbox getInbox() {
+    return LeanplumInbox.getInstance();
+  }
+
+  /**
+   * Whether or not Leanplum has finished starting and the device is registered as a developer.
+   */
+  public static boolean hasStartedAndRegisteredAsDeveloper() {
+    return LeanplumInternal.hasStartedAndRegisteredAsDeveloper();
+  }
+
+  /**
+   * Add a callback for when the start call finishes, and variables are returned back from the
+   * server.
+   */
+  public static void addStartResponseHandler(StartCallback handler) {
+    if (handler == null) {
+      Log.e("addStartResponseHandler - Invalid handler parameter provided.");
+      return;
+    }
+
+    if (LeanplumInternal.hasStarted()) {
+      if (LeanplumInternal.isStartSuccessful()) {
+        handler.setSuccess(true);
+      }
+      handler.run();
+    } else {
+      synchronized (startHandlers) {
+        if (startHandlers.indexOf(handler) == -1) {
+          startHandlers.add(handler);
+        }
+      }
+    }
+  }
+
+  /**
+   * Removes a start response callback.
+   */
+  public static void removeStartResponseHandler(StartCallback handler) {
+    if (handler == null) {
+      Log.e("removeStartResponseHandler - Invalid handler parameter provided.");
+      return;
+    }
+
+    synchronized (startHandlers) {
+      startHandlers.remove(handler);
+    }
+  }
+
+  private static void triggerStartResponse(boolean success) {
+    synchronized (startHandlers) {
+      for (StartCallback callback : startHandlers) {
+        callback.setSuccess(success);
+        OsHandler.getInstance().post(callback);
+      }
+      startHandlers.clear();
+    }
+  }
+
+  /**
+   * Add a callback for when the variables receive new values from the server. This will be called
+   * on start, and also later on if the user is in an experiment that can updated in realtime.
+   */
+  public static void addVariablesChangedHandler(VariablesChangedCallback handler) {
+    if (handler == null) {
+      Log.e("addVariablesChangedHandler - Invalid handler parameter provided.");
+      return;
+    }
+
+    synchronized (variablesChangedHandlers) {
+      variablesChangedHandlers.add(handler);
+    }
+    if (VarCache.hasReceivedDiffs()) {
+      handler.variablesChanged();
+    }
+  }
+
+  /**
+   * Removes a variables changed callback.
+   */
+  public static void removeVariablesChangedHandler(VariablesChangedCallback handler) {
+    if (handler == null) {
+      Log.e("removeVariablesChangedHandler - Invalid handler parameter provided.");
+      return;
+    }
+
+    synchronized (variablesChangedHandlers) {
+      variablesChangedHandlers.remove(handler);
+    }
+  }
+
+  private static void triggerVariablesChanged() {
+    synchronized (variablesChangedHandlers) {
+      for (VariablesChangedCallback callback : variablesChangedHandlers) {
+        OsHandler.getInstance().post(callback);
+      }
+    }
+  }
+
+  /**
+   * Add a callback for when no more file downloads are pending (either when no files needed to be
+   * downloaded or all downloads have been completed).
+   */
+  public static void addVariablesChangedAndNoDownloadsPendingHandler(
+      VariablesChangedCallback handler) {
+    if (handler == null) {
+      Log.e("addVariablesChangedAndNoDownloadsPendingHandler - Invalid handler parameter " +
+          "provided.");
+      return;
+    }
+
+    synchronized (noDownloadsHandlers) {
+      noDownloadsHandlers.add(handler);
+    }
+    if (VarCache.hasReceivedDiffs()
+        && Request.numPendingDownloads() == 0) {
+      handler.variablesChanged();
+    }
+  }
+
+  /**
+   * Removes a variables changed and no downloads pending callback.
+   */
+  public static void removeVariablesChangedAndNoDownloadsPendingHandler(
+      VariablesChangedCallback handler) {
+    if (handler == null) {
+      Log.e("removeVariablesChangedAndNoDownloadsPendingHandler - Invalid handler parameter " +
+          "provided.");
+      return;
+    }
+
+    synchronized (noDownloadsHandlers) {
+      noDownloadsHandlers.remove(handler);
+    }
+  }
+
+  /**
+   * Add a callback to call ONCE when no more file downloads are pending (either when no files
+   * needed to be downloaded or all downloads have been completed).
+   */
+  public static void addOnceVariablesChangedAndNoDownloadsPendingHandler(
+      VariablesChangedCallback handler) {
+    if (handler == null) {
+      Log.e("addOnceVariablesChangedAndNoDownloadsPendingHandler - Invalid handler parameter" +
+          " provided.");
+      return;
+    }
+
+    if (VarCache.hasReceivedDiffs()
+        && Request.numPendingDownloads() == 0) {
+      handler.variablesChanged();
+    } else {
+      synchronized (onceNoDownloadsHandlers) {
+        onceNoDownloadsHandlers.add(handler);
+      }
+    }
+  }
+
+  /**
+   * Removes a once variables changed and no downloads pending callback.
+   */
+  public static void removeOnceVariablesChangedAndNoDownloadsPendingHandler(
+      VariablesChangedCallback handler) {
+    if (handler == null) {
+      Log.e("removeOnceVariablesChangedAndNoDownloadsPendingHandler - Invalid handler" +
+          " parameter provided.");
+      return;
+    }
+
+    synchronized (onceNoDownloadsHandlers) {
+      onceNoDownloadsHandlers.remove(handler);
+    }
+  }
+
+  static void triggerVariablesChangedAndNoDownloadsPending() {
+    synchronized (noDownloadsHandlers) {
+      for (VariablesChangedCallback callback : noDownloadsHandlers) {
+        OsHandler.getInstance().post(callback);
+      }
+    }
+    synchronized (onceNoDownloadsHandlers) {
+      for (VariablesChangedCallback callback : onceNoDownloadsHandlers) {
+        OsHandler.getInstance().post(callback);
+      }
+      onceNoDownloadsHandlers.clear();
+    }
+  }
+
+  /**
+   * Defines an action that is used within Leanplum Marketing Automation. Actions can be set up to
+   * get triggered based on app opens, events, and states. Call {@link Leanplum#onAction} to handle
+   * the action.
+   *
+   * @param name The name of the action to register.
+   * @param kind Whether to display the action as a message and/or a regular action.
+   * @param args User-customizable options for the action.
+   */
+  public static void defineAction(String name, int kind, ActionArgs args) {
+    defineAction(name, kind, args, null, null);
+  }
+
+  @Deprecated
+  static void defineAction(String name, int kind, ActionArgs args,
+      Map<String, Object> options) {
+    defineAction(name, kind, args, options, null);
+  }
+
+  /**
+   * Defines an action that is used within Leanplum Marketing Automation. Actions can be set up to
+   * get triggered based on app opens, events, and states.
+   *
+   * @param name The name of the action to register.
+   * @param kind Whether to display the action as a message and/or a regular action.
+   * @param args User-customizable options for the action.
+   * @param responder Called when the action is triggered with a context object containing the
+   * user-specified options.
+   */
+  public static void defineAction(String name, int kind, ActionArgs args,
+      ActionCallback responder) {
+    defineAction(name, kind, args, null, responder);
+  }
+
+  private static void defineAction(String name, int kind, ActionArgs args,
+      Map<String, Object> options, ActionCallback responder) {
+    if (TextUtils.isEmpty(name)) {
+      Log.e("defineAction - Empty name parameter provided.");
+      return;
+    }
+    if (args == null) {
+      Log.e("defineAction - Invalid args parameter provided.");
+      return;
+    }
+
+    try {
+      Context context = Leanplum.getContext();
+      if (!initializedMessageTemplates) {
+        initializedMessageTemplates = true;
+        MessageTemplates.register(context);
+      }
+
+      if (options == null) {
+        options = new HashMap<>();
+      }
+      LeanplumInternal.getActionHandlers().remove(name);
+      VarCache.registerActionDefinition(name, kind, args.getValue(), options);
+      if (responder != null) {
+        onAction(name, responder);
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * Adds a callback that handles an action with the given name.
+   *
+   * @param actionName The name of the type of action to handle.
+   * @param handler The callback that runs when the action is triggered.
+   */
+  public static void onAction(String actionName, ActionCallback handler) {
+    if (actionName == null) {
+      Log.e("onAction - Invalid actionName parameter provided.");
+      return;
+    }
+    if (handler == null) {
+      Log.e("onAction - Invalid handler parameter provided.");
+      return;
+    }
+
+    List<ActionCallback> handlers = LeanplumInternal.getActionHandlers().get(actionName);
+    if (handlers == null) {
+      handlers = new ArrayList<>();
+      LeanplumInternal.getActionHandlers().put(actionName, handlers);
+    }
+    handlers.add(handler);
+  }
+
+  /**
+   * Updates the user ID and adds or modifies user attributes.
+   */
+  public static void setUserAttributes(final String userId, Map<String, ?> userAttributes) {
+    if (Constants.isNoop()) {
+      return;
+    }
+    if (!LeanplumInternal.hasCalledStart()) {
+      Log.e("You cannot call setUserAttributes before calling start");
+      return;
+    }
+    try {
+      final HashMap<String, Object> params = new HashMap<>();
+      if (userId != null) {
+        params.put(Constants.Params.NEW_USER_ID, userId);
+      }
+      if (userAttributes != null) {
+        userAttributes = LeanplumInternal.validateAttributes(userAttributes, "userAttributes",
+            true);
+        params.put(Constants.Params.USER_ATTRIBUTES, JsonConverter.toJson(userAttributes));
+        LeanplumInternal.getUserAttributeChanges().add(userAttributes);
+      }
+
+      if (LeanplumInternal.issuedStart()) {
+        setUserAttributesInternal(userId, params);
+      } else {
+        LeanplumInternal.addStartIssuedHandler(new Runnable() {
+          @Override
+          public void run() {
+            try {
+              setUserAttributesInternal(userId, params);
+            } catch (Throwable t) {
+              Util.handleException(t);
+            }
+          }
+        });
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  private static void setUserAttributesInternal(String userId,
+      HashMap<String, Object> requestArgs) {
+    Request.post(Constants.Methods.SET_USER_ATTRIBUTES, requestArgs).send();
+    if (userId != null && userId.length() > 0) {
+      Request.setUserId(userId);
+      if (LeanplumInternal.hasStarted()) {
+        VarCache.saveDiffs();
+      }
+    }
+    LeanplumInternal.recordAttributeChanges();
+  }
+
+  /**
+   * Updates the user ID.
+   */
+  public static void setUserId(String userId) {
+    if (userId == null) {
+      Log.e("setUserId - Invalid userId parameter provided.");
+      return;
+    }
+
+    setUserAttributes(userId, null);
+  }
+
+  /**
+   * Adds or modifies user attributes.
+   */
+  public static void setUserAttributes(Map<String, Object> userAttributes) {
+    if (userAttributes == null || userAttributes.isEmpty()) {
+      Log.e("setUserAttributes - Invalid userAttributes parameter provided (null or empty).");
+      return;
+    }
+
+    setUserAttributes(null, userAttributes);
+  }
+
+  /**
+   * Sets the registration ID used for Cloud Messaging.
+   */
+  static void setRegistrationId(final String registrationId) {
+    if (Constants.isNoop()) {
+      return;
+    }
+    pushStartCallback = new Runnable() {
+      @Override
+      public void run() {
+        if (Constants.isNoop()) {
+          return;
+        }
+        try {
+          HashMap<String, Object> params = new HashMap<>();
+          params.put(Constants.Params.DEVICE_PUSH_TOKEN, registrationId);
+          Request.post(Constants.Methods.SET_DEVICE_ATTRIBUTES, params).send();
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+      }
+    };
+    LeanplumInternal.addStartIssuedHandler(pushStartCallback);
+  }
+
+  /**
+   * Sets the traffic source info for the current user. Keys in info must be one of: publisherId,
+   * publisherName, publisherSubPublisher, publisherSubSite, publisherSubCampaign,
+   * publisherSubAdGroup, publisherSubAd.
+   */
+  public static void setTrafficSourceInfo(Map<String, String> info) {
+    if (Constants.isNoop()) {
+      return;
+    }
+    if (!LeanplumInternal.hasCalledStart()) {
+      Log.e("You cannot call setTrafficSourceInfo before calling start");
+      return;
+    }
+    if (info == null || info.isEmpty()) {
+      Log.e("setTrafficSourceInfo - Invalid info parameter provided (null or empty).");
+      return;
+    }
+
+    try {
+      final HashMap<String, Object> params = new HashMap<>();
+      info = LeanplumInternal.validateAttributes(info, "info", false);
+      params.put(Constants.Params.TRAFFIC_SOURCE, JsonConverter.toJson(info));
+      if (LeanplumInternal.issuedStart()) {
+        setTrafficSourceInfoInternal(params);
+      } else {
+        LeanplumInternal.addStartIssuedHandler(new Runnable() {
+          @Override
+          public void run() {
+            try {
+              setTrafficSourceInfoInternal(params);
+            } catch (Throwable t) {
+              Util.handleException(t);
+            }
+          }
+        });
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  private static void setTrafficSourceInfoInternal(HashMap<String, Object> params) {
+    Request.post(Constants.Methods.SET_TRAFFIC_SOURCE_INFO, params).send();
+  }
+
+  /**
+   * Logs a particular event in your application. The string can be any value of your choosing, and
+   * will show up in the dashboard.
+   * <p>
+   * <p>To track Purchase events, call {@link Leanplum#trackGooglePlayPurchase} instead for in-app
+   * purchases, or use {@link Leanplum#PURCHASE_EVENT_NAME} as the event name for other types of
+   * purchases.
+   *
+   * @param event Name of the event. Event may be empty for message impression events.
+   * @param value The value of the event. The value is special in that you can use it for targeting
+   * content and messages to users who have a particular lifetime value. For purchase events, the
+   * value is the revenue associated with the purchase.
+   * @param info Basic context associated with the event, such as the item purchased. info is
+   * treated like a default parameter.
+   * @param params Key-value pairs with metrics or data associated with the event. Parameters can be
+   * strings or numbers. You can use up to 200 different parameter names in your app.
+   */
+  public static void track(final String event, double value, String info,
+      Map<String, ?> params) {
+    LeanplumInternal.track(event, value, info, params, null);
+  }
+
+  /**
+   * Tracks an in-app purchase as a Purchase event.
+   *
+   * @param item The name of the item that was purchased.
+   * @param priceMicros The price in micros in the user's local currency.
+   * @param currencyCode The currency code corresponding to the price.
+   * @param purchaseData Purchase data from purchase.getOriginalJson().
+   * @param dataSignature Signature from purchase.getSignature().
+   */
+  public static void trackGooglePlayPurchase(String item, long priceMicros, String currencyCode,
+      String purchaseData, String dataSignature) {
+    trackGooglePlayPurchase(PURCHASE_EVENT_NAME, item, priceMicros, currencyCode, purchaseData,
+        dataSignature, null);
+  }
+
+  /**
+   * Tracks an in-app purchase as a Purchase event.
+   *
+   * @param item The name of the item that was purchased.
+   * @param priceMicros The price in micros in the user's local currency.
+   * @param currencyCode The currency code corresponding to the price.
+   * @param purchaseData Purchase data from purchase.getOriginalJson().
+   * @param dataSignature Signature from purchase.getSignature().
+   * @param params Any additional parameters to track with the event.
+   */
+  public static void trackGooglePlayPurchase(String item, long priceMicros, String currencyCode,
+      String purchaseData, String dataSignature, Map<String, ?> params) {
+    trackGooglePlayPurchase(PURCHASE_EVENT_NAME, item, priceMicros, currencyCode,
+        purchaseData, dataSignature, params);
+  }
+
+  /**
+   * Tracks an in-app purchase.
+   *
+   * @param eventName The name of the event to record the purchase under. Normally, this would be
+   * {@link Leanplum#PURCHASE_EVENT_NAME}.
+   * @param item The name of the item that was purchased.
+   * @param priceMicros The price in micros in the user's local currency.
+   * @param currencyCode The currency code corresponding to the price.
+   * @param purchaseData Purchase data from purchase.getOriginalJson().
+   * @param dataSignature Signature from purchase.getSignature().
+   * @param params Any additional parameters to track with the event.
+   */
+  @SuppressWarnings("SameParameterValue")
+  public static void trackGooglePlayPurchase(String eventName, String item, long priceMicros,
+      String currencyCode, String purchaseData, String dataSignature, Map<String, ?> params) {
+    if (TextUtils.isEmpty(eventName)) {
+      Log.w("trackGooglePlayPurchase - Empty eventName parameter provided.");
+    }
+
+    final Map<String, String> requestArgs = new HashMap<>();
+    requestArgs.put(Constants.Params.GOOGLE_PLAY_PURCHASE_DATA, purchaseData);
+    requestArgs.put(Constants.Params.GOOGLE_PLAY_PURCHASE_DATA_SIGNATURE, dataSignature);
+    requestArgs.put(Constants.Params.IAP_CURRENCY_CODE, currencyCode);
+
+    Map<String, Object> modifiedParams;
+    if (params == null) {
+      modifiedParams = new HashMap<>();
+    } else {
+      modifiedParams = new HashMap<>(params);
+    }
+    modifiedParams.put(Constants.Params.IAP_ITEM, item);
+
+    LeanplumInternal.track(eventName, priceMicros / 1000000.0, null, modifiedParams, requestArgs);
+  }
+
+  /**
+   * Logs a particular event in your application. The string can be any value of your choosing, and
+   * will show up in the dashboard.
+   * <p>
+   * <p>To track Purchase events, use {@link Leanplum#PURCHASE_EVENT_NAME}.
+   *
+   * @param event Name of the event.
+   */
+  public static void track(String event) {
+    track(event, 0.0, "", null);
+  }
+
+  /**
+   * Logs a particular event in your application. The string can be any value of your choosing, and
+   * will show up in the dashboard.
+   * <p>
+   * <p>To track Purchase events, use {@link Leanplum#PURCHASE_EVENT_NAME}.
+   *
+   * @param event Name of the event.
+   * @param value The value of the event. The value is special in that you can use it for targeting
+   * content and messages to users who have a particular lifetime value. For purchase events, the
+   * value is the revenue associated with the purchase.
+   */
+  public static void track(String event, double value) {
+    track(event, value, "", null);
+  }
+
+  /**
+   * Logs a particular event in your application. The string can be any value of your choosing, and
+   * will show up in the dashboard.
+   * <p>
+   * <p>To track Purchase events, use {@link Leanplum#PURCHASE_EVENT_NAME}.
+   *
+   * @param event Name of the event.
+   * @param info Basic context associated with the event, such as the item purchased. info is
+   * treated like a default parameter.
+   */
+  public static void track(String event, String info) {
+    track(event, 0.0, info, null);
+  }
+
+  /**
+   * Logs a particular event in your application. The string can be any value of your choosing, and
+   * will show up in the dashboard.
+   * <p>
+   * <p>To track Purchase events, use {@link Leanplum#PURCHASE_EVENT_NAME}.
+   *
+   * @param event Name of the event.
+   * @param params Key-value pairs with metrics or data associated with the event. Parameters can be
+   * strings or numbers. You can use up to 200 different parameter names in your app.
+   */
+  public static void track(String event, Map<String, ?> params) {
+    track(event, 0.0, "", params);
+  }
+
+  /**
+   * Logs a particular event in your application. The string can be any value of your choosing, and
+   * will show up in the dashboard.
+   * <p>
+   * <p>To track Purchase events, use {@link Leanplum#PURCHASE_EVENT_NAME}.
+   *
+   * @param event Name of the event.
+   * @param value The value of the event. The value is special in that you can use it for targeting
+   * content and messages to users who have a particular lifetime value. For purchase events, the
+   * value is the revenue associated with the purchase.
+   * @param params Key-value pairs with metrics or data associated with the event. Parameters can be
+   * strings or numbers. You can use up to 200 different parameter names in your app.
+   */
+  public static void track(String event, double value, Map<String, ?> params) {
+    track(event, value, "", params);
+  }
+
+  /**
+   * Logs a particular event in your application. The string can be any value of your choosing, and
+   * will show up in the dashboard.
+   * <p>
+   * <p>To track Purchase events, use {@link Leanplum#PURCHASE_EVENT_NAME}.
+   *
+   * @param event Name of the event.
+   * @param value The value of the event. The value is special in that you can use it for targeting
+   * content and messages to users who have a particular lifetime value. For purchase events, the
+   * value is the revenue associated with the purchase.
+   * @param info Basic context associated with the event, such as the item purchased. info is
+   * treated like a default parameter.
+   */
+  public static void track(String event, double value, String info) {
+    track(event, value, info, null);
+  }
+
+  /**
+   * Advances to a particular state in your application. The string can be any value of your
+   * choosing, and will show up in the dashboard. A state is a section of your app that the user is
+   * currently in.
+   *
+   * @param state Name of the state. State may be empty for message impression events.
+   * @param info Basic context associated with the state, such as the item purchased. info is
+   * treated like a default parameter.
+   * @param params Key-value pairs with metrics or data associated with the state. Parameters can be
+   * strings or numbers. You can use up to 200 different parameter names in your app.
+   */
+  public static void advanceTo(final String state, String info, final Map<String, ?> params) {
+    if (Constants.isNoop()) {
+      return;
+    }
+    if (!LeanplumInternal.hasCalledStart()) {
+      Log.e("You cannot call advanceTo before calling start");
+      return;
+    }
+
+    try {
+      final Map<String, Object> requestParams = new HashMap<>();
+      requestParams.put(Constants.Params.INFO, info);
+      requestParams.put(Constants.Params.STATE, state);
+      final Map<String, ?> validatedParams;
+      if (params != null) {
+        validatedParams = LeanplumInternal.validateAttributes(params, "params", false);
+        requestParams.put(Constants.Params.PARAMS, JsonConverter.toJson(validatedParams));
+      } else {
+        validatedParams = null;
+      }
+
+      if (LeanplumInternal.issuedStart()) {
+        advanceToInternal(state, validatedParams, requestParams);
+      } else {
+        LeanplumInternal.addStartIssuedHandler(new Runnable() {
+          @Override
+          public void run() {
+            try {
+              advanceToInternal(state, validatedParams, requestParams);
+            } catch (Throwable t) {
+              Util.handleException(t);
+            }
+          }
+        });
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * Performs the advance API and any actions that are associated with the state.
+   *
+   * @param state The state name. State may be empty for message impression events.
+   * @param params The state parameters.
+   * @param requestParams The arguments to send with the API request.
+   */
+  private static void advanceToInternal(String state, Map<String, ?> params,
+      Map<String, Object> requestParams) {
+    Request.post(Constants.Methods.ADVANCE, requestParams).send();
+
+    ContextualValues contextualValues = new ContextualValues();
+    contextualValues.parameters = params;
+
+    LeanplumInternal.maybePerformActions("state", state,
+        LeanplumMessageMatchFilter.LEANPLUM_ACTION_FILTER_ALL, null, contextualValues);
+  }
+
+  /**
+   * Advances to a particular state in your application. The string can be any value of your
+   * choosing, and will show up in the dashboard. A state is a section of your app that the user is
+   * currently in.
+   *
+   * @param state Name of the state. State may be empty for message impression events.
+   */
+  public static void advanceTo(String state) {
+    advanceTo(state, "", null);
+  }
+
+  /**
+   * Advances to a particular state in your application. The string can be any value of your
+   * choosing, and will show up in the dashboard. A state is a section of your app that the user is
+   * currently in.
+   *
+   * @param state Name of the state. State may be empty for message impression events.
+   * @param info Basic context associated with the state, such as the item purchased. info is
+   * treated like a default parameter.
+   */
+  public static void advanceTo(String state, String info) {
+    advanceTo(state, info, null);
+  }
+
+  /**
+   * Advances to a particular state in your application. The string can be any value of your
+   * choosing, and will show up in the dashboard. A state is a section of your app that the user is
+   * currently in.
+   *
+   * @param state Name of the state. State may be empty for message impression events.
+   * @param params Key-value pairs with metrics or data associated with the state. Parameters can be
+   * strings or numbers. You can use up to 200 different parameter names in your app.
+   */
+  public static void advanceTo(String state, Map<String, ?> params) {
+    advanceTo(state, "", params);
+  }
+
+  /**
+   * Pauses the current state. You can use this if your game has a "pause" mode. You shouldn't call
+   * it when someone switches out of your app because that's done automatically.
+   */
+  public static void pauseState() {
+    if (Constants.isNoop()) {
+      return;
+    }
+    if (!LeanplumInternal.hasCalledStart()) {
+      Log.e("You cannot call pauseState before calling start");
+      return;
+    }
+
+    try {
+      if (LeanplumInternal.issuedStart()) {
+        pauseStateInternal();
+      } else {
+        LeanplumInternal.addStartIssuedHandler(new Runnable() {
+          @Override
+          public void run() {
+            try {
+              pauseStateInternal();
+            } catch (Throwable t) {
+              Util.handleException(t);
+            }
+          }
+        });
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  private static void pauseStateInternal() {
+    Request.post(Constants.Methods.PAUSE_STATE, new HashMap<String, Object>()).send();
+  }
+
+  /**
+   * Resumes the current state.
+   */
+  public static void resumeState() {
+    if (Constants.isNoop()) {
+      return;
+    }
+    if (!LeanplumInternal.hasCalledStart()) {
+      Log.e("You cannot call resumeState before calling start");
+      return;
+    }
+
+    try {
+      if (LeanplumInternal.issuedStart()) {
+        resumeStateInternal();
+      } else {
+        LeanplumInternal.addStartIssuedHandler(new Runnable() {
+          @Override
+          public void run() {
+            try {
+              resumeStateInternal();
+            } catch (Throwable t) {
+              Util.handleException(t);
+            }
+          }
+        });
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  private static void resumeStateInternal() {
+    Request.post(Constants.Methods.RESUME_STATE, new HashMap<String, Object>()).send();
+  }
+
+  /**
+   * Forces content to update from the server. If variables have changed, the appropriate callbacks
+   * will fire. Use sparingly as if the app is updated, you'll have to deal with potentially
+   * inconsistent state or user experience.
+   */
+  public static void forceContentUpdate() {
+    forceContentUpdate(null);
+  }
+
+  /**
+   * Forces content to update from the server. If variables have changed, the appropriate callbacks
+   * will fire. Use sparingly as if the app is updated, you'll have to deal with potentially
+   * inconsistent state or user experience.
+   *
+   * @param callback The callback to invoke when the call completes from the server. The callback
+   * will fire regardless of whether the variables have changed.
+   */
+  @SuppressWarnings("SameParameterValue")
+  public static void forceContentUpdate(final VariablesChangedCallback callback) {
+    if (Constants.isNoop()) {
+      if (callback != null) {
+        OsHandler.getInstance().post(callback);
+      }
+      return;
+    }
+    try {
+      Map<String, Object> params = new HashMap<>();
+      params.put(Constants.Params.INCLUDE_DEFAULTS, Boolean.toString(false));
+      params.put(Constants.Params.INBOX_MESSAGES, LeanplumInbox.getInstance().messagesIds());
+      Request req = Request.post(Constants.Methods.GET_VARS, params);
+      req.onResponse(new Request.ResponseCallback() {
+        @Override
+        public void response(JSONObject response) {
+          try {
+            JSONObject lastResponse = Request.getLastResponse(response);
+            if (lastResponse == null) {
+              Log.e("No response received from the server. Please contact us to investigate.");
+            } else {
+              applyContentInResponse(lastResponse, false);
+              if (lastResponse.optBoolean(Constants.Keys.SYNC_INBOX, false)) {
+                LeanplumInbox.getInstance().downloadMessages();
+              }
+              if (lastResponse.optBoolean(Constants.Keys.LOGGING_ENABLED, false)) {
+                Constants.loggingEnabled = true;
+              }
+            }
+            if (callback != null) {
+              OsHandler.getInstance().post(callback);
+            }
+          } catch (Throwable t) {
+            Util.handleException(t);
+          }
+        }
+      });
+      req.onError(
+          new Request.ErrorCallback() {
+            @Override
+            public void error(Exception e) {
+              if (callback != null) {
+                OsHandler.getInstance().post(callback);
+              }
+            }
+          });
+      req.sendIfConnected();
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * This should be your first statement in a unit test. This prevents Leanplum from communicating
+   * with the server.
+   */
+  public static void enableTestMode() {
+    Constants.isTestMode = true;
+  }
+
+  public static boolean isTestModeEnabled() {
+    return Constants.isTestMode;
+  }
+
+  /**
+   * This should be your first statement in a unit test. This prevents Leanplum from communicating
+   * with the server.
+   */
+  public static void setIsTestModeEnabled(boolean isTestModeEnabled) {
+    Constants.isTestMode = isTestModeEnabled;
+  }
+
+  /**
+   * Gets the path for a particular resource. The resource can be overridden by the server.
+   */
+  public static String pathForResource(String filename) {
+    if (TextUtils.isEmpty(filename)) {
+      Log.e("pathForResource - Empty filename parameter provided.");
+      return null;
+    }
+
+    Var fileVar = Var.defineFile(filename, filename);
+    return (fileVar != null) ? fileVar.fileValue() : null;
+  }
+
+  /**
+   * Traverses the variable structure with the specified path. Path components can be either strings
+   * representing keys in a dictionary, or integers representing indices in a list.
+   */
+  public static Object objectForKeyPath(Object... components) {
+    return objectForKeyPathComponents(components);
+  }
+
+  /**
+   * Traverses the variable structure with the specified path. Path components can be either strings
+   * representing keys in a dictionary, or integers representing indices in a list.
+   */
+  public static Object objectForKeyPathComponents(Object[] pathComponents) {
+    try {
+      return VarCache.getMergedValueFromComponentArray(pathComponents);
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+    return null;
+  }
+
+  /**
+   * Returns information about the active variants for the current user. Each variant will contain
+   * an "id" key mapping to the numeric ID of the variant.
+   */
+  public static List<Map<String, Object>> variants() {
+    List<Map<String, Object>> variants = VarCache.variants();
+    if (variants == null) {
+      return new ArrayList<>();
+    }
+    return variants;
+  }
+
+  /**
+   * Returns metadata for all active in-app messages. Recommended only for debugging purposes and
+   * advanced use cases.
+   */
+  public static Map<String, Object> messageMetadata() {
+    Map<String, Object> messages = VarCache.messages();
+    if (messages == null) {
+      return new HashMap<>();
+    }
+    return messages;
+  }
+
+  /**
+   * Set location manually. Calls setDeviceLocation with cell type. Best if used in after calling
+   * disableLocationCollection.
+   *
+   * @param location Device location.
+   */
+  public static void setDeviceLocation(Location location) {
+    setDeviceLocation(location, LeanplumLocationAccuracyType.CELL);
+  }
+
+  /**
+   * Set location manually. Best if used in after calling disableLocationCollection. Useful if you
+   * want to apply additional logic before sending in the location.
+   *
+   * @param location Device location.
+   * @param type LeanplumLocationAccuracyType of the location.
+   */
+  public static void setDeviceLocation(Location location, LeanplumLocationAccuracyType type) {
+    if (locationCollectionEnabled) {
+      Log.w("Leanplum is automatically collecting device location, so there is no need to " +
+          "call setDeviceLocation. If you prefer to always set location manually, " +
+          "then call disableLocationCollection.");
+    }
+    LeanplumInternal.setUserLocationAttribute(location, type,
+        new LeanplumInternal.locationAttributeRequestsCallback() {
+          @Override
+          public void response(boolean success) {
+            if (success) {
+              Log.d("setUserAttributes with location is successfully called");
+            }
+          }
+        });
+  }
+
+  /**
+   * Disable location collection by setting |locationCollectionEnabled| to false.
+   */
+  public static void disableLocationCollection() {
+    locationCollectionEnabled = false;
+  }
+
+  /**
+   * Returns whether a customer enabled location collection.
+   *
+   * @return The value of |locationCollectionEnabled|.
+   */
+  public static boolean isLocationCollectionEnabled() {
+    return locationCollectionEnabled;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumActivityHelper.java
@@ -0,0 +1,344 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.app.Activity;
+import android.app.Application;
+import android.app.Application.ActivityLifecycleCallbacks;
+import android.content.res.Resources;
+import android.os.Build;
+import android.os.Bundle;
+
+import com.leanplum.annotations.Parser;
+import com.leanplum.callbacks.PostponableAction;
+import com.leanplum.internal.ActionManager;
+import com.leanplum.internal.LeanplumInternal;
+import com.leanplum.internal.LeanplumUIEditorWrapper;
+import com.leanplum.internal.OsHandler;
+import com.leanplum.internal.Util;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.Queue;
+import java.util.Set;
+
+/**
+ * Utility class for handling activity lifecycle events. Call these methods from your activity if
+ * you don't extend one of the Leanplum*Activity classes.
+ *
+ * @author Andrew First
+ */
+public class LeanplumActivityHelper {
+  /**
+   * Whether any of the activities are paused.
+   */
+  static boolean isActivityPaused;
+  private static Set<Class> ignoredActivityClasses;
+
+  /**
+   * Whether lifecycle callbacks were registered. This is only supported on Android OS &gt;= 4.0.
+   */
+  private static boolean registeredCallbacks;
+
+  static Activity currentActivity;
+
+  private final Activity activity;
+  private LeanplumResources res;
+  private LeanplumInflater inflater;
+
+  private static final Queue<Runnable> pendingActions = new LinkedList<>();
+  private static final Runnable runPendingActionsRunnable = new Runnable() {
+    @Override
+    public void run() {
+      runPendingActions();
+    }
+  };
+
+  public LeanplumActivityHelper(Activity activity) {
+    this.activity = activity;
+    Leanplum.setApplicationContext(activity.getApplicationContext());
+    Parser.parseVariables(activity);
+  }
+
+  /**
+   * Retrieves the currently active activity.
+   */
+  public static Activity getCurrentActivity() {
+    return currentActivity;
+  }
+
+  /**
+   * Retrieves if the activity is paused.
+   */
+  public static boolean isActivityPaused() {
+    return isActivityPaused;
+  }
+
+  /**
+   * Enables lifecycle callbacks for Android devices with Android OS &gt;= 4.0
+   */
+  public static void enableLifecycleCallbacks(final Application app) {
+    Leanplum.setApplicationContext(app.getApplicationContext());
+    if (Build.VERSION.SDK_INT < 14) {
+      return;
+    }
+    app.registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
+      @Override
+      public void onActivityStopped(Activity activity) {
+        try {
+          onStop(activity);
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+      }
+
+      @Override
+      public void onActivityResumed(final Activity activity) {
+        try {
+          if (Leanplum.isInterfaceEditingEnabled()) {
+            // Execute runnable in next frame to ensure that all system stuff is setup, before
+            // applying UI edits.
+            OsHandler.getInstance().post(new Runnable() {
+              @Override
+              public void run() {
+                LeanplumUIEditorWrapper.getInstance().applyInterfaceEdits(activity);
+              }
+            });
+          }
+          onResume(activity);
+          if (Leanplum.isScreenTrackingEnabled()) {
+            Leanplum.advanceTo(activity.getLocalClassName());
+          }
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+      }
+
+      @Override
+      public void onActivityPaused(Activity activity) {
+        try {
+          onPause(activity);
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+      }
+
+      @Override
+      public void onActivityStarted(Activity activity) {
+      }
+
+      @Override
+      public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
+      }
+
+      @Override
+      public void onActivityDestroyed(Activity activity) {
+      }
+
+      @Override
+      public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
+      }
+
+    });
+    registeredCallbacks = true;
+  }
+
+  public LeanplumResources getLeanplumResources() {
+    return getLeanplumResources(null);
+  }
+
+  public LeanplumResources getLeanplumResources(Resources baseResources) {
+    if (res != null) {
+      return res;
+    }
+    if (baseResources == null) {
+      baseResources = activity.getResources();
+    }
+    if (baseResources instanceof LeanplumResources) {
+      return (LeanplumResources) baseResources;
+    }
+    res = new LeanplumResources(baseResources);
+    return res;
+  }
+
+  /**
+   * Sets the view from a layout file.
+   */
+  public void setContentView(final int layoutResID) {
+    if (inflater == null) {
+      inflater = LeanplumInflater.from(activity);
+    }
+    activity.setContentView(inflater.inflate(layoutResID));
+  }
+
+  @SuppressWarnings("unused")
+  private static void onPause(Activity activity) {
+    isActivityPaused = true;
+  }
+
+  /**
+   * Call this when your activity gets paused.
+   */
+  public void onPause() {
+    try {
+      if (!registeredCallbacks) {
+        onPause(activity);
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  private static void onResume(Activity activity) {
+    isActivityPaused = false;
+    currentActivity = activity;
+    if (LeanplumInternal.isPaused() || LeanplumInternal.hasStartedInBackground()) {
+      Leanplum.resume();
+      LocationManager locationManager = ActionManager.getLocationManager();
+      if (locationManager != null) {
+        locationManager.updateGeofencing();
+        locationManager.updateUserLocation();
+      }
+    }
+
+    // Pending actions execution triggered, but Leanplum.start() may not be done yet.
+    LeanplumInternal.addStartIssuedHandler(runPendingActionsRunnable);
+  }
+
+  /**
+   * Call this when your activity gets resumed.
+   */
+  public void onResume() {
+    try {
+      if (!registeredCallbacks) {
+        onResume(activity);
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  private static void onStop(Activity activity) {
+    // onStop is called when the activity gets hidden, and is
+    // called after onPause.
+    //
+    // However, if we're switching to another activity, that activity
+    // will call onResume, so we shouldn't pause if that's the case.
+    //
+    // Thus, we can call pause from here, only if all activities are paused.
+    if (isActivityPaused) {
+      Leanplum.pause();
+      LocationManager locationManager = ActionManager.getLocationManager();
+      if (locationManager != null) {
+        locationManager.updateGeofencing();
+      }
+    }
+    if (currentActivity != null && currentActivity.equals(activity)) {
+      // Don't leak activities.
+      currentActivity = null;
+    }
+  }
+
+  /**
+   * Call this when your activity gets stopped.
+   */
+  public void onStop() {
+    try {
+      if (!registeredCallbacks) {
+        onStop(activity);
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * Enqueues a callback to invoke when an activity reaches in the foreground.
+   */
+  public static void queueActionUponActive(Runnable action) {
+    try {
+      if (currentActivity != null && !currentActivity.isFinishing() && !isActivityPaused &&
+          (!(action instanceof PostponableAction) || !isActivityClassIgnored(currentActivity))) {
+        action.run();
+      } else {
+        synchronized (pendingActions) {
+          pendingActions.add(action);
+        }
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * Runs any pending actions that have been queued.
+   */
+  private static void runPendingActions() {
+    if (isActivityPaused || currentActivity == null) {
+      // Trying to run pending actions, but no activity is resumed. Skip.
+      return;
+    }
+
+    Queue<Runnable> runningActions;
+    synchronized (pendingActions) {
+      runningActions = new LinkedList<>(pendingActions);
+      pendingActions.clear();
+    }
+    for (Runnable action : runningActions) {
+      // If postponable callback and current activity should be skipped, then postpone.
+      if (action instanceof PostponableAction && isActivityClassIgnored(currentActivity)) {
+        pendingActions.add(action);
+      } else {
+        action.run();
+      }
+    }
+  }
+
+  /**
+   * Whether or not an activity is configured to not show messages.
+   *
+   * @param activity The activity to check.
+   * @return Whether or not the activity is ignored.
+   */
+  private static boolean isActivityClassIgnored(Activity activity) {
+    return ignoredActivityClasses != null && ignoredActivityClasses.contains(activity.getClass());
+  }
+
+  /**
+   * Does not show messages for the provided activity classes.
+   *
+   * @param activityClasses The activity classes to not show messages on.
+   */
+  public static void deferMessagesForActivities(Class... activityClasses) {
+    // Check if valid arguments are provided.
+    if (activityClasses == null || activityClasses.length == 0) {
+      return;
+    }
+    // Lazy instantiate activityClasses set.
+    if (ignoredActivityClasses == null) {
+      ignoredActivityClasses = new HashSet<>(activityClasses.length);
+    }
+    // Add all class names to set.
+    Collections.addAll(ignoredActivityClasses, activityClasses);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumApplication.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.annotation.SuppressLint;
+import android.app.Application;
+import android.content.Context;
+import android.content.res.Resources;
+
+import com.leanplum.annotations.Parser;
+import com.leanplum.internal.Constants;
+
+/**
+ * Base class for your Application that handles lifecycle events.
+ *
+ * @author Andrew First
+ */
+@SuppressLint("Registered")
+public class LeanplumApplication extends Application {
+  private static LeanplumApplication instance;
+
+  public static LeanplumApplication getInstance() {
+    return instance;
+  }
+
+  public static Context getContext() {
+    return instance;
+  }
+
+  @Override
+  public void onCreate() {
+    super.onCreate();
+    instance = this;
+    LeanplumActivityHelper.enableLifecycleCallbacks(this);
+    Parser.parseVariables(this);
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Constants.isNoop() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return new LeanplumResources(super.getResources());
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumCloudMessagingProvider.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.content.Context;
+
+import com.leanplum.internal.Constants;
+import com.leanplum.internal.Log;
+import com.leanplum.utils.SharedPreferencesUtil;
+
+/**
+ * Leanplum Cloud Messaging provider.
+ *
+ * @author Anna Orlova
+ */
+abstract class LeanplumCloudMessagingProvider {
+  static final String PUSH_REGISTRATION_SERVICE = "com.leanplum.LeanplumPushRegistrationService";
+  static final String PUSH_RECEIVER = "com.leanplum.LeanplumPushReceiver";
+
+  private static String registrationId;
+
+  /**
+   * Registration app for Cloud Messaging.
+   *
+   * @return String - registration id for app.
+   */
+  public abstract String getRegistrationId();
+
+  /**
+   * Verifies that Android Manifest is set up correctly.
+   *
+   * @return true If Android Manifest is set up correctly.
+   */
+  public abstract boolean isManifestSetUp();
+
+  public abstract boolean isInitialized();
+
+  /**
+   * Unregister from cloud messaging.
+   */
+  public abstract void unregister();
+
+  static String getCurrentRegistrationId() {
+    return registrationId;
+  }
+
+  void onRegistrationIdReceived(Context context, String registrationId) {
+    if (registrationId == null) {
+      Log.w("Registration ID is undefined.");
+      return;
+    }
+    LeanplumCloudMessagingProvider.registrationId = registrationId;
+    // Check if received push notification token is different from stored one and send new one to
+    // server.
+    if (!LeanplumCloudMessagingProvider.registrationId.equals(SharedPreferencesUtil.getString(
+        context, Constants.Defaults.LEANPLUM_PUSH, Constants.Defaults.PROPERTY_REGISTRATION_ID))) {
+      Log.i("Device registered for push notifications with registration token", registrationId);
+      storePreferences(context.getApplicationContext());
+    }
+    // Send push token on every launch for not missed token when user force quit the app.
+    sendRegistrationIdToBackend(LeanplumCloudMessagingProvider.registrationId);
+  }
+
+  /**
+   * Sends the registration ID to the server over HTTP.
+   */
+  private static void sendRegistrationIdToBackend(String registrationId) {
+    Leanplum.setRegistrationId(registrationId);
+  }
+
+  /**
+   * Stores the registration ID in the application's {@code SharedPreferences}.
+   *
+   * @param context application's context.
+   */
+  public void storePreferences(Context context) {
+    Log.v("Saving the registration ID in the shared preferences.");
+    SharedPreferencesUtil.setString(context, Constants.Defaults.LEANPLUM_PUSH,
+        Constants.Defaults.PROPERTY_REGISTRATION_ID, registrationId);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumDeviceIdMode.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2015, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+/**
+ * LeanplumDeviceIdMode enum used for Leanplum.setDeviceMode.
+ *
+ * @author Paul Beusterien
+ */
+public enum LeanplumDeviceIdMode {
+  /**
+   * Takes the md5 hash of the MAC address, or the ANDROID_ID on Marshmallow or later, or if the
+   * permission to access the MAC address is not set (Default).
+   */
+  MD5_MAC_ADDRESS,
+
+  /**
+   * Uses the ANDROID_ID.
+   */
+  ANDROID_ID,
+
+  /**
+   * Uses the Android Advertising ID. Requires Google Play Services v4.0 or higher. If there is an
+   * error retrieving the Advertising ID, MD5_MAC_ADDRESS will be used instead.
+   * <p>
+   * <p>You also need the following line of code in your Android manifest within your
+   * &lt;application&gt; tag:
+   * <p>
+   * <pre>&lt;meta-data android:name="com.google.android.gms.version"
+   * android:value="@integer/google_play_services_version" /&gt;</pre>
+   */
+  ADVERTISING_ID,
+}
+
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumEditorMode.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+/**
+ * Enum for describing the Editor Mode.
+ *
+ * @author Ben Marten
+ */
+public enum LeanplumEditorMode {
+  LP_EDITOR_MODE_INTERFACE(0),
+  LP_EDITOR_MODE_EVENT(1);
+
+  private final int value;
+
+  /**
+   * Creates a new EditorMode enum with given value.
+   */
+  LeanplumEditorMode(final int newValue) {
+    value = newValue;
+  }
+
+  /**
+   * Returns the value of the enum entry.
+   *
+   * @return The value of the entry.
+   */
+  public int getValue() {
+    return value;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumException.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+/**
+ * Leanplum exception.
+ *
+ * @author Andrew First
+ */
+public class LeanplumException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  public LeanplumException(String message) {
+    super(message);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumGcmProvider.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.content.Context;
+
+import com.google.android.gms.gcm.GoogleCloudMessaging;
+import com.google.android.gms.iid.InstanceID;
+import com.leanplum.internal.Constants;
+import com.leanplum.internal.LeanplumManifestHelper;
+import com.leanplum.internal.Log;
+import com.leanplum.internal.Util;
+import com.leanplum.utils.SharedPreferencesUtil;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+
+/**
+ * Leanplum provider for work with GCM.
+ *
+ * @author Anna Orlova
+ */
+class LeanplumGcmProvider extends LeanplumCloudMessagingProvider {
+  private static final String ERROR_TIMEOUT = "TIMEOUT";
+  private static final String ERROR_INVALID_SENDER = "INVALID_SENDER";
+  private static final String ERROR_AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED";
+  private static final String ERROR_PHONE_REGISTRATION_ERROR = "PHONE_REGISTRATION_ERROR";
+  private static final String ERROR_TOO_MANY_REGISTRATIONS = "TOO_MANY_REGISTRATIONS";
+
+  private static final String SEND_PERMISSION = "com.google.android.c2dm.permission.SEND";
+  private static final String RECEIVE_PERMISSION = "com.google.android.c2dm.permission.RECEIVE";
+  private static final String RECEIVE_ACTION = "com.google.android.c2dm.intent.RECEIVE";
+  private static final String REGISTRATION_ACTION = "com.google.android.c2dm.intent.REGISTRATION";
+  private static final String INSTANCE_ID_ACTION = "com.google.android.gms.iid.InstanceID";
+  private static final String PUSH_LISTENER_SERVICE = "com.leanplum.LeanplumPushListenerService";
+  private static final String GCM_RECEIVER = "com.google.android.gms.gcm.GcmReceiver";
+  private static final String PUSH_INSTANCE_ID_SERVICE =
+      "com.leanplum.LeanplumPushInstanceIDService";
+
+  private static String senderIds;
+
+  static void setSenderId(String senderId) {
+    senderIds = senderId;
+  }
+
+  /**
+   * Stores the GCM sender ID in the application's {@code SharedPreferences}.
+   *
+   * @param context application's context.
+   */
+  @Override
+  public void storePreferences(Context context) {
+    super.storePreferences(context);
+    Log.v("Saving GCM sender ID");
+    SharedPreferencesUtil.setString(context, Constants.Defaults.LEANPLUM_PUSH,
+        Constants.Defaults.PROPERTY_SENDER_IDS, senderIds);
+  }
+
+  public String getRegistrationId() {
+    String registrationId = null;
+    try {
+      InstanceID instanceID = InstanceID.getInstance(Leanplum.getContext());
+      if (senderIds == null || instanceID == null) {
+        Log.w("There was a problem setting up GCM, please make sure you follow instructions " +
+            "on how to set it up.");
+        return null;
+      }
+      registrationId = instanceID.getToken(senderIds,
+          GoogleCloudMessaging.INSTANCE_ID_SCOPE, null);
+    } catch (IOException e) {
+      if (GoogleCloudMessaging.ERROR_SERVICE_NOT_AVAILABLE.equals(e.getMessage())) {
+        Log.w("GCM service is not available. Will try to " +
+            "register again next time the app starts.");
+      } else if (ERROR_TIMEOUT.equals(e.getMessage())) {
+        Log.w("Retrieval of GCM registration token timed out. " +
+            "Will try to register again next time the app starts.");
+      } else if (ERROR_INVALID_SENDER.equals(e.getMessage())) {
+        Log.e("The GCM sender account is not recognized. Please be " +
+            "sure to call LeanplumPushService.setGsmSenderId() with a valid GCM sender id.");
+      } else if (ERROR_AUTHENTICATION_FAILED.equals(e.getMessage())) {
+        Log.w("Bad Google Account password.");
+      } else if (ERROR_PHONE_REGISTRATION_ERROR.equals(e.getMessage())) {
+        Log.w("This phone doesn't currently support GCM.");
+      } else if (ERROR_TOO_MANY_REGISTRATIONS.equals(e.getMessage())) {
+        Log.w("This phone has more than the allowed number of " +
+            "apps that are registered with GCM.");
+      } else {
+        Log.e("Failed to complete registration token refresh.");
+        Util.handleException(e);
+      }
+    } catch (Throwable t) {
+      Log.w("There was a problem setting up GCM, please make sure you follow instructions " +
+          "on how to set it up. Please verify that you are using correct version of " +
+          "Google Play Services and Android Support Library v4.");
+      Util.handleException(t);
+    }
+    return registrationId;
+  }
+
+  public boolean isInitialized() {
+    return senderIds != null || getCurrentRegistrationId() != null;
+  }
+
+  public boolean isManifestSetUp() {
+    Context context = Leanplum.getContext();
+    if (context == null) {
+      return false;
+    }
+
+    boolean hasPermissions = LeanplumManifestHelper.checkPermission(RECEIVE_PERMISSION, false, true)
+        && (LeanplumManifestHelper.checkPermission(context.getPackageName() +
+        ".gcm.permission.C2D_MESSAGE", true, false) || LeanplumManifestHelper.checkPermission(
+        context.getPackageName() + ".permission.C2D_MESSAGE", true, true));
+
+    boolean hasGcmReceiver = LeanplumManifestHelper.checkComponent(
+        LeanplumManifestHelper.getReceivers(), GCM_RECEIVER, true, SEND_PERMISSION,
+        Arrays.asList(RECEIVE_ACTION, REGISTRATION_ACTION), context.getPackageName());
+    boolean hasPushReceiver = LeanplumManifestHelper.checkComponent(
+        LeanplumManifestHelper.getReceivers(), PUSH_RECEIVER, false, null,
+        Collections.singletonList(PUSH_LISTENER_SERVICE), null);
+
+    boolean hasReceivers = hasGcmReceiver && hasPushReceiver;
+
+    boolean hasPushListenerService = LeanplumManifestHelper.checkComponent(
+        LeanplumManifestHelper.getServices(), PUSH_LISTENER_SERVICE, false, null,
+        Collections.singletonList(RECEIVE_ACTION), null);
+    boolean hasPushInstanceIDService = LeanplumManifestHelper.checkComponent(
+        LeanplumManifestHelper.getServices(), PUSH_INSTANCE_ID_SERVICE, false, null,
+        Collections.singletonList(INSTANCE_ID_ACTION), null);
+    boolean hasPushRegistrationService = LeanplumManifestHelper.checkComponent(
+        LeanplumManifestHelper.getServices(), PUSH_REGISTRATION_SERVICE, false, null, null, null);
+
+    boolean hasServices = hasPushListenerService && hasPushInstanceIDService
+        && hasPushRegistrationService;
+
+    return hasPermissions && hasReceivers && hasServices;
+  }
+
+  /**
+   * Unregister from GCM.
+   */
+  public void unregister() {
+    try {
+      InstanceID.getInstance(Leanplum.getContext()).deleteInstanceID();
+      Log.i("Application was unregistred from GCM.");
+    } catch (Exception e) {
+      Log.e("Failed to unregister from GCM.");
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumInbox.java
@@ -0,0 +1,405 @@
+/*
+ * Copyright 2017, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import com.leanplum.callbacks.InboxChangedCallback;
+import com.leanplum.callbacks.VariablesChangedCallback;
+import com.leanplum.internal.AESCrypt;
+import com.leanplum.internal.CollectionUtil;
+import com.leanplum.internal.Constants;
+import com.leanplum.internal.JsonConverter;
+import com.leanplum.internal.Log;
+import com.leanplum.internal.OsHandler;
+import com.leanplum.internal.Request;
+import com.leanplum.internal.Util;
+
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Inbox class.
+ *
+ * @author Aleksandar Gyorev, Anna Orlova
+ */
+public class LeanplumInbox {
+  static boolean isInboxImagePrefetchingEnabled = true;
+  /**
+   * Should be like this until Newsfeed is removed for backward compatibility.
+   */
+  static Newsfeed instance = new Newsfeed();
+  static Set<String> downloadedImageUrls;
+
+  // Inbox properties.
+  private int unreadCount;
+  private Map<String, LeanplumInboxMessage> messages;
+  private boolean didLoad = false;
+  private List<InboxChangedCallback> changedCallbacks;
+  private Object updatingLock = new Object();
+
+  LeanplumInbox() {
+    this.unreadCount = 0;
+    this.messages = new HashMap<>();
+    this.didLoad = false;
+    this.changedCallbacks = new ArrayList<>();
+    downloadedImageUrls = new HashSet<>();
+  }
+
+  /**
+   * Static 'getInstance' method.
+   */
+  static LeanplumInbox getInstance() {
+    return instance;
+  }
+
+  /**
+   * Disable prefetching images.
+   */
+  public static void disableImagePrefetching() {
+    isInboxImagePrefetchingEnabled = false;
+  }
+
+  boolean isInboxImagePrefetchingEnabled() {
+    return isInboxImagePrefetchingEnabled;
+  }
+
+  void updateUnreadCount(int unreadCount) {
+    this.unreadCount = unreadCount;
+    save();
+    triggerChanged();
+  }
+
+  void update(Map<String, LeanplumInboxMessage> messages, int unreadCount, boolean shouldSave) {
+    try {
+      synchronized (updatingLock) {
+        this.unreadCount = unreadCount;
+        if (messages != null) {
+          this.messages = messages;
+        }
+      }
+      this.didLoad = true;
+      if (shouldSave) {
+        save();
+      }
+      triggerChanged();
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  void removeMessage(String messageId) {
+    int unreadCount = this.unreadCount;
+    LeanplumInboxMessage message = messageForId(messageId);
+    if (message != null && !message.isRead()) {
+      unreadCount--;
+    }
+
+    messages.remove(messageId);
+    update(messages, unreadCount, true);
+
+    if (Constants.isNoop()) {
+      return;
+    }
+
+    Map<String, Object> params = new HashMap<>();
+    params.put(Constants.Params.INBOX_MESSAGE_ID, messageId);
+    Request req = Request.post(Constants.Methods.DELETE_INBOX_MESSAGE, params);
+    req.send();
+  }
+
+  void triggerChanged() {
+    synchronized (changedCallbacks) {
+      for (InboxChangedCallback callback : changedCallbacks) {
+        OsHandler.getInstance().post(callback);
+      }
+    }
+  }
+
+  void load() {
+    if (Constants.isNoop()) {
+      return;
+    }
+    Context context = Leanplum.getContext();
+    SharedPreferences defaults = context.getSharedPreferences(
+        "__leanplum__", Context.MODE_PRIVATE);
+    if (Request.token() == null) {
+      update(new HashMap<String, LeanplumInboxMessage>(), 0, false);
+      return;
+    }
+    int unreadCount = 0;
+    AESCrypt aesContext = new AESCrypt(Request.appId(), Request.token());
+    String newsfeedString = aesContext.decodePreference(
+        defaults, Constants.Defaults.INBOX_KEY, "{}");
+    Map<String, Object> newsfeed = JsonConverter.fromJson(newsfeedString);
+
+    Map<String, LeanplumInboxMessage> messages = new HashMap<>();
+    if (newsfeed == null) {
+      Log.e("Could not parse newsfeed string: " + newsfeedString);
+    } else {
+      for (Map.Entry<String, Object> entry : newsfeed.entrySet()) {
+        String messageId = entry.getKey();
+        Map<String, Object> data = CollectionUtil.uncheckedCast(entry.getValue());
+        LeanplumInboxMessage message = LeanplumInboxMessage.createFromJsonMap(messageId, data);
+
+        if (message != null && message.isActive()) {
+          messages.put(messageId, message);
+          if (!message.isRead()) {
+            unreadCount++;
+          }
+        }
+      }
+    }
+
+    update(messages, unreadCount, false);
+  }
+
+  void save() {
+    if (Constants.isNoop()) {
+      return;
+    }
+    if (Request.token() == null) {
+      return;
+    }
+    Context context = Leanplum.getContext();
+    SharedPreferences defaults = context.getSharedPreferences(
+        "__leanplum__", Context.MODE_PRIVATE);
+    SharedPreferences.Editor editor = defaults.edit();
+    Map<String, Object> messages = new HashMap<>();
+    for (Map.Entry<String, LeanplumInboxMessage> entry : this.messages.entrySet()) {
+      String messageId = entry.getKey();
+      NewsfeedMessage newsfeedMessage = entry.getValue();
+      Map<String, Object> data = newsfeedMessage.toJsonMap();
+      messages.put(messageId, data);
+    }
+    String messagesJson = JsonConverter.toJson(messages);
+    AESCrypt aesContext = new AESCrypt(Request.appId(), Request.token());
+    editor.putString(Constants.Defaults.INBOX_KEY, aesContext.encrypt(messagesJson));
+    try {
+      editor.apply();
+    } catch (NoSuchMethodError e) {
+      editor.commit();
+    }
+  }
+
+  void downloadMessages() {
+    if (Constants.isNoop()) {
+      return;
+    }
+
+    Request req = Request.post(Constants.Methods.GET_INBOX_MESSAGES, null);
+    req.onResponse(new Request.ResponseCallback() {
+      @Override
+      public void response(JSONObject responses) {
+        try {
+          JSONObject response = Request.getLastResponse(responses);
+          if (response == null) {
+            Log.e("No inbox response received from the server.");
+            return;
+          }
+
+          JSONObject messagesDict = response.optJSONObject(Constants.Keys.INBOX_MESSAGES);
+          if (messagesDict == null) {
+            Log.e("No inbox messages found in the response from the server.", response);
+            return;
+          }
+          int unreadCount = 0;
+          final Map<String, LeanplumInboxMessage> messages = new HashMap<>();
+          Boolean willDownladImages = false;
+
+          for (Iterator iterator = messagesDict.keys(); iterator.hasNext(); ) {
+            String messageId = (String) iterator.next();
+            JSONObject messageDict = messagesDict.getJSONObject(messageId);
+
+            Map<String, Object> actionArgs = JsonConverter.mapFromJson(
+                messageDict.getJSONObject(Constants.Keys.MESSAGE_DATA).getJSONObject(Constants.Keys.VARS)
+            );
+            Long deliveryTimestamp = messageDict.getLong(Constants.Keys.DELIVERY_TIMESTAMP);
+            Long expirationTimestamp = null;
+            if (messageDict.opt(Constants.Keys.EXPIRATION_TIMESTAMP) != null) {
+              expirationTimestamp = messageDict.getLong(Constants.Keys.EXPIRATION_TIMESTAMP);
+            }
+            boolean isRead = messageDict.getBoolean(Constants.Keys.IS_READ);
+            LeanplumInboxMessage message = LeanplumInboxMessage.constructMessage(messageId,
+                deliveryTimestamp, expirationTimestamp, isRead, actionArgs);
+            if (message != null) {
+              willDownladImages |= message.downloadImageIfPrefetchingEnabled();
+              if (!isRead) {
+                unreadCount++;
+              }
+              messages.put(messageId, message);
+            }
+          }
+
+          if (!willDownladImages) {
+            update(messages, unreadCount, true);
+            return;
+          }
+
+          final int totalUnreadCount = unreadCount;
+          Leanplum.addOnceVariablesChangedAndNoDownloadsPendingHandler(
+              new VariablesChangedCallback() {
+                @Override
+                public void variablesChanged() {
+                  update(messages, totalUnreadCount, true);
+                }
+              });
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+      }
+    });
+    req.sendIfConnected();
+  }
+
+  /**
+   * Returns the number of all inbox messages on the device.
+   */
+  public int count() {
+    return messages.size();
+  }
+
+  /**
+   * Returns the number of the unread inbox messages on the device.
+   */
+  public int unreadCount() {
+    return unreadCount;
+  }
+
+  /**
+   * Returns the identifiers of all inbox messages on the device sorted in ascending
+   * chronological order, i.e. the id of the oldest message is the first one, and the most recent
+   * one is the last one in the array.
+   */
+  public List<String> messagesIds() {
+    List<String> messageIds = new ArrayList<>(messages.keySet());
+    try {
+      Collections.sort(messageIds, new Comparator<String>() {
+        @Override
+        public int compare(String firstMessage, String secondMessage) {
+          Date firstDate = messageForId(firstMessage).getDeliveryTimestamp();
+          Date secondDate = messageForId(secondMessage).getDeliveryTimestamp();
+          return firstDate.compareTo(secondDate);
+        }
+      });
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+    return messageIds;
+  }
+
+  /**
+   * Have to stay as is because of backward compatibility + generics super-sub incompatibility
+   * (http://www.angelikalanger.com/GenericsFAQ/FAQSections/ParameterizedTypes.html#Topic2).
+   * <p>
+   * Returns a List containing all of the newsfeed messages sorted chronologically ascending (i.e.
+   * the oldest first and the newest last).
+   */
+  public List<NewsfeedMessage> allMessages() {
+    return allMessages(new ArrayList<NewsfeedMessage>());
+  }
+
+  /**
+   * Have to stay as is because of backward compatibility + generics super-sub incompatibility
+   * (http://www.angelikalanger.com/GenericsFAQ/FAQSections/ParameterizedTypes.html#Topic2).
+   * <p>
+   * Returns a List containing all of the unread newsfeed messages sorted chronologically ascending
+   * (i.e. the oldest first and the newest last).
+   */
+  public List<NewsfeedMessage> unreadMessages() {
+    return unreadMessages(new ArrayList<NewsfeedMessage>());
+  }
+
+  /**
+   * Suggested workaround for generics to be used with {@link LeanplumInbox#getInstance()} although
+   * only LeanplumInboxMessage could be an instance of NewsfeedMessage.
+   */
+  private <T extends NewsfeedMessage> List<T> allMessages(List<T> messages) {
+    if (messages == null) {
+      messages = new ArrayList<>();
+    }
+    try {
+      for (String messageId : messagesIds()) {
+        messages.add((T) messageForId(messageId));
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+    return messages;
+  }
+
+  /**
+   * Suggested workaround for generics to be used with {@link LeanplumInbox#getInstance()} although
+   * only LeanplumInboxMessage could be an instance of NewsfeedMessage.
+   */
+  private <T extends NewsfeedMessage> List<T> unreadMessages(List<T> unreadMessages) {
+    if (unreadMessages == null) {
+      unreadMessages = new ArrayList<>();
+    }
+    List<LeanplumInboxMessage> messages = allMessages(null);
+    for (LeanplumInboxMessage message : messages) {
+      if (!message.isRead()) {
+        unreadMessages.add((T) message);
+      }
+    }
+    return unreadMessages;
+  }
+
+  /**
+   * Returns the inbox messages associated with the given getMessageId identifier.
+   */
+  public LeanplumInboxMessage messageForId(String messageId) {
+    return messages.get(messageId);
+  }
+
+  /**
+   * Add a callback for when the inbox receives new values from the server.
+   */
+  public void addChangedHandler(InboxChangedCallback handler) {
+    synchronized (changedCallbacks) {
+      changedCallbacks.add(handler);
+    }
+    if (this.didLoad) {
+      handler.inboxChanged();
+    }
+  }
+
+  /**
+   * Removes a inbox changed callback.
+   */
+  public void removeChangedHandler(InboxChangedCallback handler) {
+    synchronized (changedCallbacks) {
+      changedCallbacks.remove(handler);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumInboxMessage.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2017, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.net.Uri;
+import android.text.TextUtils;
+
+import com.leanplum.internal.CollectionUtil;
+import com.leanplum.internal.Constants;
+import com.leanplum.internal.Log;
+import com.leanplum.internal.Util;
+
+import org.json.JSONObject;
+
+import java.io.File;
+import java.util.Map;
+
+import static com.leanplum.internal.FileManager.DownloadFileResult;
+import static com.leanplum.internal.FileManager.fileExistsAtPath;
+import static com.leanplum.internal.FileManager.fileValue;
+import static com.leanplum.internal.FileManager.maybeDownloadFile;
+
+/**
+ * LeanplumInboxMessage class.
+ *
+ * @author Anna Orlova
+ */
+public class LeanplumInboxMessage extends NewsfeedMessage {
+  private String imageUrl;
+  private String imageFileName;
+
+  private LeanplumInboxMessage(String messageId, Long deliveryTimestamp, Long expirationTimestamp,
+      boolean isRead, ActionContext context) {
+    super(messageId, deliveryTimestamp, expirationTimestamp, isRead, context);
+    imageUrl = context.stringNamed(Constants.Keys.INBOX_IMAGE);
+    if (imageUrl != null) {
+      try {
+        imageFileName = Util.sha256(imageUrl);
+      } catch (Exception ignored) {
+      }
+    }
+  }
+
+  static LeanplumInboxMessage createFromJsonMap(String messageId, Map<String, Object> map) {
+    Map<String, Object> messageData = CollectionUtil.uncheckedCast(map.get(Constants.Keys
+        .MESSAGE_DATA));
+    Long deliveryTimestamp = CollectionUtil.uncheckedCast(map.get(Constants.Keys
+        .DELIVERY_TIMESTAMP));
+    Long expirationTimestamp = CollectionUtil.uncheckedCast(map.get(Constants.Keys
+        .EXPIRATION_TIMESTAMP));
+    Boolean isRead = CollectionUtil.uncheckedCast(map.get(Constants.Keys.IS_READ));
+    return constructMessage(messageId, deliveryTimestamp, expirationTimestamp,
+        isRead != null ? isRead : false, messageData);
+  }
+
+  static LeanplumInboxMessage constructMessage(String messageId, Long deliveryTimestamp,
+      Long expirationTimestamp, boolean isRead, Map<String, Object> actionArgs) {
+    if (!isValidMessageId(messageId)) {
+      Log.e("Malformed inbox messageId: " + messageId);
+      return null;
+    }
+
+    String[] messageIdParts = messageId.split("##");
+    ActionContext context = new ActionContext((String) actionArgs.get(Constants.Values.ACTION_ARG),
+        actionArgs, messageIdParts[0]);
+    context.preventRealtimeUpdating();
+    context.update();
+    return new LeanplumInboxMessage(messageId, deliveryTimestamp, expirationTimestamp, isRead,
+        context);
+  }
+
+  /**
+   * Returns the image file path of the inbox message. Can be null.
+   */
+  public String getImageFilePath() {
+    String path = fileValue(imageFileName);
+    if (fileExistsAtPath(path)) {
+      return new File(path).getAbsolutePath();
+    }
+    if (!LeanplumInbox.getInstance().isInboxImagePrefetchingEnabled()) {
+      Log.w("Inbox Message image path is null because you're calling disableImagePrefetching. " +
+          "Consider using imageURL method or remove disableImagePrefetching.");
+    }
+    return null;
+  }
+
+  /**
+   * Returns the image Uri of the inbox message.
+   * You can safely use this with prefetching enabled.
+   * It will return the file Uri path instead if the image is in cache.
+   */
+  public Uri getImageUrl() {
+    String path = fileValue(imageFileName);
+    if (fileExistsAtPath(path)) {
+      return Uri.fromFile(new File(path));
+    }
+    if (TextUtils.isEmpty(imageUrl)) {
+      return null;
+    }
+
+    return Uri.parse(imageUrl);
+  }
+
+  /**
+   * Returns the data of the inbox message. Advanced use only.
+   */
+  public JSONObject getData() {
+    JSONObject object = null;
+    try {
+      String dataString = getContext().stringNamed(Constants.Keys.DATA);
+      if (!TextUtils.isEmpty(dataString)) {
+        object = new JSONObject(dataString);
+      }
+    } catch (Exception e) {
+      Log.w("Unable to parse JSONObject for Data field of inbox message.");
+    }
+    return object;
+  }
+
+  /**
+   * Download image if prefetching is enabled.
+   * Uses {@link LeanplumInbox#downloadedImageUrls} to make sure we don't call fileExist method
+   * multiple times for same URLs.
+   *
+   * @return Boolean True if the image will be downloaded, otherwise false.
+   */
+  Boolean downloadImageIfPrefetchingEnabled() {
+    if (!LeanplumInbox.isInboxImagePrefetchingEnabled) {
+      return false;
+    }
+
+    if (TextUtils.isEmpty(imageUrl) || LeanplumInbox.downloadedImageUrls.contains(imageUrl)) {
+      return false;
+    }
+
+    DownloadFileResult result = maybeDownloadFile(true, imageFileName,
+        imageUrl, imageUrl, null);
+    LeanplumInbox.downloadedImageUrls.add(imageUrl);
+    return DownloadFileResult.DOWNLOADING == result;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumInflater.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.XmlResourceParser;
+import android.view.InflateException;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.leanplum.internal.Log;
+import com.leanplum.internal.Util;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Inflates layout files that may be overridden by other files.
+ *
+ * @author Andrew First
+ */
+public class LeanplumInflater {
+  private Context context;
+  private LeanplumResources res;
+
+  public static LeanplumInflater from(Context context) {
+    return new LeanplumInflater(context);
+  }
+
+  private LeanplumInflater(Context context) {
+    this.context = context;
+  }
+
+  public LeanplumResources getLeanplumResources() {
+    return getLeanplumResources(null);
+  }
+
+  public LeanplumResources getLeanplumResources(Resources baseResources) {
+    if (res != null) {
+      return res;
+    }
+    if (baseResources == null) {
+      baseResources = context.getResources();
+    }
+    if (baseResources instanceof LeanplumResources) {
+      return (LeanplumResources) baseResources;
+    }
+    res = new LeanplumResources(baseResources);
+    return res;
+  }
+
+  /**
+   * Creates a view from the corresponding resource ID.
+   */
+  public View inflate(int layoutResID) {
+    return inflate(layoutResID, null, false);
+  }
+
+  /**
+   * Creates a view from the corresponding resource ID.
+   */
+  public View inflate(int layoutResID, ViewGroup root) {
+    return inflate(layoutResID, root, root != null);
+  }
+
+  /**
+   * Creates a view from the corresponding resource ID.
+   */
+  public View inflate(int layoutResID, ViewGroup root, boolean attachToRoot) {
+    Var<String> var;
+    try {
+      LeanplumResources res = getLeanplumResources(context.getResources());
+      var = res.getOverrideResource(layoutResID);
+      if (var == null || var.stringValue.equals(var.defaultValue())) {
+        return LayoutInflater.from(context).inflate(layoutResID, root, attachToRoot);
+      }
+      int overrideResId = var.overrideResId();
+      if (overrideResId != 0) {
+        return LayoutInflater.from(context).inflate(overrideResId, root, attachToRoot);
+      }
+    } catch (Throwable t) {
+      if (!(t instanceof InflateException)) {
+        Util.handleException(t);
+      }
+      return LayoutInflater.from(context).inflate(layoutResID, root, attachToRoot);
+    }
+
+    InputStream stream = null;
+
+    try {
+      ByteArrayOutputStream fileData = new ByteArrayOutputStream();
+      stream = var.stream();
+      byte[] buffer = new byte[8192];
+      int bytesRead;
+      while ((bytesRead = stream.read(buffer)) > -1) {
+        fileData.write(buffer, 0, bytesRead);
+      }
+      Object xmlBlock = Class.forName("android.content.res.XmlBlock").getConstructor(
+          byte[].class).newInstance((Object) fileData.toByteArray());
+      XmlResourceParser parser = null;
+      try {
+        parser = (XmlResourceParser) xmlBlock.getClass().getMethod(
+            "newParser").invoke(xmlBlock);
+        return LayoutInflater.from(context).inflate(parser, root, attachToRoot);
+      } catch (Throwable t) {
+        throw new RuntimeException(t);
+      } finally {
+        if (parser != null) {
+          parser.close();
+        }
+      }
+    } catch (Throwable t) {
+      Log.e("Could not inflate resource " + layoutResID + ":" + var.stringValue(), t);
+    } finally {
+      if (stream != null) {
+        try {
+          stream.close();
+        } catch (IOException e) {
+          Log.e("Failed to close input stream.");
+        }
+      }
+    }
+    return LayoutInflater.from(context).inflate(layoutResID, root, attachToRoot);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumLocalPushListenerService.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.app.IntentService;
+import android.content.Intent;
+import android.os.Bundle;
+
+import com.leanplum.internal.Constants;
+import com.leanplum.internal.Log;
+import com.leanplum.internal.Util;
+
+/**
+ * Listener Service for local push notifications.
+ *
+ * @author Aleksandar Gyorev
+ */
+public class LeanplumLocalPushListenerService extends IntentService {
+  public LeanplumLocalPushListenerService() {
+    super("LeanplumLocalPushListenerService");
+  }
+
+  @Override
+  protected void onHandleIntent(Intent intent) {
+    try {
+      if (intent == null) {
+        Log.e("The intent cannot be null");
+        return;
+      }
+      Bundle extras = intent.getExtras();
+      if (!extras.isEmpty() && extras.containsKey(Constants.Keys.PUSH_MESSAGE_TEXT)) {
+        LeanplumPushService.handleNotification(this, extras);
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumLocationAccuracyType.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2017, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+/**
+ * LeanplumLocationAccuracyType enum used for Leanplum.setUserLocationAttribute.
+ *
+ * @author Alexis Oyama
+ */
+public enum LeanplumLocationAccuracyType {
+  /**
+   * Lowest accuracy. Reserved for internal use.
+   */
+  IP(0),
+
+  /**
+   * Default accuracy.
+   */
+  CELL(1),
+
+  /**
+   * Highest accuracy.
+   */
+  GPS(2);
+
+  private int value;
+
+  LeanplumLocationAccuracyType(int value) {
+    this.value = value;
+  }
+
+  public int value() {
+    return value;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumManualProvider.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.content.Context;
+
+/**
+ * Leanplum provider for manually registering for Cloud Messaging services.
+ *
+ * @author Anna Orlova
+ */
+public class LeanplumManualProvider extends LeanplumCloudMessagingProvider {
+  LeanplumManualProvider(Context context, String registrationId) {
+    onRegistrationIdReceived(context, registrationId);
+  }
+
+  public String getRegistrationId() {
+    return getCurrentRegistrationId();
+  }
+
+  public boolean isInitialized() {
+    return true;
+  }
+
+  public boolean isManifestSetUp() {
+    return true;
+  }
+
+  public void unregister() {
+
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumPushInstanceIDService.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.content.Intent;
+
+import com.google.android.gms.iid.InstanceIDListenerService;
+import com.leanplum.internal.Log;
+
+/**
+ * GCM InstanceID listener service to handle creation, rotation, and updating of registration
+ * tokens.
+ *
+ * @author Aleksandar Gyorev
+ */
+public class LeanplumPushInstanceIDService extends InstanceIDListenerService {
+  /**
+   * Called if InstanceID token is updated. This may occur if the security of the previous token had
+   * been compromised. This call is initiated by the InstanceID provider.
+   */
+  @Override
+  public void onTokenRefresh() {
+    Log.i("GCM InstanceID token needs an update");
+    // Fetch updated Instance ID token and notify our app's server of any changes (if applicable).
+    Intent intent = new Intent(this, LeanplumPushRegistrationService.class);
+    startService(intent);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumPushListenerService.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.os.Bundle;
+
+import com.google.android.gms.gcm.GcmListenerService;
+import com.leanplum.internal.Constants.Keys;
+import com.leanplum.internal.Log;
+import com.leanplum.internal.Util;
+
+/**
+ * GCM listener service, which enables handling messages on the app's behalf.
+ *
+ * @author Aleksandar Gyorev
+ */
+public class LeanplumPushListenerService extends GcmListenerService {
+  /**
+   * Called when a message is received.
+   *
+   * @param senderId Sender ID of the sender.
+   * @param data Data bundle containing the message data as key-value pairs.
+   */
+  @Override
+  public void onMessageReceived(String senderId, Bundle data) {
+    try {
+      if (data.containsKey(Keys.PUSH_MESSAGE_TEXT)) {
+        LeanplumPushService.handleNotification(this, data);
+      }
+      Log.i("Received: " + data.toString());
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumPushNotificationCustomizer.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2015, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.os.Bundle;
+import android.support.v4.app.NotificationCompat;
+
+/**
+ * Implement LeanplumPushNotificationCustomizer to customize the appearance of notifications.
+ */
+public interface LeanplumPushNotificationCustomizer {
+  void customize(NotificationCompat.Builder builder, Bundle notificationPayload);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumPushReceiver.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import com.leanplum.internal.Log;
+import com.leanplum.internal.Util;
+
+/**
+ * Handles push notification intents, for example, by tracking opens and performing the open
+ * action.
+ *
+ * @author Aleksandar Gyorev
+ */
+public class LeanplumPushReceiver extends BroadcastReceiver {
+
+  @Override
+  public void onReceive(Context context, Intent intent) {
+    try {
+      if (intent == null) {
+        Log.e("Received a null intent.");
+        return;
+      }
+      LeanplumPushService.openNotification(context, intent.getExtras());
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumPushRegistrationService.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.app.IntentService;
+import android.content.Intent;
+
+import com.leanplum.internal.Log;
+
+/**
+ * Registration service that handles registration with the GCM and FCM, using
+ * InstanceID.
+ *
+ * @author Aleksandar Gyorev
+ */
+public class LeanplumPushRegistrationService extends IntentService {
+  private static String existingRegistrationId;
+
+  public LeanplumPushRegistrationService() {
+    super("LeanplumPushRegistrationService");
+  }
+
+  @Override
+  protected void onHandleIntent(Intent intent) {
+    LeanplumCloudMessagingProvider provider = LeanplumPushService.getCloudMessagingProvider();
+    if (provider == null) {
+      Log.e("Failed to complete registration token refresh.");
+      return;
+    }
+    String registrationId = provider.getRegistrationId();
+    if (registrationId != null) {
+      if (existingRegistrationId != null && !registrationId.equals(existingRegistrationId)) {
+        Log.e("WARNING: It appears your app is registering " +
+            "with GCM/FCM using multiple GCM/FCM sender ids. Please be sure to call " +
+            "LeanplumPushService.setGcmSenderIds() with " +
+            "all of the GCM sender ids that you use, not just the one that you use with " +
+            "Leanplum. Otherwise, GCM/FCM push notifications may not work consistently.");
+      }
+      existingRegistrationId = registrationId;
+      provider.onRegistrationIdReceived(getApplicationContext(), registrationId);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumPushService.java
@@ -0,0 +1,776 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.app.Activity;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.v4.app.NotificationCompat;
+import android.text.TextUtils;
+
+import com.leanplum.callbacks.VariablesChangedCallback;
+import com.leanplum.internal.ActionManager;
+import com.leanplum.internal.Constants;
+import com.leanplum.internal.Constants.Keys;
+import com.leanplum.internal.Constants.Methods;
+import com.leanplum.internal.Constants.Params;
+import com.leanplum.internal.JsonConverter;
+import com.leanplum.internal.LeanplumInternal;
+import com.leanplum.internal.Log;
+import com.leanplum.internal.Request;
+import com.leanplum.internal.Util;
+import com.leanplum.internal.VarCache;
+import com.leanplum.utils.BitmapUtil;
+import com.leanplum.utils.SharedPreferencesUtil;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+
+/**
+ * Leanplum push notification service class, handling initialization, opening, showing, integration
+ * verification and registration for push notifications.
+ *
+ * @author Andrew First, Anna Orlova
+ */
+public class LeanplumPushService {
+  /**
+   * Leanplum's built-in Google Cloud Messaging sender ID.
+   */
+  public static final String LEANPLUM_SENDER_ID = "44059457771";
+  private static final String LEANPLUM_PUSH_FCM_LISTENER_SERVICE_CLASS =
+      "com.leanplum.LeanplumPushFcmListenerService";
+  private static final String PUSH_FIREBASE_MESSAGING_SERVICE_CLASS =
+      "com.leanplum.LeanplumPushFirebaseMessagingService";
+  private static final String LEANPLUM_PUSH_INSTANCE_ID_SERVICE_CLASS =
+      "com.leanplum.LeanplumPushInstanceIDService";
+  private static final String LEANPLUM_PUSH_LISTENER_SERVICE_CLASS =
+      "com.leanplum.LeanplumPushListenerService";
+  private static final String GCM_RECEIVER_CLASS = "com.google.android.gms.gcm.GcmReceiver";
+
+  private static Class<? extends Activity> callbackClass;
+  private static LeanplumCloudMessagingProvider provider;
+  private static boolean isFirebaseEnabled = false;
+  private static final int NOTIFICATION_ID = 1;
+
+  private static final String OPEN_URL = "Open URL";
+  private static final String URL = "URL";
+  private static final String OPEN_ACTION = "Open";
+  private static LeanplumPushNotificationCustomizer customizer;
+
+  /**
+   * Use Firebase Cloud Messaging, instead of the default Google Cloud Messaging.
+   */
+  public static void enableFirebase() {
+    LeanplumPushService.isFirebaseEnabled = true;
+  }
+
+  /**
+   * Whether Firebase Cloud Messaging is enabled or not.
+   *
+   * @return Boolean - true if enabled
+   */
+  static boolean isFirebaseEnabled() {
+    return isFirebaseEnabled;
+  }
+
+  /**
+   * Get Cloud Messaging provider. By default - GCM.
+   *
+   * @return LeanplumCloudMessagingProvider - current provider
+   */
+  static LeanplumCloudMessagingProvider getCloudMessagingProvider() {
+    return provider;
+  }
+
+  /**
+   * Changes the default activity to launch if the user opens a push notification.
+   *
+   * @param callbackClass The activity class.
+   */
+  public static void setDefaultCallbackClass(Class<? extends Activity> callbackClass) {
+    LeanplumPushService.callbackClass = callbackClass;
+  }
+
+  /**
+   * Sets an object used to customize the appearance of notifications. <p>Call this from your
+   * Application class's onCreate method so that the customizer is set when your application starts
+   * in the background.
+   */
+  public static void setCustomizer(LeanplumPushNotificationCustomizer customizer) {
+    LeanplumPushService.customizer = customizer;
+  }
+
+  /**
+   * Sets the Google Cloud Messaging/Firebase Cloud Messaging sender ID. Required for push
+   * notifications to work.
+   *
+   * @param senderId The GCM/FCM sender ID to permit notifications from. Use {@link
+   * LeanplumPushService#LEANPLUM_SENDER_ID} to use the built-in sender ID for GCM. If you have
+   * multiple sender IDs, use {@link LeanplumPushService#setGcmSenderIds}.
+   */
+  public static void setGcmSenderId(String senderId) {
+    LeanplumGcmProvider.setSenderId(senderId);
+  }
+
+  /**
+   * Sets the Google Cloud Messaging/Firebase Cloud Messaging sender ID. Required for push
+   * notifications to work.
+   *
+   * @param senderIds The GCM/FCM sender IDs to permit notifications from. Use {@link
+   * LeanplumPushService#LEANPLUM_SENDER_ID} to use the built-in sender ID.
+   */
+  public static void setGcmSenderIds(String... senderIds) {
+    StringBuilder joinedSenderIds = new StringBuilder();
+    for (String senderId : senderIds) {
+      if (joinedSenderIds.length() > 0) {
+        joinedSenderIds.append(',');
+      }
+      joinedSenderIds.append(senderId);
+    }
+    LeanplumGcmProvider.setSenderId(joinedSenderIds.toString());
+  }
+
+  private static Class<? extends Activity> getCallbackClass() {
+    return callbackClass;
+  }
+
+  private static boolean areActionsEmbedded(final Bundle message) {
+    return message.containsKey(Keys.PUSH_MESSAGE_ACTION);
+  }
+
+  private static void requireMessageContent(
+      final String messageId, final VariablesChangedCallback onComplete) {
+    Leanplum.addOnceVariablesChangedAndNoDownloadsPendingHandler(new VariablesChangedCallback() {
+      @Override
+      public void variablesChanged() {
+        try {
+          Map<String, Object> messages = VarCache.messages();
+          if (messageId == null || (messages != null && messages.containsKey(messageId))) {
+            onComplete.variablesChanged();
+          } else {
+            // Try downloading the messages again if it doesn't exist.
+            // Maybe the message was created while the app was running.
+            Map<String, Object> params = new HashMap<>();
+            params.put(Params.INCLUDE_DEFAULTS, Boolean.toString(false));
+            params.put(Params.INCLUDE_MESSAGE_ID, messageId);
+            Request req = Request.post(Methods.GET_VARS, params);
+            req.onResponse(new Request.ResponseCallback() {
+              @Override
+              public void response(JSONObject response) {
+                try {
+                  JSONObject getVariablesResponse = Request.getLastResponse(response);
+                  if (getVariablesResponse == null) {
+                    Log.e("No response received from the server. Please contact us to " +
+                        "investigate.");
+                  } else {
+                    Map<String, Object> values = JsonConverter.mapFromJson(
+                        getVariablesResponse.optJSONObject(Constants.Keys.VARS));
+                    Map<String, Object> messages = JsonConverter.mapFromJson(
+                        getVariablesResponse.optJSONObject(Constants.Keys.MESSAGES));
+                    Map<String, Object> regions = JsonConverter.mapFromJson(
+                        getVariablesResponse.optJSONObject(Constants.Keys.REGIONS));
+                    List<Map<String, Object>> variants = JsonConverter.listFromJson(
+                        getVariablesResponse.optJSONArray(Constants.Keys.VARIANTS));
+                    if (!Constants.canDownloadContentMidSessionInProduction ||
+                        VarCache.getDiffs().equals(values)) {
+                      values = null;
+                    }
+                    if (VarCache.getMessageDiffs().equals(messages)) {
+                      messages = null;
+                    }
+                    if (values != null || messages != null) {
+                      VarCache.applyVariableDiffs(values, messages, null, null, regions, variants);
+                    }
+                  }
+                  onComplete.variablesChanged();
+                } catch (Throwable t) {
+                  Util.handleException(t);
+                }
+              }
+            });
+            req.onError(new Request.ErrorCallback() {
+              @Override
+              public void error(Exception e) {
+                onComplete.variablesChanged();
+              }
+            });
+            req.sendIfConnected();
+          }
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+      }
+    });
+  }
+
+  private static String getMessageId(Bundle message) {
+    String messageId = message.getString(Keys.PUSH_MESSAGE_ID_NO_MUTE_WITH_ACTION);
+    if (messageId == null) {
+      messageId = message.getString(Keys.PUSH_MESSAGE_ID_MUTE_WITH_ACTION);
+      if (messageId == null) {
+        messageId = message.getString(Keys.PUSH_MESSAGE_ID_NO_MUTE);
+        if (messageId == null) {
+          messageId = message.getString(Keys.PUSH_MESSAGE_ID_MUTE);
+        }
+      }
+    }
+    if (messageId != null) {
+      message.putString(Keys.PUSH_MESSAGE_ID, messageId);
+    }
+    return messageId;
+  }
+
+  static void handleNotification(final Context context, final Bundle message) {
+    if (LeanplumActivityHelper.currentActivity != null
+        && !LeanplumActivityHelper.isActivityPaused
+        && (message.containsKey(Keys.PUSH_MESSAGE_ID_MUTE_WITH_ACTION)
+        || message.containsKey(Keys.PUSH_MESSAGE_ID_MUTE))) {
+      // Mute notifications that have "Mute inside app" set if the app is open.
+      return;
+    }
+
+    final String messageId = LeanplumPushService.getMessageId(message);
+    if (messageId == null || !LeanplumInternal.hasCalledStart()) {
+      showNotification(context, message);
+      return;
+    }
+
+    // Can only track displays if we call Leanplum.start explicitly above where it says
+    // if (!Leanplum.calledStart). However, this is probably not worth it.
+    //
+    // Map<String, String> requestArgs = new HashMap<String, String>();
+    // requestArgs.put(Constants.Params.MESSAGE_ID, getMessageId);
+    // Leanplum.track("Displayed", 0.0, null, null, requestArgs);
+
+    showNotification(context, message);
+  }
+
+  /**
+   * Put the message into a notification and post it.
+   */
+  private static void showNotification(Context context, Bundle message) {
+    NotificationManager notificationManager = (NotificationManager)
+        context.getSystemService(Context.NOTIFICATION_SERVICE);
+
+    Intent intent = new Intent(context, LeanplumPushReceiver.class);
+    intent.addCategory("lpAction");
+    intent.putExtras(message);
+    PendingIntent contentIntent = PendingIntent.getBroadcast(
+        context.getApplicationContext(), new Random().nextInt(),
+        intent, 0);
+
+    String title = Util.getApplicationName(context.getApplicationContext());
+    if (message.getString("title") != null) {
+      title = message.getString("title");
+    }
+    NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
+        .setSmallIcon(context.getApplicationInfo().icon)
+        .setContentTitle(title)
+        .setStyle(new NotificationCompat.BigTextStyle()
+            .bigText(message.getString(Keys.PUSH_MESSAGE_TEXT)))
+        .setContentText(message.getString(Keys.PUSH_MESSAGE_TEXT));
+
+    String imageUrl = message.getString(Keys.PUSH_MESSAGE_IMAGE_URL);
+    // BigPictureStyle support requires API 16 and higher.
+    if (!TextUtils.isEmpty(imageUrl) && Build.VERSION.SDK_INT >= 16) {
+      Bitmap bigPicture = BitmapUtil.getScaledBitmap(context, imageUrl);
+      if (bigPicture != null) {
+        builder.setStyle(new NotificationCompat.BigPictureStyle()
+            .bigPicture(bigPicture)
+            .setBigContentTitle(title)
+            .setSummaryText(message.getString(Keys.PUSH_MESSAGE_TEXT)));
+      } else {
+        Log.w(String.format("Image download failed for push notification with big picture. " +
+            "No image will be included with the push notification. Image URL: %s.", imageUrl));
+      }
+    }
+
+    // Try to put notification on top of notification area.
+    if (Build.VERSION.SDK_INT >= 16) {
+      builder.setPriority(Notification.PRIORITY_MAX);
+    }
+    builder.setAutoCancel(true);
+    builder.setContentIntent(contentIntent);
+
+    if (LeanplumPushService.customizer != null) {
+      LeanplumPushService.customizer.customize(builder, message);
+    }
+
+    int notificationId = LeanplumPushService.NOTIFICATION_ID;
+    Object notificationIdObject = message.get("lp_notificationId");
+    if (notificationIdObject instanceof Number) {
+      notificationId = ((Number) notificationIdObject).intValue();
+    } else if (notificationIdObject instanceof String) {
+      try {
+        notificationId = Integer.parseInt((String) notificationIdObject);
+      } catch (NumberFormatException e) {
+        notificationId = LeanplumPushService.NOTIFICATION_ID;
+      }
+    } else if (message.containsKey(Keys.PUSH_MESSAGE_ID)) {
+      String value = message.getString(Keys.PUSH_MESSAGE_ID);
+      if (value != null) {
+        notificationId = value.hashCode();
+      }
+    }
+    notificationManager.notify(notificationId, builder.build());
+  }
+
+  static void openNotification(Context context, final Bundle notification) {
+    Log.d("Opening push notification action.");
+    if (notification == null) {
+      Log.i("Received null Bundle.");
+      return;
+    }
+
+    // Checks if open action is "Open URL" and there is some activity that can handle intent.
+    if (isActivityWithIntentStarted(context, notification)) {
+      return;
+    }
+
+    // Start activity.
+    Class<? extends Activity> callbackClass = LeanplumPushService.getCallbackClass();
+    boolean shouldStartActivity = true;
+    if (LeanplumActivityHelper.currentActivity != null &&
+        !LeanplumActivityHelper.isActivityPaused) {
+      if (callbackClass == null) {
+        shouldStartActivity = false;
+      } else if (callbackClass.isInstance(LeanplumActivityHelper.currentActivity)) {
+        shouldStartActivity = false;
+      }
+    }
+
+    if (shouldStartActivity) {
+      Intent actionIntent = getActionIntent(context);
+      actionIntent.putExtras(notification);
+      actionIntent.addFlags(
+          Intent.FLAG_ACTIVITY_CLEAR_TOP |
+              Intent.FLAG_ACTIVITY_NEW_TASK);
+      context.startActivity(actionIntent);
+    }
+
+    // Perform action.
+    LeanplumActivityHelper.queueActionUponActive(new VariablesChangedCallback() {
+      @Override
+      public void variablesChanged() {
+        try {
+          final String messageId = LeanplumPushService.getMessageId(notification);
+          final String actionName = Constants.Values.DEFAULT_PUSH_ACTION;
+
+          // Make sure content is available.
+          if (messageId != null) {
+            if (LeanplumPushService.areActionsEmbedded(notification)) {
+              Map<String, Object> args = new HashMap<>();
+              args.put(actionName, JsonConverter.fromJson(
+                  notification.getString(Keys.PUSH_MESSAGE_ACTION)));
+              ActionContext context = new ActionContext(
+                  ActionManager.PUSH_NOTIFICATION_ACTION_NAME, args, messageId);
+              context.preventRealtimeUpdating();
+              context.update();
+              context.runTrackedActionNamed(actionName);
+            } else {
+              Leanplum.addOnceVariablesChangedAndNoDownloadsPendingHandler(
+                  new VariablesChangedCallback() {
+                    @Override
+                    public void variablesChanged() {
+                      try {
+                        LeanplumPushService.requireMessageContent(messageId,
+                            new VariablesChangedCallback() {
+                              @Override
+                              public void variablesChanged() {
+                                try {
+                                  LeanplumInternal.performTrackedAction(actionName, messageId);
+                                } catch (Throwable t) {
+                                  Util.handleException(t);
+                                }
+                              }
+                            });
+                      } catch (Throwable t) {
+                        Util.handleException(t);
+                      }
+                    }
+                  });
+            }
+          }
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+      }
+    });
+  }
+
+  /**
+   * Return true if we found an activity to handle Intent and started it.
+   */
+  private static boolean isActivityWithIntentStarted(Context context, Bundle notification) {
+    String action = notification.getString(Keys.PUSH_MESSAGE_ACTION);
+    if (action != null && action.contains(OPEN_URL)) {
+      Intent deepLinkIntent = getDeepLinkIntent(notification);
+      if (deepLinkIntent != null && activityHasIntent(context, deepLinkIntent)) {
+        String messageId = LeanplumPushService.getMessageId(notification);
+        if (messageId != null) {
+          ActionContext actionContext = new ActionContext(
+              ActionManager.PUSH_NOTIFICATION_ACTION_NAME, null, messageId);
+          actionContext.track(OPEN_ACTION, 0.0, null);
+          context.startActivity(deepLinkIntent);
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Gets Intent from Push Notification Bundle.
+   */
+  private static Intent getDeepLinkIntent(Bundle notification) {
+    try {
+      String actionString = notification.getString(Keys.PUSH_MESSAGE_ACTION);
+      if (actionString != null) {
+        JSONObject openAction = new JSONObject(actionString);
+        Intent deepLinkIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(
+            openAction.getString(URL)));
+        deepLinkIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        return deepLinkIntent;
+      }
+    } catch (JSONException ignored) {
+    }
+    return null;
+  }
+
+  /**
+   * Checks if there is some activity that can handle intent.
+   */
+  private static Boolean activityHasIntent(Context context, Intent deepLinkIntent) {
+    List<ResolveInfo> resolveInfoList =
+        context.getPackageManager().queryIntentActivities(deepLinkIntent, 0);
+    if (resolveInfoList != null && !resolveInfoList.isEmpty()) {
+      for (ResolveInfo resolveInfo : resolveInfoList) {
+        if (resolveInfo != null && resolveInfo.activityInfo != null &&
+            resolveInfo.activityInfo.name != null) {
+          if (resolveInfo.activityInfo.name.contains(context.getPackageName())) {
+            // If url can be handled by current app - set package name to intent, so url will be
+            // open by current app. Skip chooser dialog.
+            deepLinkIntent.setPackage(resolveInfo.activityInfo.packageName);
+            return true;
+          }
+        }
+      }
+    }
+    return false;
+  }
+
+  private static Intent getActionIntent(Context context) {
+    Class<? extends Activity> callbackClass = LeanplumPushService.getCallbackClass();
+    if (callbackClass != null) {
+      return new Intent(context, callbackClass);
+    } else {
+      PackageManager pm = context.getPackageManager();
+      return pm.getLaunchIntentForPackage(context.getPackageName());
+    }
+  }
+
+  /**
+   * Unregisters the device from all GCM push notifications. You shouldn't need to call this method
+   * in production.
+   */
+  public static void unregister() {
+    try {
+      Intent unregisterIntent = new Intent("com.google.android.c2dm.intent.UNREGISTER");
+      Context context = Leanplum.getContext();
+      unregisterIntent.putExtra("app", PendingIntent.getBroadcast(context, 0, new Intent(), 0));
+      unregisterIntent.setPackage("com.google.android.gms");
+      context.startService(unregisterIntent);
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * Registers the application with GCM servers asynchronously.
+   * <p>
+   * Stores the registration ID and app versionCode in the application's shared preferences.
+   */
+  private static void registerInBackground() {
+    Context context = Leanplum.getContext();
+    if (context == null) {
+      Log.e("Failed to register application with GCM/FCM. Your application context is not set.");
+      return;
+    }
+    Intent registerIntent = new Intent(context, LeanplumPushRegistrationService.class);
+    context.startService(registerIntent);
+  }
+
+  /**
+   * Register manually for Google Cloud Messaging services.
+   *
+   * @param token The registration ID token or the instance ID security token.
+   */
+  public static void setGcmRegistrationId(String token) {
+    new LeanplumManualProvider(Leanplum.getContext().getApplicationContext(), token);
+  }
+
+  /**
+   * Call this when Leanplum starts.
+   */
+  static void onStart() {
+    try {
+      if (Util.hasPlayServices()) {
+        initPushService();
+      } else {
+        Log.i("No valid Google Play Services APK found.");
+      }
+    } catch (LeanplumException e) {
+      Log.e("There was an error registering for push notifications.\n" +
+          Log.getStackTraceString(e));
+    }
+  }
+
+  private static void initPushService() {
+    if (!enableServices()) {
+      return;
+    }
+    provider = new LeanplumGcmProvider();
+    if (!provider.isInitialized() || !provider.isManifestSetUp()) {
+      return;
+    }
+    if (hasAppIDChanged(Request.appId())) {
+      provider.unregister();
+    }
+    registerInBackground();
+  }
+
+
+  /**
+   * Enable Leanplum GCM or FCM services.
+   *
+   * @return True if services was enabled.
+   */
+  private static boolean enableServices() {
+    Context context = Leanplum.getContext();
+    if (context == null) {
+      return false;
+    }
+
+    PackageManager packageManager = context.getPackageManager();
+    if (packageManager == null) {
+      return false;
+    }
+
+    if (isFirebaseEnabled) {
+      Class fcmListenerClass = getClassForName(LEANPLUM_PUSH_FCM_LISTENER_SERVICE_CLASS);
+      if (fcmListenerClass == null) {
+        return false;
+      }
+
+      if (!wasComponentEnabled(context, packageManager, fcmListenerClass)) {
+        if (!enableServiceAndStart(context, packageManager, PUSH_FIREBASE_MESSAGING_SERVICE_CLASS)
+            || !enableServiceAndStart(context, packageManager, fcmListenerClass)) {
+          return false;
+        }
+      }
+    } else {
+      Class gcmPushInstanceIDClass = getClassForName(LEANPLUM_PUSH_INSTANCE_ID_SERVICE_CLASS);
+      if (gcmPushInstanceIDClass == null) {
+        return false;
+      }
+
+      if (!wasComponentEnabled(context, packageManager, gcmPushInstanceIDClass)) {
+        if (!enableComponent(context, packageManager, LEANPLUM_PUSH_LISTENER_SERVICE_CLASS) ||
+            !enableComponent(context, packageManager, gcmPushInstanceIDClass) ||
+            !enableComponent(context, packageManager, GCM_RECEIVER_CLASS)) {
+          return false;
+        }
+
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Gets Class for name.
+   *
+   * @param className - class name.
+   * @return Class for provided class name.
+   */
+  private static Class getClassForName(String className) {
+    try {
+      return Class.forName(className);
+    } catch (Throwable t) {
+      if (isFirebaseEnabled) {
+        Log.e("Please compile FCM library.");
+      } else {
+        Log.e("Please compile GCM library.");
+      }
+      return null;
+    }
+  }
+
+  /**
+   * Enables and starts service for provided class name.
+   *
+   * @param context Current Context.
+   * @param packageManager Current PackageManager.
+   * @param className Name of Class that needs to be enabled and started.
+   * @return True if service was enabled and started.
+   */
+  private static boolean enableServiceAndStart(Context context, PackageManager packageManager,
+      String className) {
+    Class clazz;
+    try {
+      clazz = Class.forName(className);
+    } catch (Throwable t) {
+      return false;
+    }
+    return enableServiceAndStart(context, packageManager, clazz);
+  }
+
+  /**
+   * Enables and starts service for provided class name.
+   *
+   * @param context Current Context.
+   * @param packageManager Current PackageManager.
+   * @param clazz Class of service that needs to be enabled and started.
+   * @return True if service was enabled and started.
+   */
+  private static boolean enableServiceAndStart(Context context, PackageManager packageManager,
+      Class clazz) {
+    if (!enableComponent(context, packageManager, clazz)) {
+      return false;
+    }
+    try {
+      context.startService(new Intent(context, clazz));
+    } catch (Throwable t) {
+      Log.w("Could not start service " + clazz.getName());
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * Enables component for provided class name.
+   *
+   * @param context Current Context.
+   * @param packageManager Current PackageManager.
+   * @param className Name of Class for enable.
+   * @return True if component was enabled.
+   */
+  private static boolean enableComponent(Context context, PackageManager packageManager,
+      String className) {
+    try {
+      Class clazz = Class.forName(className);
+      return enableComponent(context, packageManager, clazz);
+    } catch (Throwable t) {
+      return false;
+    }
+  }
+
+  /**
+   * Enables component for provided class.
+   *
+   * @param context Current Context.
+   * @param packageManager Current PackageManager.
+   * @param clazz Class for enable.
+   * @return True if component was enabled.
+   */
+  private static boolean enableComponent(Context context, PackageManager packageManager,
+      Class clazz) {
+    if (clazz == null || context == null || packageManager == null) {
+      return false;
+    }
+
+    try {
+      packageManager.setComponentEnabledSetting(new ComponentName(context, clazz),
+          PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);
+    } catch (Throwable t) {
+      Log.w("Could not enable component " + clazz.getName());
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * Checks if component for provided class enabled before.
+   *
+   * @param context Current Context.
+   * @param packageManager Current PackageManager.
+   * @param clazz Class for check.
+   * @return True if component was enabled before.
+   */
+  private static boolean wasComponentEnabled(Context context, PackageManager packageManager,
+      Class clazz) {
+    if (clazz == null || context == null || packageManager == null) {
+      return false;
+    }
+    int componentStatus = packageManager.getComponentEnabledSetting(new ComponentName(context,
+        clazz));
+    if (PackageManager.COMPONENT_ENABLED_STATE_DEFAULT == componentStatus ||
+        PackageManager.COMPONENT_ENABLED_STATE_DISABLED == componentStatus) {
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * Check if current application id is different from stored one.
+   *
+   * @param currentAppId - Current application id.
+   * @return True if application id was stored before and doesn't equal to current.
+   */
+  private static boolean hasAppIDChanged(String currentAppId) {
+    if (currentAppId == null) {
+      return false;
+    }
+
+    Context context = Leanplum.getContext();
+    if (context == null) {
+      return false;
+    }
+
+    String storedAppId = SharedPreferencesUtil.getString(context, Constants.Defaults.LEANPLUM_PUSH,
+        Constants.Defaults.APP_ID);
+    if (!currentAppId.equals(storedAppId)) {
+      Log.v("Saving the application id in the shared preferences.");
+      SharedPreferencesUtil.setString(context, Constants.Defaults.LEANPLUM_PUSH,
+          Constants.Defaults.APP_ID, currentAppId);
+      // Check application id was stored before.
+      if (!SharedPreferencesUtil.DEFAULT_STRING_VALUE.equals(storedAppId)) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumResources.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.graphics.drawable.Drawable;
+import android.util.DisplayMetrics;
+
+import com.leanplum.internal.CollectionUtil;
+import com.leanplum.internal.Constants;
+import com.leanplum.internal.FileManager;
+import com.leanplum.internal.Log;
+import com.leanplum.internal.ResourceQualifiers;
+import com.leanplum.internal.ResourceQualifiers.Qualifier;
+import com.leanplum.internal.Util;
+import com.leanplum.internal.VarCache;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+// Description of resources.asrc file (we don't use this right nwo)
+// http://ekasiswanto.wordpress.com/2012/09/19/descriptions-of-androids-resources-arsc/
+
+public class LeanplumResources extends Resources {
+  public LeanplumResources(Resources base) {
+    super(base.getAssets(), base.getDisplayMetrics(), base.getConfiguration());
+  }
+
+  /* internal */
+  <T> Var<T> getOverrideResource(int id) {
+    try {
+      String name = getResourceEntryName(id);
+      String type = getResourceTypeName(id);
+      if (FileManager.resources == null) {
+        return null;
+      }
+      HashMap<String, Object> resourceValues = CollectionUtil.uncheckedCast(FileManager.resources
+          .objectForKeyPath());
+      Map<String, String> eligibleFolders = new HashMap<>();
+      synchronized (VarCache.valuesFromClient) {
+        for (String folder : resourceValues.keySet()) {
+          if (!folder.toLowerCase().startsWith(type)) {
+            continue;
+          }
+          HashMap<String, Object> files = CollectionUtil.uncheckedCast(resourceValues.get(folder));
+          String eligibleFile = null;
+          for (String filename : files.keySet()) {
+            String currentName = filename.replace("\\.", ".");
+            // Get filename without extension.
+            int dotPos = currentName.lastIndexOf('.');
+            if (dotPos >= 0) {
+              currentName = currentName.substring(0, dotPos);
+            }
+
+            if (currentName.equals(name)) {
+              eligibleFile = filename;
+            }
+          }
+          if (eligibleFile == null) {
+            continue;
+          }
+          eligibleFolders.put(folder, eligibleFile);
+        }
+      }
+
+      Map<String, ResourceQualifiers> folderQualifiers = new HashMap<>();
+      for (String folder : eligibleFolders.keySet()) {
+        folderQualifiers.put(folder, ResourceQualifiers.fromFolder(folder));
+      }
+
+      // 1. Eliminate qualifiers that contradict the device configuration.
+      // See http://developer.android.com/guide/topics/resources/providing-resources.html
+      Configuration config = getConfiguration();
+      DisplayMetrics display = getDisplayMetrics();
+      Set<String> matchedFolders = new HashSet<>();
+      for (String folder : eligibleFolders.keySet()) {
+        ResourceQualifiers qualifiers = folderQualifiers.get(folder);
+        for (Qualifier qualifier : qualifiers.qualifiers.keySet()) {
+          if (qualifier.getFilter().isMatch(
+              qualifiers.qualifiers.get(qualifier), config, display)) {
+            matchedFolders.add(folder);
+          }
+        }
+      }
+
+      // 2. Identify the next qualifier in the table (MCC first, then MNC,
+      // then language, and so on.
+      for (Qualifier qualifier : ResourceQualifiers.Qualifier.values()) {
+        Map<String, Object> betterMatchedFolders = new HashMap<>();
+        for (String folder : matchedFolders) {
+          ResourceQualifiers folderQualifier = folderQualifiers.get(folder);
+          Object qualifierValue = folderQualifier.qualifiers.get(qualifier);
+          if (qualifierValue != null) {
+            betterMatchedFolders.put(folder, qualifierValue);
+          }
+        }
+        betterMatchedFolders = qualifier.getFilter().bestMatch(
+            betterMatchedFolders, config, display);
+
+        // 3. Do any resource directories use this qualifier?
+        if (!betterMatchedFolders.isEmpty()) {
+          // Yes.
+          // 4. Eliminate directories that do not include this qualifier.
+          matchedFolders = betterMatchedFolders.keySet();
+        }
+      }
+
+      // Return result.
+      if (!eligibleFolders.isEmpty()) {
+        String folder = eligibleFolders.entrySet().iterator().next().getValue();
+        String varName = Constants.Values.RESOURCES_VARIABLE + "." + folder
+            + "." + eligibleFolders.get(folder);
+        return VarCache.getVariable(varName);
+      }
+    } catch (Exception e) {
+      Log.e("Error getting resource", e);
+    }
+    return null;
+  }
+
+  @Override
+  public Drawable getDrawable(int id) throws NotFoundException {
+    try {
+      Var<String> override = getOverrideResource(id);
+      if (override != null) {
+        int overrideResId = override.overrideResId();
+        if (overrideResId != 0) {
+          return super.getDrawable(overrideResId);
+        }
+        if (!override.stringValue.equals(override.defaultValue())) {
+          Drawable result = Drawable.createFromStream(override.stream(), override.fileValue());
+          if (result != null) {
+            return result;
+          }
+        }
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+    return super.getDrawable(id);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LeanplumUIEditor.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2017, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.app.Activity;
+
+/**
+ * Describes the API of the visual editor package.
+ */
+public interface LeanplumUIEditor {
+  /**
+   * Enable interface editing via Leanplum.com Visual Editor.
+   */
+  void allowInterfaceEditing(Boolean isDevelopmentModeEnabled);
+
+  /**
+   * Enables Interface editing for the desired activity.
+   *
+   * @param activity The activity to enable interface editing for.
+   */
+  void applyInterfaceEdits(Activity activity);
+
+  /**
+   * Sets the update flag to true.
+   */
+  void startUpdating();
+
+  /**
+   * Sets the update flag to false.
+   */
+  void stopUpdating();
+
+  /**
+   * Send an immediate update of the UI to the LP server.
+   */
+  void sendUpdate();
+
+  /**
+   * Send an update with given delay of the UI to the LP server.
+   */
+  void sendUpdateDelayed(int delay);
+
+  /**
+   * Send an update of the UI to the LP server, delayed by the default time.
+   */
+  void sendUpdateDelayedDefault();
+
+  /**
+   * Returns the current editor mode.
+   *
+   * @return The current editor mode.
+   */
+  LeanplumEditorMode getMode();
+
+  /**
+   * Sets the current editor mode.
+   *
+   * @param mode The editor mode to set.
+   */
+  void setMode(LeanplumEditorMode mode);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/LocationManager.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Public interface to LocationManager. This is abstracted away so that the Google Play Services
+ * dependencies are constrained to {@link LocationManagerImplementation}.
+ *
+ * @author Andrew First
+ */
+public interface LocationManager {
+  void updateGeofencing();
+
+  void updateUserLocation();
+
+  void setRegionsData(Map<String, Object> regionData,
+      Set<String> foregroundRegionNames, Set<String> backgroundRegionNames);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/Newsfeed.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2015, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import com.leanplum.callbacks.InboxChangedCallback;
+import com.leanplum.callbacks.NewsfeedChangedCallback;
+
+/**
+ * Newsfeed class.
+ *
+ * @author Aleksandar Gyorev
+ */
+public class Newsfeed extends LeanplumInbox {
+
+  /**
+   * A private constructor, which prevents any other class from instantiating.
+   */
+  Newsfeed() {
+  }
+
+  /**
+   * Static 'getInstance' method.
+   */
+  static Newsfeed getInstance() {
+    return instance;
+  }
+
+  /**
+   * Add a callback for when the newsfeed receives new values from the server.
+   *
+   * @deprecated use {@link #addChangedHandler(InboxChangedCallback)} instead
+   */
+  @Deprecated
+  public void addNewsfeedChangedHandler(NewsfeedChangedCallback handler) {
+    super.addChangedHandler(handler);
+  }
+
+  /**
+   * Removes a newsfeed changed callback.
+   *
+   * @deprecated use {@link #removeChangedHandler(InboxChangedCallback)} instead
+   */
+  @Deprecated
+  public void removeNewsfeedChangedHandler(NewsfeedChangedCallback handler) {
+    super.removeChangedHandler(handler);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/NewsfeedMessage.java
@@ -0,0 +1,219 @@
+/*
+ * Copyright 2015, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import com.leanplum.internal.Constants;
+import com.leanplum.internal.Request;
+import com.leanplum.internal.Util;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * NewsfeedMessage class.
+ *
+ * @author Aleksandar Gyorev
+ */
+public abstract class NewsfeedMessage {
+  private String messageId;
+  private Long deliveryTimestamp;
+  private Long expirationTimestamp;
+  private boolean isRead;
+  private ActionContext context;
+
+  NewsfeedMessage(String messageId, Long deliveryTimestamp, Long expirationTimestamp,
+      boolean isRead, ActionContext context) {
+    this.messageId = messageId;
+    this.deliveryTimestamp = deliveryTimestamp;
+    this.expirationTimestamp = expirationTimestamp;
+    this.isRead = isRead;
+    this.context = context;
+  }
+
+  Map<String, Object> toJsonMap() {
+    Map<String, Object> map = new HashMap<>();
+    map.put(Constants.Keys.DELIVERY_TIMESTAMP, this.deliveryTimestamp);
+    map.put(Constants.Keys.EXPIRATION_TIMESTAMP, this.expirationTimestamp);
+    map.put(Constants.Keys.MESSAGE_DATA, this.actionArgs());
+    map.put(Constants.Keys.IS_READ, this.isRead());
+    return map;
+  }
+
+  Map<String, Object> actionArgs() {
+    return context.getArgs();
+  }
+
+  void setIsRead(boolean isRead) {
+    this.isRead = isRead;
+  }
+
+  boolean isActive() {
+    if (expirationTimestamp == null) {
+      return true;
+    }
+
+    Date now = new Date();
+    return now.before(new Date(expirationTimestamp));
+  }
+
+  static boolean isValidMessageId(String messageId) {
+    return messageId.split("##").length == 2;
+  }
+
+  ActionContext getContext() {
+    return context;
+  }
+
+  /**
+   * Returns the message identifier of the newsfeed message.
+   *
+   * @deprecated As of release 1.3.0, replaced by {@link #getMessageId()}
+   */
+  @Deprecated
+  public String messageId() {
+    return getMessageId();
+  }
+
+  /**
+   * Returns the message identifier of the newsfeed message.
+   */
+  public String getMessageId() {
+    return messageId;
+  }
+
+  /**
+   * Returns the title of the newsfeed message.
+   *
+   * @deprecated As of release 1.3.0, replaced by {@link #getTitle()}
+   */
+  @Deprecated
+  public String title() {
+    return getTitle();
+  }
+
+  /**
+   * Returns the title of the newsfeed message.
+   */
+  public String getTitle() {
+    return context.stringNamed(Constants.Keys.TITLE);
+  }
+
+  /**
+   * Returns the subtitle of the newsfeed message.
+   *
+   * @deprecated As of release 1.3.0, replaced by {@link #getSubtitle()}
+   */
+  @Deprecated
+  public String subtitle() {
+    return getSubtitle();
+  }
+
+  /**
+   * Returns the subtitle of the newsfeed message.
+   */
+  public String getSubtitle() {
+    return context.stringNamed(Constants.Keys.SUBTITLE);
+  }
+
+  /**
+   * Returns the delivery timestamp of the newsfeed message.
+   *
+   * @deprecated As of release 1.3.0, replaced by {@link #getDeliveryTimestamp()}
+   */
+  @Deprecated
+  public Date deliveryTimestamp() {
+    return getDeliveryTimestamp();
+  }
+
+  /**
+   * Returns the delivery timestamp of the newsfeed message.
+   */
+  public Date getDeliveryTimestamp() {
+    return new Date(deliveryTimestamp);
+  }
+
+  /**
+   * Return the expiration timestamp of the newsfeed message.
+   *
+   * @deprecated As of release 1.3.0, replaced by {@link #getExpirationTimestamp()}
+   */
+  @Deprecated
+  public Date expirationTimestamp() {
+    return getExpirationTimestamp();
+  }
+
+  /**
+   * Return the expiration timestamp of the newsfeed message.
+   */
+  public Date getExpirationTimestamp() {
+    if (expirationTimestamp == null) {
+      return null;
+    }
+    return new Date(expirationTimestamp);
+  }
+
+  /**
+   * Returns 'true' if the newsfeed message is read.
+   */
+  public boolean isRead() {
+    return isRead;
+  }
+
+  /**
+   * Read the newsfeed message, marking it as read and invoking its open action.
+   */
+  public void read() {
+    try {
+      if (Constants.isNoop()) {
+        return;
+      }
+
+      if (!this.isRead) {
+        setIsRead(true);
+
+        int unreadCount = Newsfeed.getInstance().unreadCount() - 1;
+        Newsfeed.getInstance().updateUnreadCount(unreadCount);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put(Constants.Params.INBOX_MESSAGE_ID, messageId);
+        Request req = Request.post(Constants.Methods.MARK_INBOX_MESSAGE_AS_READ,
+            params);
+        req.send();
+      }
+      this.context.runTrackedActionNamed(Constants.Values.DEFAULT_PUSH_ACTION);
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * Remove the newsfeed message from the newsfeed.
+   */
+  public void remove() {
+    try {
+      Newsfeed.getInstance().removeMessage(messageId);
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/UIEditorBridge.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2017, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import com.leanplum.internal.FileManager;
+import com.leanplum.internal.Socket;
+import com.leanplum.internal.Util;
+import com.leanplum.internal.VarCache;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Bridge class for the UI editor package to access LP internal methods.
+ *
+ * @author Ben Marten
+ */
+public class UIEditorBridge {
+  public static void setInterfaceUpdateBlock(CacheUpdateBlock block) {
+    VarCache.onInterfaceUpdate(block);
+  }
+
+  public static void setEventsUpdateBlock(CacheUpdateBlock block) {
+    VarCache.onEventsUpdate(block);
+  }
+
+  public static List<Map<String, Object>> getUpdateRuleDiffs() {
+    return VarCache.getUpdateRuleDiffs();
+  }
+
+  public static List<Map<String, Object>> getEventRuleDiffs() {
+    return VarCache.getEventRuleDiffs();
+  }
+
+  public static boolean isSocketConnected() {
+    return Socket.getInstance() != null && Socket.getInstance().isConnected();
+  }
+
+  public static <T> void socketSendEvent(String eventName, Map<String, T> data) {
+    if (Socket.getInstance() != null && eventName != null) {
+      Socket.getInstance().sendEvent(eventName, data);
+    }
+  }
+
+  public static String fileRelativeToDocuments(String path) {
+    return FileManager.fileRelativeToDocuments(path);
+  }
+
+  public static void handleException(Throwable t) {
+    Util.handleException(t);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/Var.java
@@ -0,0 +1,622 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum;
+
+import android.text.TextUtils;
+
+import com.leanplum.callbacks.VariableCallback;
+import com.leanplum.internal.Constants;
+import com.leanplum.internal.FileManager;
+import com.leanplum.internal.FileManager.DownloadFileResult;
+import com.leanplum.internal.LeanplumInternal;
+import com.leanplum.internal.Log;
+import com.leanplum.internal.OsHandler;
+import com.leanplum.internal.Util;
+import com.leanplum.internal.VarCache;
+
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Leanplum variable.
+ *
+ * @param <T> Type of the variable. Can be Boolean, Byte, Short, Integer, Long, Float, Double,
+ * Character, String, List, or Map. You may nest lists and maps arbitrarily.
+ * @author Andrew First
+ */
+public class Var<T> {
+  private String name;
+  private String[] nameComponents;
+  public String stringValue;
+  private Double numberValue;
+  private T defaultValue;
+  private T value;
+  private String kind;
+  private final List<VariableCallback<T>> fileReadyHandlers = new ArrayList<>();
+  private final List<VariableCallback<T>> valueChangedHandlers = new ArrayList<>();
+  private boolean fileIsPending;
+  private boolean hadStarted;
+  private boolean isAsset;
+  public boolean isResource;
+  private int size;
+  private String hash;
+  private byte[] data;
+  private boolean valueIsInAssets = false;
+  private boolean isInternal;
+  private int overrideResId;
+  private static boolean printedCallbackWarning;
+
+  private void warnIfNotStarted() {
+    if (!isInternal && !Leanplum.hasStarted() && !printedCallbackWarning) {
+      Log.w("Leanplum hasn't finished retrieving values from the server. "
+          + "You should use a callback to make sure the value for '" + name +
+          "' is ready. Otherwise, your app may not use the most up-to-date value.");
+      printedCallbackWarning = true;
+    }
+  }
+
+  private interface VarInitializer<T> {
+    void init(Var<T> var);
+  }
+
+  private static <T> Var<T> define(
+      String name, T defaultValue, String kind, VarInitializer<T> initializer) {
+    if (TextUtils.isEmpty(name)) {
+      Log.e("Empty name parameter provided.");
+      return null;
+    }
+    Var<T> existing = VarCache.getVariable(name);
+    if (existing != null) {
+      return existing;
+    }
+    if (LeanplumInternal.hasCalledStart() &&
+        !name.startsWith(Constants.Values.RESOURCES_VARIABLE)) {
+      Log.w("You should not create new variables after calling start (name=" + name + ")");
+    }
+    Var<T> var = new Var<>();
+    try {
+      var.name = name;
+      var.nameComponents = VarCache.getNameComponents(name);
+      var.defaultValue = defaultValue;
+      var.value = defaultValue;
+      var.kind = kind;
+      if (name.startsWith(Constants.Values.RESOURCES_VARIABLE)) {
+        var.isInternal = true;
+      }
+      if (initializer != null) {
+        initializer.init(var);
+      }
+      var.cacheComputedValues();
+      VarCache.registerVariable(var);
+      if (Constants.Kinds.FILE.equals(var.kind)) {
+        VarCache.registerFile(var.stringValue,
+            var.defaultValue() == null ? null : var.defaultValue().toString(),
+            var.defaultStream(), var.isResource, var.hash, var.size);
+      }
+      var.update();
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+    return var;
+  }
+
+  /**
+   * Defines a new variable with a default value.
+   *
+   * @param name Name of the variable.
+   * @param defaultValue Default value of the variable. Can't be null.
+   */
+  public static <T> Var<T> define(String name, T defaultValue) {
+    return define(name, defaultValue, VarCache.kindFromValue(defaultValue), null);
+  }
+
+  /**
+   * Defines a variable with kind. Can be Boolean, Byte, Short, Integer, Long, Float, Double,
+   * Character, String, List, or Map. You may nest lists and maps arbitrarily.
+   *
+   * @param name Name of the variable.
+   * @param defaultValue Default value.
+   * @param kind Kind of the variable.
+   * @param <T> Boolean, Byte, Short, Integer, Long, Float, Double, Character, String, List, or
+   * Map.
+   * @return Initialized variable.
+   */
+  public static <T> Var<T> define(String name, T defaultValue, String kind) {
+    return define(name, defaultValue, kind, null);
+  }
+
+  /**
+   * Defines a color.
+   *
+   * @param name Name of the variable
+   * @param defaultValue Default value.
+   * @return Initialized variable.
+   */
+  @SuppressWarnings("WeakerAccess")
+  public static Var<Integer> defineColor(String name, int defaultValue) {
+    return define(name, defaultValue, Constants.Kinds.COLOR, null);
+  }
+
+  /**
+   * Defines a variable for a file.
+   *
+   * @param name Name of the variable.
+   * @param defaultFilename Default filename.
+   * @return Initialized variable.
+   */
+  public static Var<String> defineFile(String name, String defaultFilename) {
+    return define(name, defaultFilename, Constants.Kinds.FILE, null);
+  }
+
+  /**
+   * Defines a variable for a file located in assets directory.
+   *
+   * @param name Name of the variable.
+   * @param defaultFilename Default filename.
+   * @return Initialized variable.
+   */
+  public static Var<String> defineAsset(String name, String defaultFilename) {
+    return define(name, defaultFilename, Constants.Kinds.FILE, new VarInitializer<String>() {
+      @Override
+      public void init(Var<String> var) {
+        var.isAsset = true;
+      }
+    });
+  }
+
+  /**
+   * Define a resource variable with default value referencing id of the file located in
+   * res/ directory.
+   *
+   * @param name Name of the variable.
+   * @param resId Resource id of any file located in res/ directory.
+   * @return Initalized variable.
+   */
+  public static Var<String> defineResource(String name, int resId) {
+    String resourceName = Util.generateResourceNameFromId(resId);
+    return define(name, resourceName, Constants.Kinds.FILE, new VarInitializer<String>() {
+      @Override
+      public void init(Var<String> var) {
+        var.isResource = true;
+      }
+    });
+  }
+
+  /**
+   * Defines a resource.
+   *
+   * @param name Name of the variable.
+   * @param defaultFilename Default filename.
+   * @param size Size of the data.
+   * @param hash Hash of the data.
+   * @param data Data.
+   * @return Initalized variable.
+   */
+  public static Var<String> defineResource(String name, String defaultFilename,
+      final int size, final String hash, final byte[] data) {
+    return define(name, defaultFilename, Constants.Kinds.FILE, new VarInitializer<String>() {
+      @Override
+      public void init(Var<String> var) {
+        var.isResource = true;
+        var.size = size;
+        var.hash = hash;
+        var.data = data;
+      }
+    });
+  }
+
+  protected Var() {
+  }
+
+  /**
+   * Gets name of the variable.
+   *
+   * @return Varaible name.
+   */
+  public String name() {
+    return name;
+  }
+
+  /**
+   * Gets name components of a variable.
+   *
+   * @return Name components.
+   */
+  public String[] nameComponents() {
+    return nameComponents;
+  }
+
+  /**
+   * Gets the kind of a variable.
+   *
+   * @return Kind of a variable.
+   */
+  public String kind() {
+    return kind;
+  }
+
+  /**
+   * Gets variable default value.
+   *
+   * @return Default value.
+   */
+  public T defaultValue() {
+    return defaultValue;
+  }
+
+  /**
+   * Get variable value.
+   *
+   * @return Value.
+   */
+  public T value() {
+    warnIfNotStarted();
+    return value;
+  }
+
+  /**
+   * Gets overridden resource id for variable.
+   *
+   * @return Id of the overridden resource.
+   */
+  public int overrideResId() {
+    return overrideResId;
+  }
+
+  /**
+   * Sets overridden resource id for a variable.
+   *
+   * @param resId Resource id.
+   */
+  public void setOverrideResId(int resId) {
+    overrideResId = resId;
+  }
+
+  @SuppressWarnings("unchecked")
+  private void cacheComputedValues() {
+    if (value instanceof String) {
+      stringValue = (String) value;
+      try {
+        numberValue = Double.valueOf(stringValue);
+      } catch (NumberFormatException e) {
+        numberValue = null;
+      }
+    } else if (value instanceof Number) {
+      stringValue = "" + value;
+      numberValue = ((Number) value).doubleValue();
+      if (defaultValue instanceof Byte) {
+        value = (T) (Byte) ((Number) value).byteValue();
+      } else if (defaultValue instanceof Short) {
+        value = (T) (Short) ((Number) value).shortValue();
+      } else if (defaultValue instanceof Integer) {
+        value = (T) (Integer) ((Number) value).intValue();
+      } else if (defaultValue instanceof Long) {
+        value = (T) (Long) ((Number) value).longValue();
+      } else if (defaultValue instanceof Float) {
+        value = (T) (Float) ((Number) value).floatValue();
+      } else if (defaultValue instanceof Double) {
+        value = (T) (Double) ((Number) value).doubleValue();
+      } else if (defaultValue instanceof Character) {
+        value = (T) (Character) (char) ((Number) value).intValue();
+      }
+    } else if (value != null &&
+        !(value instanceof Iterable<?>) && !(value instanceof Map<?, ?>)) {
+      stringValue = value.toString();
+      numberValue = null;
+    } else {
+      stringValue = null;
+      numberValue = null;
+    }
+  }
+
+  /**
+   * Updates variable with values from server.
+   */
+  public void update() {
+    // TODO: Clean up memory for resource variables.
+    //data = null;
+
+    T oldValue = value;
+    value = VarCache.getMergedValueFromComponentArray(nameComponents);
+    if (value == null && oldValue == null) {
+      return;
+    }
+    if (value != null && oldValue != null && value.equals(oldValue) && hadStarted) {
+      return;
+    }
+    cacheComputedValues();
+
+    if (VarCache.silent() && name.startsWith(Constants.Values.RESOURCES_VARIABLE)
+        && Constants.Kinds.FILE.equals(kind) && !fileIsPending) {
+      triggerFileIsReady();
+    }
+
+    if (VarCache.silent()) {
+      return;
+    }
+
+    if (Leanplum.hasStarted()) {
+      triggerValueChanged();
+    }
+
+    // Check if file exists, otherwise we need to download it.
+    if (Constants.Kinds.FILE.equals(kind)) {
+      if (!Constants.isNoop()) {
+        DownloadFileResult result = FileManager.maybeDownloadFile(
+            isResource, stringValue, (String) defaultValue, null,
+            new Runnable() {
+              @Override
+              public void run() {
+                triggerFileIsReady();
+              }
+            });
+        valueIsInAssets = false;
+        if (result == DownloadFileResult.DOWNLOADING) {
+          fileIsPending = true;
+        } else if (result == DownloadFileResult.EXISTS_IN_ASSETS) {
+          valueIsInAssets = true;
+        }
+      }
+      if (Leanplum.hasStarted() && !fileIsPending) {
+        triggerFileIsReady();
+      }
+    }
+
+    if (Leanplum.hasStarted()) {
+      hadStarted = true;
+    }
+  }
+
+  private void triggerValueChanged() {
+    synchronized (valueChangedHandlers) {
+      for (VariableCallback<T> callback : valueChangedHandlers) {
+        callback.setVariable(this);
+        OsHandler.getInstance().post(callback);
+      }
+    }
+  }
+
+  /**
+   * Adds value changed handler for a given variable.
+   *
+   * @param handler Handler to add.
+   */
+  public void addValueChangedHandler(VariableCallback<T> handler) {
+    if (handler == null) {
+      Log.e("Invalid handler parameter provided.");
+      return;
+    }
+
+    synchronized (valueChangedHandlers) {
+      valueChangedHandlers.add(handler);
+    }
+    if (Leanplum.hasStarted()) {
+      handler.handle(this);
+    }
+  }
+
+  /**
+   * Removes value changed handler for a given variable.
+   *
+   * @param handler Handler to be removed.
+   */
+  public void removeValueChangedHandler(VariableCallback<T> handler) {
+    synchronized (valueChangedHandlers) {
+      valueChangedHandlers.remove(handler);
+    }
+  }
+
+  private void triggerFileIsReady() {
+    synchronized (fileReadyHandlers) {
+      fileIsPending = false;
+      for (VariableCallback<T> callback : fileReadyHandlers) {
+        callback.setVariable(this);
+        OsHandler.getInstance().post(callback);
+      }
+    }
+  }
+
+  /**
+   * Adds file ready handler for a given variable.
+   *
+   * @param handler Handler to add.
+   */
+  public void addFileReadyHandler(VariableCallback<T> handler) {
+    if (handler == null) {
+      Log.e("Invalid handler parameter provided.");
+      return;
+    }
+    synchronized (fileReadyHandlers) {
+      fileReadyHandlers.add(handler);
+    }
+    if (Leanplum.hasStarted() && !fileIsPending) {
+      handler.handle(this);
+    }
+  }
+
+  /**
+   * Removes file ready handler for a given variable.
+   *
+   * @param handler Handler to be removed.
+   */
+  public void removeFileReadyHandler(VariableCallback<T> handler) {
+    if (handler == null) {
+      Log.e("Invalid handler parameter provided.");
+      return;
+    }
+    synchronized (fileReadyHandlers) {
+      fileReadyHandlers.remove(handler);
+    }
+  }
+
+  /**
+   * Returns file value for variable initialized as file/asset/resource.
+   *
+   * @return String representing file value.
+   */
+  public String fileValue() {
+    try {
+      warnIfNotStarted();
+      if (Constants.Kinds.FILE.equals(kind)) {
+        return FileManager.fileValue(stringValue, (String) defaultValue, valueIsInAssets);
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+    return null;
+  }
+
+  /**
+   * Returns object for specified key path.
+   *
+   * @param keys Keys to look for.
+   * @return Object if found, null otherwise.
+   */
+  @SuppressWarnings("WeakerAccess") // Used by Air SDK.
+  public Object objectForKeyPath(Object... keys) {
+    try {
+      warnIfNotStarted();
+      List<Object> components = new ArrayList<>();
+      Collections.addAll(components, nameComponents);
+      if (keys != null && keys.length > 0) {
+        Collections.addAll(components, keys);
+      }
+      return VarCache.getMergedValueFromComponentArray(
+          components.toArray(new Object[components.size()]));
+    } catch (Throwable t) {
+      Util.handleException(t);
+      return null;
+    }
+  }
+
+  /**
+   * Returns a number of elements contained in a List variable.
+   *
+   * @return Elements count or 0 if Variable is not a List.
+   */
+  @Deprecated
+  public int count() {
+    return countInternal();
+  }
+
+  /**
+   * Returns a number of elements contained in a List variable.
+   *
+   * @return Elements count or 0 if Variable is not a List.
+   */
+  private int countInternal() {
+    try {
+      warnIfNotStarted();
+      Object result = VarCache.getMergedValueFromComponentArray(nameComponents);
+      if (result instanceof List) {
+        return ((List<?>) result).size();
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+      return 0;
+    }
+    LeanplumInternal.maybeThrowException(new UnsupportedOperationException(
+        "This variable is not a list."));
+    return 0;
+  }
+
+  /**
+   * Gets a value from a variable initialized as Number.
+   *
+   * @return A Number value.
+   */
+  @Deprecated
+  public Number numberValue() {
+    return numberValueInternal();
+  }
+
+  /**
+   * Gets a value from a variable initialized as Number.
+   *
+   * @return A Number value.
+   */
+  private Number numberValueInternal() {
+    warnIfNotStarted();
+    return numberValue;
+  }
+
+  /**
+   * Gets a value from a variable initialized as String.
+   *
+   * @return A String value.
+   */
+  public String stringValue() {
+    warnIfNotStarted();
+    return stringValue;
+  }
+
+  /**
+   * Creates and returns InputStream for overridden file/asset/resource variable.
+   * Caller is responsible for closing it properly to avoid leaking resources.
+   *
+   * @return InputStream for a file.
+   */
+  public InputStream stream() {
+    try {
+      if (!Constants.Kinds.FILE.equals(kind)) {
+        return null;
+      }
+      warnIfNotStarted();
+      InputStream stream = FileManager.stream(isResource, isAsset, valueIsInAssets,
+          fileValue(), (String) defaultValue, data);
+      if (stream == null) {
+        return defaultStream();
+      }
+      return stream;
+    } catch (Throwable t) {
+      Util.handleException(t);
+      return null;
+    }
+  }
+
+  /**
+   * Creates and returns InputStream for default file/asset/resource variable.
+   * Caller is responsible for closing it properly to avoid leaking resources.
+   *
+   * @return InputStream for a file.
+   */
+  private InputStream defaultStream() {
+    try {
+      if (!Constants.Kinds.FILE.equals(kind)) {
+        return null;
+      }
+      return FileManager.stream(isResource, isAsset, valueIsInAssets,
+          (String) defaultValue, (String) defaultValue, data);
+    } catch (Throwable t) {
+      Util.handleException(t);
+      return null;
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "Var(" + name + ")=" + value;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/activities/LeanplumAccountAuthenticatorActivity.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.activities;
+
+import android.accounts.AccountAuthenticatorActivity;
+import android.annotation.SuppressLint;
+import android.content.res.Resources;
+
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+
+@SuppressLint("Registered")
+public class LeanplumAccountAuthenticatorActivity extends AccountAuthenticatorActivity {
+  private LeanplumActivityHelper helper;
+
+  private LeanplumActivityHelper getHelper() {
+    if (helper == null) {
+      helper = new LeanplumActivityHelper(this);
+    }
+    return helper;
+  }
+
+  @Override
+  protected void onPause() {
+    super.onPause();
+    getHelper().onPause();
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    getHelper().onStop();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    getHelper().onResume();
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return getHelper().getLeanplumResources(super.getResources());
+  }
+
+  @Override
+  public void setContentView(final int layoutResID) {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      super.setContentView(layoutResID);
+      return;
+    }
+    getHelper().setContentView(layoutResID);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/activities/LeanplumActionBarActivity.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.activities;
+
+import android.annotation.SuppressLint;
+import android.content.res.Resources;
+import android.support.v7.app.ActionBarActivity;
+
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+
+@SuppressLint("Registered")
+@SuppressWarnings("deprecation")
+public class LeanplumActionBarActivity extends ActionBarActivity {
+  private LeanplumActivityHelper helper;
+
+  private LeanplumActivityHelper getHelper() {
+    if (helper == null) {
+      helper = new LeanplumActivityHelper(this);
+    }
+    return helper;
+  }
+
+  @Override
+  protected void onPause() {
+    super.onPause();
+    getHelper().onPause();
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    getHelper().onStop();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    getHelper().onResume();
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return getHelper().getLeanplumResources(super.getResources());
+  }
+
+  @Override
+  public void setContentView(final int layoutResID) {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      super.setContentView(layoutResID);
+      return;
+    }
+    getHelper().setContentView(layoutResID);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/activities/LeanplumActivity.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.activities;
+
+import android.app.Activity;
+import android.content.res.Resources;
+
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+
+public abstract class LeanplumActivity extends Activity {
+  private LeanplumActivityHelper helper;
+
+  private LeanplumActivityHelper getHelper() {
+    if (helper == null) {
+      helper = new LeanplumActivityHelper(this);
+    }
+    return helper;
+  }
+
+  @Override
+  protected void onPause() {
+    super.onPause();
+    getHelper().onPause();
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    getHelper().onStop();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    getHelper().onResume();
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return getHelper().getLeanplumResources(super.getResources());
+  }
+
+  @Override
+  public void setContentView(final int layoutResID) {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      super.setContentView(layoutResID);
+      return;
+    }
+    getHelper().setContentView(layoutResID);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/activities/LeanplumActivityGroup.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.activities;
+
+import android.annotation.SuppressLint;
+import android.app.ActivityGroup;
+import android.content.res.Resources;
+
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+
+@SuppressLint("Registered")
+@SuppressWarnings("deprecation")
+public class LeanplumActivityGroup extends ActivityGroup {
+  private LeanplumActivityHelper helper;
+
+  private LeanplumActivityHelper getHelper() {
+    if (helper == null) {
+      helper = new LeanplumActivityHelper(this);
+    }
+    return helper;
+  }
+
+  @Override
+  protected void onPause() {
+    super.onPause();
+    getHelper().onPause();
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    getHelper().onStop();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    getHelper().onResume();
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return getHelper().getLeanplumResources(super.getResources());
+  }
+
+  @Override
+  public void setContentView(final int layoutResID) {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      super.setContentView(layoutResID);
+      return;
+    }
+    getHelper().setContentView(layoutResID);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/activities/LeanplumAliasActivity.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.activities;
+
+import android.annotation.SuppressLint;
+import android.app.AliasActivity;
+import android.content.res.Resources;
+
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+
+@SuppressLint("Registered")
+public class LeanplumAliasActivity extends AliasActivity {
+  private LeanplumActivityHelper helper;
+
+  private LeanplumActivityHelper getHelper() {
+    if (helper == null) {
+      helper = new LeanplumActivityHelper(this);
+    }
+    return helper;
+  }
+
+  @Override
+  protected void onPause() {
+    super.onPause();
+    getHelper().onPause();
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    getHelper().onStop();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    getHelper().onResume();
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return getHelper().getLeanplumResources(super.getResources());
+  }
+
+  @Override
+  public void setContentView(final int layoutResID) {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      super.setContentView(layoutResID);
+      return;
+    }
+    getHelper().setContentView(layoutResID);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/activities/LeanplumAppCompatActivity.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2015, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.activities;
+
+import android.annotation.SuppressLint;
+import android.content.res.Resources;
+import android.support.v7.app.AppCompatActivity;
+
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+
+@SuppressLint("Registered")
+public class LeanplumAppCompatActivity extends AppCompatActivity {
+  private LeanplumActivityHelper helper;
+
+  private LeanplumActivityHelper getHelper() {
+    if (helper == null) {
+      helper = new LeanplumActivityHelper(this);
+    }
+    return helper;
+  }
+
+  @Override
+  protected void onPause() {
+    super.onPause();
+    getHelper().onPause();
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    getHelper().onStop();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    getHelper().onResume();
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return getHelper().getLeanplumResources(super.getResources());
+  }
+
+  @Override
+  public void setContentView(final int layoutResID) {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      super.setContentView(layoutResID);
+      return;
+    }
+    getHelper().setContentView(layoutResID);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/activities/LeanplumExpandableListActivity.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.activities;
+
+import android.annotation.SuppressLint;
+import android.app.ExpandableListActivity;
+import android.content.res.Resources;
+
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+
+@SuppressLint("Registered")
+public class LeanplumExpandableListActivity extends ExpandableListActivity {
+  private LeanplumActivityHelper helper;
+
+  private LeanplumActivityHelper getHelper() {
+    if (helper == null) {
+      helper = new LeanplumActivityHelper(this);
+    }
+    return helper;
+  }
+
+  @Override
+  protected void onPause() {
+    super.onPause();
+    getHelper().onPause();
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    getHelper().onStop();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    getHelper().onResume();
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return getHelper().getLeanplumResources(super.getResources());
+  }
+
+  @Override
+  public void setContentView(final int layoutResID) {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      super.setContentView(layoutResID);
+      return;
+    }
+    getHelper().setContentView(layoutResID);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/activities/LeanplumFragmentActivity.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.activities;
+
+import android.content.res.Resources;
+import android.support.v4.app.FragmentActivity;
+
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+
+public abstract class LeanplumFragmentActivity extends FragmentActivity {
+  private LeanplumActivityHelper helper;
+
+  private LeanplumActivityHelper getHelper() {
+    if (helper == null) {
+      helper = new LeanplumActivityHelper(this);
+    }
+    return helper;
+  }
+
+  @Override
+  protected void onPause() {
+    super.onPause();
+    getHelper().onPause();
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    getHelper().onStop();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    getHelper().onResume();
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return getHelper().getLeanplumResources(super.getResources());
+  }
+
+  @Override
+  public void setContentView(final int layoutResID) {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      super.setContentView(layoutResID);
+      return;
+    }
+    getHelper().setContentView(layoutResID);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/activities/LeanplumLauncherActivity.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.activities;
+
+import android.annotation.SuppressLint;
+import android.app.LauncherActivity;
+import android.content.res.Resources;
+
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+
+@SuppressLint("Registered")
+public class LeanplumLauncherActivity extends LauncherActivity {
+  private LeanplumActivityHelper helper;
+
+  private LeanplumActivityHelper getHelper() {
+    if (helper == null) {
+      helper = new LeanplumActivityHelper(this);
+    }
+    return helper;
+  }
+
+  @Override
+  protected void onPause() {
+    super.onPause();
+    getHelper().onPause();
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    getHelper().onStop();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    getHelper().onResume();
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return getHelper().getLeanplumResources(super.getResources());
+  }
+
+  @Override
+  public void setContentView(final int layoutResID) {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      super.setContentView(layoutResID);
+      return;
+    }
+    getHelper().setContentView(layoutResID);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/activities/LeanplumListActivity.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.activities;
+
+import android.annotation.SuppressLint;
+import android.app.ListActivity;
+import android.content.res.Resources;
+
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+
+@SuppressLint("Registered")
+public class LeanplumListActivity extends ListActivity {
+  private LeanplumActivityHelper helper;
+
+  private LeanplumActivityHelper getHelper() {
+    if (helper == null) {
+      helper = new LeanplumActivityHelper(this);
+    }
+    return helper;
+  }
+
+  @Override
+  protected void onPause() {
+    super.onPause();
+    getHelper().onPause();
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    getHelper().onStop();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    getHelper().onResume();
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return getHelper().getLeanplumResources(super.getResources());
+  }
+
+  @Override
+  public void setContentView(final int layoutResID) {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      super.setContentView(layoutResID);
+      return;
+    }
+    getHelper().setContentView(layoutResID);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/activities/LeanplumNativeActivity.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.activities;
+
+import android.annotation.SuppressLint;
+import android.app.NativeActivity;
+import android.content.res.Resources;
+
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+
+@SuppressLint("Registered")
+public class LeanplumNativeActivity extends NativeActivity {
+  private LeanplumActivityHelper helper;
+
+  private LeanplumActivityHelper getHelper() {
+    if (helper == null) {
+      helper = new LeanplumActivityHelper(this);
+    }
+    return helper;
+  }
+
+  @Override
+  protected void onPause() {
+    super.onPause();
+    getHelper().onPause();
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    getHelper().onStop();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    getHelper().onResume();
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return getHelper().getLeanplumResources(super.getResources());
+  }
+
+  @Override
+  public void setContentView(final int layoutResID) {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      super.setContentView(layoutResID);
+      return;
+    }
+    getHelper().setContentView(layoutResID);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/activities/LeanplumPreferenceActivity.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package com.leanplum.activities;
+
+import android.annotation.SuppressLint;
+import android.content.res.Resources;
+import android.preference.PreferenceActivity;
+
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+
+@SuppressLint("Registered")
+public class LeanplumPreferenceActivity extends PreferenceActivity {
+  private LeanplumActivityHelper helper;
+
+  private LeanplumActivityHelper getHelper() {
+    if (helper == null) {
+      helper = new LeanplumActivityHelper(this);
+    }
+    return helper;
+  }
+
+  @Override
+  protected void onPause() {
+    super.onPause();
+    getHelper().onPause();
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    getHelper().onStop();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    getHelper().onResume();
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return getHelper().getLeanplumResources(super.getResources());
+  }
+
+  @Override
+  public void setContentView(final int layoutResID) {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      super.setContentView(layoutResID);
+      return;
+    }
+    getHelper().setContentView(layoutResID);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/activities/LeanplumTabActivity.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.activities;
+
+import android.annotation.SuppressLint;
+import android.app.TabActivity;
+import android.content.res.Resources;
+
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+
+@SuppressLint("Registered")
+@SuppressWarnings("deprecation")
+public class LeanplumTabActivity extends TabActivity {
+  private LeanplumActivityHelper helper;
+
+  private LeanplumActivityHelper getHelper() {
+    if (helper == null) {
+      helper = new LeanplumActivityHelper(this);
+    }
+    return helper;
+  }
+
+  @Override
+  protected void onPause() {
+    super.onPause();
+    getHelper().onPause();
+  }
+
+  @Override
+  protected void onStop() {
+    super.onStop();
+    getHelper().onStop();
+  }
+
+  @Override
+  protected void onResume() {
+    super.onResume();
+    getHelper().onResume();
+  }
+
+  @Override
+  public Resources getResources() {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      return super.getResources();
+    }
+    return getHelper().getLeanplumResources(super.getResources());
+  }
+
+  @Override
+  public void setContentView(final int layoutResID) {
+    if (Leanplum.isTestModeEnabled() || !Leanplum.isResourceSyncingEnabled()) {
+      super.setContentView(layoutResID);
+      return;
+    }
+    getHelper().setContentView(layoutResID);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/annotations/File.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Leanplum variable annotation. Use this to make this variable changeable from the Leanplum
+ * dashboard.
+ *
+ * @author Andrew First
+ */
+@Target(ElementType.FIELD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface File {
+  /**
+   * (Optional). The group to put the variable in. Use "." to nest groups.
+   */
+  String group() default "";
+
+  /**
+   * (Optional). The name of the variable. If not set, then uses the actual name of the field.
+   */
+  String name() default "";
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/annotations/Parser.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.annotations;
+
+import android.text.TextUtils;
+
+import com.leanplum.Var;
+import com.leanplum.callbacks.VariableCallback;
+import com.leanplum.internal.Log;
+
+import java.lang.ref.WeakReference;
+import java.lang.reflect.Field;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Parses Leanplum annotations.
+ *
+ * @author Andrew First
+ */
+public class Parser {
+  private static <T> void defineVariable(
+      final Object instance,
+      String name,
+      T value,
+      String kind,
+      final Field field) {
+    final Var<T> var = Var.define(name, value, kind);
+    final boolean hasInstance = instance != null;
+    final WeakReference<Object> weakInstance = new WeakReference<>(instance);
+    var.addValueChangedHandler(new VariableCallback<T>() {
+      @Override
+      public void handle(Var<T> variable) {
+        Object instance = weakInstance.get();
+        if ((hasInstance && instance == null) || field == null) {
+          var.removeValueChangedHandler(this);
+          return;
+        }
+        try {
+          boolean accessible = field.isAccessible();
+          if (!accessible) {
+            field.setAccessible(true);
+          }
+          field.set(instance, var.value());
+          if (!accessible) {
+            field.setAccessible(false);
+          }
+        } catch (IllegalArgumentException e) {
+          Log.e("Leanplum", "Invalid value " + var.value() +
+              " for field " + var.name(), e);
+        } catch (IllegalAccessException e) {
+          Log.e("Leanplum", "Error setting value for field " + var.name(), e);
+        }
+      }
+    });
+  }
+
+  private static void defineFileVariable(
+      final Object instance,
+      String name,
+      String value,
+      final Field field) {
+    final Var<String> var = Var.defineFile(name, value);
+    final boolean hasInstance = instance != null;
+    final WeakReference<Object> weakInstance = new WeakReference<>(instance);
+    var.addFileReadyHandler(new VariableCallback<String>() {
+      @Override
+      public void handle(Var<String> variable) {
+        Object instance = weakInstance.get();
+        if ((hasInstance && instance == null) || field == null) {
+          var.removeFileReadyHandler(this);
+          return;
+        }
+        try {
+          boolean accessible = field.isAccessible();
+          if (!accessible) {
+            field.setAccessible(true);
+          }
+          field.set(instance, var.fileValue());
+          if (!accessible) {
+            field.setAccessible(false);
+          }
+        } catch (IllegalArgumentException e) {
+          Log.e("Leanplum", "Invalid value " + var.value() +
+              " for field " + var.name(), e);
+        } catch (IllegalAccessException e) {
+          Log.e("Leanplum", "Error setting value for field " + var.name(), e);
+        }
+      }
+    });
+  }
+
+  /**
+   * Parses Leanplum annotations for all given object instances.
+   */
+  public static void parseVariables(Object... instances) {
+    try {
+      for (Object instance : instances) {
+        parseVariablesHelper(instance, instance.getClass());
+      }
+    } catch (Throwable t) {
+      Log.e("Leanplum", "Error parsing variables", t);
+    }
+  }
+
+  /**
+   * Parses Leanplum annotations for all given classes.
+   */
+  public static void parseVariablesForClasses(Class<?>... classes) {
+    try {
+      for (Class<?> clazz : classes) {
+        parseVariablesHelper(null, clazz);
+      }
+    } catch (Throwable t) {
+      Log.e("Leanplum", "Error parsing variables", t);
+    }
+  }
+
+  private static void parseVariablesHelper(Object instance, Class<?> clazz)
+      throws IllegalArgumentException, IllegalAccessException {
+    Field[] fields = clazz.getFields();
+
+    for (final Field field : fields) {
+      String group, name;
+      boolean isFile = false;
+      if (field.isAnnotationPresent(Variable.class)) {
+        Variable annotation = field.getAnnotation(Variable.class);
+        group = annotation.group();
+        name = annotation.name();
+      } else if (field.isAnnotationPresent(File.class)) {
+        File annotation = field.getAnnotation(File.class);
+        group = annotation.group();
+        name = annotation.name();
+        isFile = true;
+      } else {
+        continue;
+      }
+
+      String variableName = name;
+      if (TextUtils.isEmpty(variableName)) {
+        variableName = field.getName();
+      }
+      if (!TextUtils.isEmpty(group)) {
+        variableName = group + "." + variableName;
+      }
+
+      Class<?> fieldType = field.getType();
+      String fieldTypeString = fieldType.toString();
+      if (fieldTypeString.equals("int")) {
+        defineVariable(instance, variableName, field.getInt(instance), "integer", field);
+      } else if (fieldTypeString.equals("byte")) {
+        defineVariable(instance, variableName, field.getByte(instance), "integer", field);
+      } else if (fieldTypeString.equals("short")) {
+        defineVariable(instance, variableName, field.getShort(instance), "integer", field);
+      } else if (fieldTypeString.equals("long")) {
+        defineVariable(instance, variableName, field.getLong(instance), "integer", field);
+      } else if (fieldTypeString.equals("char")) {
+        defineVariable(instance, variableName, field.getChar(instance), "integer", field);
+      } else if (fieldTypeString.equals("float")) {
+        defineVariable(instance, variableName, field.getFloat(instance), "float", field);
+      } else if (fieldTypeString.equals("double")) {
+        defineVariable(instance, variableName, field.getDouble(instance), "float", field);
+      } else if (fieldTypeString.equals("boolean")) {
+        defineVariable(instance, variableName, field.getBoolean(instance), "bool", field);
+      } else if (fieldType.isPrimitive()) {
+        Log.e("Variable " + variableName + " is an unsupported primitive type.");
+      } else if (fieldType.isArray()) {
+        Log.e("Variable " + variableName + " should be a List instead of an Array.");
+      } else if (fieldType.isAssignableFrom(List.class)) {
+        defineVariable(instance, variableName, field.get(instance), "list", field);
+      } else if (fieldType.isAssignableFrom(Map.class)) {
+        defineVariable(instance, variableName, field.get(instance), "group", field);
+      } else {
+        Object value = field.get(instance);
+        String stringValue = value == null ? null : value.toString();
+        if (isFile) {
+          defineFileVariable(instance, variableName, stringValue, field);
+        } else {
+          defineVariable(instance, variableName, stringValue, "string", field);
+        }
+      }
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/annotations/Variable.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Leanplum variable annotation.
+ * <p>
+ * <p>Use this to make this variable changeable from the Leanplum dashboard. Variables must be of
+ * type boolean, byte, short, int, long, float, double, char, String, List, or Map. Lists and maps
+ * may contain other lists and maps.
+ * <p>
+ * <p>Variables with this annotation update when the API call for Leanplum.start completes
+ * successfully or fails (in which case values are loaded from a cache stored on the device).
+ *
+ * @author Andrew First
+ */
+@Target(ElementType.FIELD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Variable {
+  /**
+   * (Optional). The group to put the variable in. Use "." to nest groups.
+   */
+  String group() default "";
+
+  /**
+   * (Optional). The name of the variable. If not set, then uses the actual name of the field.
+   */
+  String name() default "";
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/callbacks/ActionCallback.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.callbacks;
+
+import com.leanplum.ActionContext;
+
+/**
+ * Callback that gets run when an action is triggered.
+ *
+ * @author Andrew First
+ */
+public abstract class ActionCallback {
+  public abstract boolean onResponse(ActionContext context);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/callbacks/InboxChangedCallback.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2017, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.callbacks;
+
+/**
+ * Inbox changes callback.
+ *
+ * @author Anna Orlova
+ */
+public abstract class InboxChangedCallback implements Runnable {
+  public void run() {
+    this.inboxChanged();
+  }
+
+  public abstract void inboxChanged();
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/callbacks/NewsfeedChangedCallback.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2015, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.callbacks;
+
+/**
+ * Newsfeed changed callback.
+ *
+ * @author Aleksandar Gyorev
+ */
+public abstract class NewsfeedChangedCallback extends InboxChangedCallback {
+  @Override
+  public void inboxChanged() {
+    newsfeedChanged();
+  }
+
+  public abstract void newsfeedChanged();
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/callbacks/PostponableAction.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.callbacks;
+
+import com.leanplum.LeanplumActivityHelper;
+
+/**
+ * Action callback that will not be executed for activity classes that are ignored via
+ * {@link LeanplumActivityHelper#deferMessagesForActivities(Class[])}
+ *
+ * @author Ben Marten
+ */
+public abstract class PostponableAction implements Runnable {
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/callbacks/RegisterDeviceCallback.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.callbacks;
+
+/**
+ * Callback that gets run when the device needs to be registered.
+ *
+ * @author Andrew First
+ */
+public abstract class RegisterDeviceCallback implements Runnable {
+  public static abstract class EmailCallback implements Runnable {
+    private String email;
+
+    public void setResponseHandler(String email) {
+      this.email = email;
+    }
+
+    public void run() {
+      this.onResponse(email);
+    }
+
+    public abstract void onResponse(String email);
+  }
+
+  private EmailCallback callback;
+
+  public void setResponseHandler(EmailCallback callback) {
+    this.callback = callback;
+  }
+
+  public void run() {
+    this.onResponse(callback);
+  }
+
+  public abstract void onResponse(EmailCallback callback);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/callbacks/RegisterDeviceFinishedCallback.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.callbacks;
+
+/**
+ * Callback that gets run when the device has been registered.
+ *
+ * @author Andrew First
+ */
+public abstract class RegisterDeviceFinishedCallback implements Runnable {
+  private boolean success;
+
+  public void setSuccess(boolean success) {
+    this.success = success;
+  }
+
+  public void run() {
+    this.onResponse(success);
+  }
+
+  public abstract void onResponse(boolean success);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/callbacks/StartCallback.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.callbacks;
+
+/**
+ * Callback that gets run when Leanplum is started.
+ *
+ * @author Andrew First
+ */
+public abstract class StartCallback implements Runnable {
+  private boolean success;
+
+  public void setSuccess(boolean success) {
+    this.success = success;
+  }
+
+  public void run() {
+    this.onResponse(success);
+  }
+
+  public abstract void onResponse(boolean success);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/callbacks/VariableCallback.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.callbacks;
+
+import com.leanplum.Var;
+
+/**
+ * Leanplum variable callback.
+ *
+ * @author Andrew First
+ */
+public abstract class VariableCallback<T> implements Runnable {
+  private Var<T> variable;
+
+  public void setVariable(Var<T> variable) {
+    this.variable = variable;
+  }
+
+  public void run() {
+    this.handle(variable);
+  }
+
+  public abstract void handle(Var<T> variable);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/callbacks/VariablesChangedCallback.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.callbacks;
+
+/**
+ * Variables changed callback.
+ *
+ * @author Andrew First
+ */
+public abstract class VariablesChangedCallback implements Runnable {
+  public void run() {
+    this.variablesChanged();
+  }
+
+  public abstract void variablesChanged();
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/AESCrypt.java
@@ -0,0 +1,311 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.content.SharedPreferences;
+import android.util.Pair;
+
+import java.io.UnsupportedEncodingException;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.KeySpec;
+import java.util.Arrays;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.PBEKeySpec;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * AES Encryption as detailed at
+ * http://nelenkov.blogspot.com/2012/04/using-password-based-encryption-on.html
+ *
+ * @author Aakash Patel
+ */
+public class AESCrypt {
+
+  private static enum EncryptionType {
+    /**
+     * Encryption based on a token received from the server. Used in SDK versions prior to 1.2.20.
+     * <p>
+     * Corresponds to ciphertexts of the format "[12, -33, 52]", corresponding to Arrays.toString()
+     * of an encrypted byte[].
+     * <p>
+     * Legacy values may decrypt to ciphertexts. We ignore what appear to be double-encrypted
+     * ciphertexts that are stored in a legacy format.
+     */
+    LEGACY_TOKEN(0),
+
+    /**
+     * Encryption based on the app ID. Used in SDK versions since 1.2.20.
+     * <p>
+     * Corresponds to ciphertexts of the format "01[12, -33, 52]". The format adds a version
+     * identifier ("01") prefix to ciphertexts, allowing us to change the format in the future.
+     * <p>
+     * With the exception of LEGACY_TOKEN ciphertexts, which must continue to be supported, we will
+     * use the first two characters to determine the encryption protocol.
+     */
+    APP_ID_KEY(1);
+
+    public final int id;
+    public final String prefix;
+    public final String prefixWithBracket;
+
+    EncryptionType(int id) {
+      this.id = id;
+      prefix = String.format("%02d", id);
+      prefixWithBracket = prefix + "[";
+    }
+
+    private static EncryptionType forId(int id) {
+      if (id == 1) {
+        return APP_ID_KEY;
+      }
+      return null;
+    }
+
+    public static Pair<EncryptionType, String> parseCipherText(String cipherText) {
+      if (cipherText == null || cipherText.isEmpty()) {
+        return null;
+      }
+      if (cipherText.startsWith("[")) {
+        return Pair.create(LEGACY_TOKEN, cipherText);
+      }
+      if (cipherText.startsWith(APP_ID_KEY.prefixWithBracket)) {
+        return Pair.create(
+            APP_ID_KEY, cipherText.substring(APP_ID_KEY.prefixWithBracket.length() - 1));
+      }
+      return null;
+    }
+  }
+
+  // Build prefix and suffix strings longhand, to obfuscate them slightly.
+  // Probably doesn't matter.
+  private static final String APP_ID_KEY_PREFIX = new StringBuilder()
+      .append("L").append("q").append(3).append("f").append("z").toString();
+  private static final String APP_ID_KEY_SUFFIX = new StringBuilder()
+      .append("b").append("L").append("t").append("i").append(2).toString();
+
+  private final String appId;
+  private final String token;
+
+  /**
+   * Creates an AESCrypt encryption context.
+   * <p>
+   * Intended for short-term use, since the encryption token can change.
+   */
+  public AESCrypt(String appId, String token) {
+    this.appId = appId;
+    this.token = token;
+  }
+
+  private String appIdKeyPassword() {
+    return APP_ID_KEY_PREFIX + appId + APP_ID_KEY_SUFFIX;
+  }
+
+  /**
+   * Creates a ciphertext using a password based on current context parameters.
+   *
+   * @param plaintext
+   * @return A cipher text string, or null if encryption fails (unexpected).
+   */
+  public String encrypt(String plaintext) {
+    if (plaintext == null) {
+      return null;
+    }
+    // Always encrypt using the APP_ID_KEY method.
+    if (appId == null || appId.isEmpty()) {
+      Log.e("Encrypt called with null appId.");
+      return null;
+    }
+    String cipherText = encryptInternal(appIdKeyPassword(), plaintext);
+    if (cipherText == null) {
+      Log.w("Failed to encrypt.");
+      return null;
+    }
+    if (cipherText.isEmpty() || cipherText.equals(plaintext) || !cipherText.startsWith("[")) {
+      Log.w("Invalid ciphertext: " + cipherText);
+      return null;
+    }
+    return EncryptionType.APP_ID_KEY.prefix + cipherText;
+  }
+
+  public String decodePreference(SharedPreferences preferences, String key, String defaultValue) {
+    String cipherText = preferences.getString(key, null);
+    if (cipherText == null) {
+      return defaultValue;
+    }
+    String decoded = decrypt(cipherText);
+    if (decoded == null) {
+      return defaultValue;
+    }
+    return decoded;
+  }
+
+  /**
+   * Decrypts a ciphertext in either legacy or current format, using a password based on context
+   * parameters.
+   *
+   * @param cipherText The value to encrypt; tolerates null.
+   * @return A cipher text string, or null if the value can't be decrypted.
+   */
+  public String decrypt(String cipherText) {
+    Pair<EncryptionType, String> encryptionSpec = EncryptionType.parseCipherText(cipherText);
+    String result = null;
+    if (encryptionSpec == null) {
+      Log.v("Got null encryptionSpec for encrypted: " + cipherText);
+    } else {
+      switch (encryptionSpec.first) {
+        case LEGACY_TOKEN:
+          if (token == null || token.isEmpty()) {
+            Log.e("Decrypt called with null token.");
+          } else {
+            result = decryptInternal(token, encryptionSpec.second);
+            // For legacy keys only -- detect if the value we decode is a valid legacy ciphertext.
+            // If so, it was almost certainly produced by legacy decryption, which would return
+            // ciphertext on decryption failure. Discard the value and return null.
+            if (result != null && parseCiphertextInternal(result) != null) {
+              Log.e("Discarding legacy value that appears to be an encrypted value: " +
+                  result);
+              return null;
+            }
+          }
+          break;
+        case APP_ID_KEY:
+          if (appId == null || appId.isEmpty()) {
+            Log.e("Decrypt called with null appId.");
+          } else {
+            result = decryptInternal(appIdKeyPassword(), encryptionSpec.second);
+          }
+          break;
+      }
+    }
+    if (result == null) {
+      Log.w("Unable to decrypt " + cipherText);
+    }
+    return result;
+  }
+
+  /**
+   * Encrypts the plaintext using password. In case of exception, returns null.
+   */
+  // VisibleForTesting
+  public static String encryptInternal(String password, String plaintext) {
+    try {
+      return Arrays.toString(performCryptOperation(Cipher.ENCRYPT_MODE, password,
+          plaintext.getBytes("UTF-8")));
+    } catch (UnsupportedEncodingException e) {
+      Log.w("Unable to encrypt " + plaintext, e);
+      return null;
+    }
+  }
+
+  private static byte[] parseCiphertextInternal(String ciphertext) {
+    if (ciphertext == null) {
+      return null;
+    }
+    ciphertext = ciphertext.trim();
+    if (ciphertext.length() < 2) {
+      return null;
+    }
+    try {
+      String[] byteStrings =
+          ciphertext.substring(1, ciphertext.length() - 1).trim().split("\\s*,\\s*");
+      byte[] bytes = new byte[byteStrings.length];
+      for (int i = 0; i < byteStrings.length; i++) {
+        bytes[i] = Byte.parseByte(byteStrings[i]);
+      }
+      return bytes;
+    } catch (NumberFormatException e) {
+      return null;
+    }
+  }
+
+  /**
+   * Decrypts the ciphertext using password. In case of exception, returns null.
+   *
+   * @param ciphertext Must be a valid byte array represented as a string as returned by
+   * Arrays.toString().
+   */
+  private static String decryptInternal(String password, String ciphertext) {
+    try {
+      byte[] bytes = parseCiphertextInternal(ciphertext);
+      if (bytes == null) {
+        Log.w("Invalid ciphertext: " + ciphertext);
+        return null;
+      }
+      byte[] byteResult = performCryptOperation(Cipher.DECRYPT_MODE, password, bytes);
+      if (byteResult != null) {
+        return new String(byteResult, "UTF-8");
+      }
+    } catch (UnsupportedEncodingException e) {
+      // Unreachable on android, which guarantees UTF-8 support.
+      Log.w("Could not encode UTF8 string.\n" + Log.getStackTraceString(e));
+    }
+    return null;
+  }
+
+  /**
+   * Performs either an encryption or a decryption based on the mode. In case of exception, returns
+   * null.
+   *
+   * @param mode Should be either Cipher.ENCRYPT_MODE or Cipher.DECRYPT_MODE
+   * @param password The password to crypt.
+   * @param text The text to crypt.
+   * @return The result of the crypt.
+   */
+  private static byte[] performCryptOperation(int mode, String password, byte[] text) {
+    byte[] result = null;
+    try {
+      byte[] SALT = Constants.Crypt.SALT.getBytes("UTF-8");
+      byte[] IV = Constants.Crypt.IV.getBytes("UTF-8");
+      KeySpec keySpec = new PBEKeySpec(password.toCharArray(), SALT, Constants.Crypt.ITER_COUNT,
+          Constants.Crypt.KEY_LENGTH);
+      SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("PBEWithMD5And128BitAES-CBC-OpenSSL");
+      byte[] keyBytes = keyFactory.generateSecret(keySpec).getEncoded();
+      SecretKey key = new SecretKeySpec(keyBytes, "AES");
+
+      Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+      IvParameterSpec ivParams = new IvParameterSpec(IV);
+      cipher.init(mode, key, ivParams);
+
+      result = cipher.doFinal(text);
+    } catch (InvalidKeyException e) {
+      // Don't log exceptions; we have more useful warning logs when this returns null.
+    } catch (NoSuchAlgorithmException e) {
+    } catch (NoSuchPaddingException e) {
+    } catch (InvalidAlgorithmParameterException e) {
+    } catch (IllegalBlockSizeException e) {
+    } catch (BadPaddingException e) {
+    } catch (UnsupportedEncodingException e) {
+    } catch (InvalidKeySpecException e) {
+    }
+    return result;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/ActionArg.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.text.TextUtils;
+
+import java.io.InputStream;
+
+/**
+ * Represents an argument for a message or action.
+ *
+ * @param <T> Type of the argument. Can be Boolean, Byte, Short, Integer, Long, Float, Double,
+ * Character, String, List, or Map.
+ * @author Andrew First
+ */
+public class ActionArg<T> {
+  private String name;
+  private String kind;
+  private T defaultValue;
+  private boolean isAsset;
+
+  private ActionArg() {
+  }
+
+  private static <T> ActionArg<T> argNamed(String name, T defaultValue, String kind) {
+    ActionArg<T> arg = new ActionArg<>();
+    arg.name = name;
+    arg.kind = kind;
+    arg.defaultValue = defaultValue;
+    return arg;
+  }
+
+  /**
+   * Creates an instance of a new arg with a default value.
+   *
+   * @param name Name of the arg.
+   * @param defaultValue Default value of the arg. Can't be null.
+   */
+  public static <T> ActionArg<T> argNamed(String name, T defaultValue) {
+    return argNamed(name, defaultValue, VarCache.kindFromValue(defaultValue));
+  }
+
+  public static ActionArg<Integer> colorArgNamed(String name, int defaultValue) {
+    return argNamed(name, defaultValue, Constants.Kinds.COLOR);
+  }
+
+  public static ActionArg<String> fileArgNamed(String name, String defaultFilename) {
+    if (TextUtils.isEmpty(defaultFilename)) {
+      defaultFilename = "";
+    }
+    ActionArg<String> arg = argNamed(name, defaultFilename, Constants.Kinds.FILE);
+    VarCache.registerFile(arg.defaultValue, arg.defaultValue,
+        arg.defaultStream(), false, null, 0);
+    return arg;
+  }
+
+  public static ActionArg<String> assetArgNamed(String name, String defaultFilename) {
+    ActionArg<String> arg = argNamed(name, defaultFilename, Constants.Kinds.FILE);
+    arg.isAsset = true;
+    VarCache.registerFile(arg.defaultValue, arg.defaultValue,
+        arg.defaultStream(), false, null, 0);
+    return arg;
+  }
+
+  public static ActionArg<String> actionArgNamed(String name, String defaultValue) {
+    if (TextUtils.isEmpty(defaultValue)) {
+      defaultValue = "";
+    }
+    return argNamed(name, defaultValue, Constants.Kinds.ACTION);
+  }
+
+  public String name() {
+    return name;
+  }
+
+  public String kind() {
+    return kind;
+  }
+
+  public T defaultValue() {
+    return defaultValue;
+  }
+
+  public InputStream defaultStream() {
+    if (!kind.equals(Constants.Kinds.FILE)) {
+      return null;
+    }
+    return FileManager.stream(false, isAsset, isAsset,
+        (String) defaultValue, (String) defaultValue, null);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/ActionManager.java
@@ -0,0 +1,642 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+
+import com.leanplum.ActionContext;
+import com.leanplum.ActionContext.ContextualValues;
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumLocalPushListenerService;
+import com.leanplum.LeanplumPushService;
+import com.leanplum.LocationManager;
+import com.leanplum.callbacks.ActionCallback;
+
+import java.io.Serializable;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Handles in-app and push messaging.
+ *
+ * @author Andrew First
+ */
+public class ActionManager {
+  private Map<String, Map<String, Number>> messageImpressionOccurrences;
+  private Map<String, Number> messageTriggerOccurrences;
+  private Map<String, Number> sessionOccurrences;
+
+  private static ActionManager instance;
+
+  public static final String PUSH_NOTIFICATION_ACTION_NAME = "__Push Notification";
+  public static final String HELD_BACK_ACTION_NAME = "__held_back";
+
+  private static final String PREFERENCES_NAME = "__leanplum_messaging__";
+
+  public static class MessageMatchResult {
+    public boolean matchedTrigger;
+    public boolean matchedUnlessTrigger;
+    public boolean matchedLimit;
+  }
+
+  public static synchronized ActionManager getInstance() {
+    if (instance == null) {
+      instance = new ActionManager();
+    }
+    return instance;
+  }
+
+  private static boolean loggedLocationManagerFailure = false;
+
+  public static LocationManager getLocationManager() {
+    if (Util.hasPlayServices()) {
+      loggedLocationManagerFailure = true;
+    }
+    return null;
+  }
+
+  private ActionManager() {
+    listenForLocalNotifications();
+    sessionOccurrences = new HashMap<>();
+    messageImpressionOccurrences = new HashMap<>();
+    messageTriggerOccurrences = new HashMap<>();
+  }
+
+  private void listenForLocalNotifications() {
+    Leanplum.onAction(PUSH_NOTIFICATION_ACTION_NAME, new ActionCallback() {
+      @Override
+      public boolean onResponse(ActionContext actionContext) {
+        try {
+          String messageId = actionContext.getMessageId();
+
+          // Get eta.
+          Object countdownObj;
+          if (((BaseActionContext) actionContext).isPreview()) {
+            countdownObj = 5.0;
+          } else {
+            Map<String, Object> messageConfig = CollectionUtil.uncheckedCast(
+                VarCache.getMessageDiffs().get(messageId));
+            if (messageConfig == null) {
+              Log.e("Could not find message options for ID " + messageId);
+              return false;
+            }
+            countdownObj = messageConfig.get("countdown");
+          }
+          if (!(countdownObj instanceof Number)) {
+            Log.e("Invalid notification countdown: " + countdownObj);
+            return false;
+          }
+          long eta = System.currentTimeMillis() + ((Number) countdownObj).longValue() * 1000L;
+
+          Context context = Leanplum.getContext();
+          Intent intentAlarm = new Intent(context, LeanplumLocalPushListenerService.class);
+          AlarmManager alarmManager = (AlarmManager) context.getSystemService(
+              Context.ALARM_SERVICE);
+
+          // If there's already one scheduled before the eta, discard this.
+          // Otherwise, discard the scheduled one.
+          SharedPreferences preferences = context.getSharedPreferences(
+              PREFERENCES_NAME, Context.MODE_PRIVATE);
+          long existingEta = preferences.getLong(String.format(
+              Constants.Defaults.LOCAL_NOTIFICATION_KEY, messageId), 0L);
+          if (existingEta > 0L && existingEta > System.currentTimeMillis()) {
+            if (existingEta < eta) {
+              return false;
+            } else if (existingEta >= eta) {
+              PendingIntent existingIntent = PendingIntent.getService(
+                  context, messageId.hashCode(), intentAlarm,
+                  PendingIntent.FLAG_UPDATE_CURRENT);
+              alarmManager.cancel(existingIntent);
+            }
+          }
+
+          // Specify custom data for the notification
+          Map<String, Serializable> data = actionContext.objectNamed("Advanced options.Data");
+          if (data != null) {
+            for (String key : data.keySet()) {
+              intentAlarm.putExtra(key, data.get(key));
+            }
+          }
+
+          // Specify open action
+          String openAction = actionContext.stringNamed(Constants.Values.DEFAULT_PUSH_ACTION);
+          boolean muteInsideApp = Boolean.TRUE.equals(actionContext.objectNamed(
+              "Advanced options.Mute inside app"));
+          if (openAction != null) {
+            if (muteInsideApp) {
+              intentAlarm.putExtra(Constants.Keys.PUSH_MESSAGE_ID_MUTE_WITH_ACTION, messageId);
+            } else {
+              intentAlarm.putExtra(Constants.Keys.PUSH_MESSAGE_ID_NO_MUTE_WITH_ACTION, messageId);
+            }
+          } else {
+            if (muteInsideApp) {
+              intentAlarm.putExtra(Constants.Keys.PUSH_MESSAGE_ID_MUTE, messageId);
+            } else {
+              intentAlarm.putExtra(Constants.Keys.PUSH_MESSAGE_ID_NO_MUTE, messageId);
+            }
+          }
+
+          // Message.
+          String message = actionContext.stringNamed("Message");
+          intentAlarm.putExtra(Constants.Keys.PUSH_MESSAGE_TEXT,
+              message != null ? message : Constants.Values.DEFAULT_PUSH_MESSAGE);
+
+          // Collapse key.
+          String collapseKey = actionContext.stringNamed("Android options.Collapse key");
+          if (collapseKey != null) {
+            intentAlarm.putExtra("collapseKey", collapseKey);
+          }
+
+          // Delay while idle.
+          boolean delayWhileIdle = Boolean.TRUE.equals(actionContext.objectNamed(
+              "Android options.Delay while idle"));
+          if (delayWhileIdle) {
+            intentAlarm.putExtra("delayWhileIdle", true);
+          }
+
+          // Schedule notification.
+          PendingIntent operation = PendingIntent.getService(
+              context, messageId.hashCode(), intentAlarm,
+              PendingIntent.FLAG_UPDATE_CURRENT);
+          alarmManager.set(AlarmManager.RTC_WAKEUP, eta, operation);
+
+          // Save notification so we can cancel it later.
+          SharedPreferences.Editor editor = preferences.edit();
+          editor.putLong(String.format(Constants.Defaults.LOCAL_NOTIFICATION_KEY, messageId), eta);
+          try {
+            editor.apply();
+          } catch (NoSuchMethodError e) {
+            editor.commit();
+          }
+
+          Log.i("Scheduled notification");
+          return true;
+        } catch (Throwable t) {
+          Util.handleException(t);
+          return false;
+        }
+      }
+    });
+
+    Leanplum.onAction("__Cancel" + PUSH_NOTIFICATION_ACTION_NAME, new ActionCallback() {
+      @Override
+      public boolean onResponse(ActionContext actionContext) {
+        try {
+          String messageId = actionContext.getMessageId();
+
+          // Get existing eta and clear notification from preferences.
+          Context context = Leanplum.getContext();
+          SharedPreferences preferences = context.getSharedPreferences(
+              PREFERENCES_NAME, Context.MODE_PRIVATE);
+          String preferencesKey = String.format(Constants.Defaults.LOCAL_NOTIFICATION_KEY, messageId);
+          long existingEta = preferences.getLong(preferencesKey, 0L);
+          SharedPreferences.Editor editor = preferences.edit();
+          editor.remove(preferencesKey);
+          try {
+            editor.apply();
+          } catch (NoSuchMethodError e) {
+            editor.commit();
+          }
+
+          // Cancel notification.
+          Intent intentAlarm = new Intent(context, LeanplumPushService.class);
+          AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+          PendingIntent existingIntent = PendingIntent.getService(
+              context, messageId.hashCode(), intentAlarm, PendingIntent.FLAG_UPDATE_CURRENT);
+          alarmManager.cancel(existingIntent);
+
+          boolean didCancel = existingEta > System.currentTimeMillis();
+          if (didCancel) {
+            Log.i("Cancelled notification");
+          }
+          return didCancel;
+        } catch (Throwable t) {
+          Util.handleException(t);
+          return false;
+        }
+      }
+    });
+  }
+
+  public Map<String, Number> getMessageImpressionOccurrences(String messageId) {
+    Map<String, Number> occurrences = messageImpressionOccurrences.get(messageId);
+    if (occurrences != null) {
+      return occurrences;
+    }
+    Context context = Leanplum.getContext();
+    SharedPreferences preferences = context.getSharedPreferences(
+        PREFERENCES_NAME, Context.MODE_PRIVATE);
+    String savedValue = preferences.getString(
+        String.format(Constants.Defaults.MESSAGE_IMPRESSION_OCCURRENCES_KEY, messageId),
+        "{}");
+    occurrences = CollectionUtil.uncheckedCast(JsonConverter.fromJson(savedValue));
+    messageImpressionOccurrences.put(messageId, occurrences);
+    return occurrences;
+  }
+
+  public void saveMessageImpressionOccurrences(Map<String, Number> occurrences, String messageId) {
+    Context context = Leanplum.getContext();
+    SharedPreferences preferences = context.getSharedPreferences(
+        PREFERENCES_NAME, Context.MODE_PRIVATE);
+    SharedPreferences.Editor editor = preferences.edit();
+    editor.putString(
+        String.format(Constants.Defaults.MESSAGE_IMPRESSION_OCCURRENCES_KEY, messageId),
+        JsonConverter.toJson(occurrences));
+    messageImpressionOccurrences.put(messageId, occurrences);
+    try {
+      editor.apply();
+    } catch (NoSuchMethodError e) {
+      editor.commit();
+    }
+  }
+
+  public int getMessageTriggerOccurrences(String messageId) {
+    Number occurrences = messageTriggerOccurrences.get(messageId);
+    if (occurrences != null) {
+      return occurrences.intValue();
+    }
+    Context context = Leanplum.getContext();
+    SharedPreferences preferences = context.getSharedPreferences(
+        PREFERENCES_NAME, Context.MODE_PRIVATE);
+    int savedValue = preferences.getInt(
+        String.format(Constants.Defaults.MESSAGE_TRIGGER_OCCURRENCES_KEY, messageId), 0);
+    messageTriggerOccurrences.put(messageId, savedValue);
+    return savedValue;
+  }
+
+  public void saveMessageTriggerOccurrences(int occurrences, String messageId) {
+    Context context = Leanplum.getContext();
+    SharedPreferences preferences = context.getSharedPreferences(
+        PREFERENCES_NAME, Context.MODE_PRIVATE);
+    SharedPreferences.Editor editor = preferences.edit();
+    editor.putInt(
+        String.format(Constants.Defaults.MESSAGE_TRIGGER_OCCURRENCES_KEY, messageId), occurrences);
+    messageTriggerOccurrences.put(messageId, occurrences);
+    try {
+      editor.apply();
+    } catch (NoSuchMethodError e) {
+      editor.commit();
+    }
+  }
+
+  public MessageMatchResult shouldShowMessage(String messageId, Map<String, Object> messageConfig,
+      String when, String eventName, ContextualValues contextualValues) {
+    MessageMatchResult result = new MessageMatchResult();
+
+    // 1. Must not be muted.
+    Context context = Leanplum.getContext();
+    SharedPreferences preferences = context.getSharedPreferences(
+        PREFERENCES_NAME, Context.MODE_PRIVATE);
+    if (preferences.getBoolean(
+        String.format(Constants.Defaults.MESSAGE_MUTED_KEY, messageId), false)) {
+      return result;
+    }
+
+    // 2. Must match at least one trigger.
+    result.matchedTrigger = matchedTriggers(messageConfig.get("whenTriggers"), when, eventName,
+        contextualValues);
+    result.matchedUnlessTrigger = matchedTriggers(messageConfig.get("unlessTriggers"), when, eventName,
+        contextualValues);
+    if (!result.matchedTrigger && !result.matchedUnlessTrigger) {
+      return result;
+    }
+
+    // 3. Must match all limit conditions.
+    Object limitConfigObj = messageConfig.get("whenLimits");
+    Map<String, Object> limitConfig = null;
+    if (limitConfigObj instanceof Map<?, ?>) {
+      limitConfig = CollectionUtil.uncheckedCast(limitConfigObj);
+    }
+    result.matchedLimit = matchesLimits(messageId, limitConfig);
+    return result;
+  }
+
+  private boolean matchesLimits(String messageId, Map<String, Object> limitConfig) {
+    if (limitConfig == null) {
+      return true;
+    }
+    List<Object> limits = CollectionUtil.uncheckedCast(limitConfig.get("children"));
+    if (limits.isEmpty()) {
+      return true;
+    }
+    Map<String, Number> impressionOccurrences = getMessageImpressionOccurrences(messageId);
+    int triggerOccurrences = getMessageTriggerOccurrences(messageId) + 1;
+    for (Object limitObj : limits) {
+      Map<String, Object> limit = CollectionUtil.uncheckedCast(limitObj);
+      String subject = limit.get("subject").toString();
+      String noun = limit.get("noun").toString();
+      String verb = limit.get("verb").toString();
+
+      // E.g. 5 times per session; 2 times per 7 minutes.
+      if (subject.equals("times")) {
+        List<Object> objects = CollectionUtil.uncheckedCast(limit.get("objects"));
+        int perTimeUnit = objects.size() > 0 ?
+            Integer.parseInt(objects.get(0).toString()) : 0;
+        if (!matchesLimitTimes(Integer.parseInt(noun),
+            perTimeUnit, verb, impressionOccurrences, messageId)) {
+          return false;
+        }
+
+        // E.g. On the 5th occurrence.
+      } else if (subject.equals("onNthOccurrence")) {
+        int amount = Integer.parseInt(noun);
+        if (triggerOccurrences != amount) {
+          return false;
+        }
+
+        // E.g. Every 5th occurrence.
+      } else if (subject.equals("everyNthOccurrence")) {
+        int multiple = Integer.parseInt(noun);
+        if (multiple == 0 || triggerOccurrences % multiple != 0) {
+          return false;
+        }
+      }
+    }
+    return true;
+  }
+
+  private boolean matchesLimitTimes(int amount, int time, String units,
+      Map<String, Number> occurrences, String messageId) {
+    Number existing = 0L;
+    if (units.equals("limitSession")) {
+      existing = sessionOccurrences.get(messageId);
+      if (existing == null) {
+        existing = 0L;
+      }
+    } else {
+      if (occurrences == null || occurrences.isEmpty()) {
+        return true;
+      }
+      Number min = occurrences.get("min");
+      Number max = occurrences.get("max");
+      if (min == null) {
+        min = 0L;
+      }
+      if (max == null) {
+        max = 0L;
+      }
+      if (units.equals("limitUser")) {
+        existing = max.longValue() - min.longValue() + 1;
+      } else {
+        if (units.equals("limitMinute")) {
+          time *= 60;
+        } else if (units.equals("limitHour")) {
+          time *= 3600;
+        } else if (units.equals("limitDay")) {
+          time *= 86400;
+        } else if (units.equals("limitWeek")) {
+          time *= 604800;
+        } else if (units.equals("limitMonth")) {
+          time *= 2592000;
+        }
+        long now = System.currentTimeMillis();
+        int matchedOccurrences = 0;
+        for (long i = max.longValue(); i >= min.longValue(); i--) {
+          if (occurrences.containsKey("" + i)) {
+            long timeAgo = (now - occurrences.get("" + i).longValue()) / 1000;
+            if (timeAgo > time) {
+              break;
+            }
+            matchedOccurrences++;
+            if (matchedOccurrences >= amount) {
+              return false;
+            }
+          }
+        }
+      }
+    }
+    return existing.longValue() < amount;
+  }
+
+  private boolean matchedTriggers(Object triggerConfigObj, String when, String eventName,
+      ContextualValues contextualValues) {
+    if (triggerConfigObj instanceof Map<?, ?>) {
+      Map<String, Object> triggerConfig = CollectionUtil.uncheckedCast(triggerConfigObj);
+      List<Object> triggers = CollectionUtil.uncheckedCast(triggerConfig.get("children"));
+      for (Object triggerObj : triggers) {
+        Map<String, Object> trigger = CollectionUtil.uncheckedCast(triggerObj);
+        if (matchedTrigger(trigger, when, eventName, contextualValues)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  private boolean matchedTrigger(Map<String, Object> trigger, String when, String eventName,
+      ContextualValues contextualValues) {
+    String subject = (String) trigger.get("subject");
+    if (subject.equals(when)) {
+      String noun = (String) trigger.get("noun");
+      if ((noun == null && eventName == null) || (noun != null && noun.equals(eventName))) {
+        String verb = (String) trigger.get("verb");
+        List<Object> objects = CollectionUtil.uncheckedCast(trigger.get("objects"));
+
+        // Evaluate user attribute changed to value.
+        if ("changesTo".equals(verb)) {
+          if (contextualValues != null && objects != null) {
+            for (Object object : objects) {
+              if ((object == null && contextualValues.attributeValue == null) ||
+                  (object != null && object.toString().equalsIgnoreCase(
+                      contextualValues.attributeValue.toString()))) {
+                return true;
+              }
+            }
+          }
+          return false;
+        }
+
+        // Evaluate user attribute changed from value to value.
+        if ("changesFromTo".equals(verb)) {
+          return contextualValues != null &&
+              objects.size() == 2 && objects.get(0) != null && objects.get(1) != null &&
+              contextualValues.previousAttributeValue != null &&
+              contextualValues.attributeValue != null &&
+              objects.get(0).toString().equalsIgnoreCase(
+                  contextualValues.previousAttributeValue.toString()) &&
+              objects.get(1).toString().equalsIgnoreCase(
+                  contextualValues.attributeValue.toString());
+        }
+
+        // Evaluate event parameter is value.
+        if ("triggersWithParameter".equals(verb)) {
+          if (contextualValues != null &&
+              objects.size() == 2 && objects.get(0) != null && objects.get(1) != null &&
+              contextualValues.parameters != null) {
+            Object parameterValue = contextualValues.parameters.get(objects.get(0));
+            return parameterValue != null && parameterValue.toString().equalsIgnoreCase(
+                objects.get(1).toString());
+          }
+          return false;
+        }
+
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public void recordMessageTrigger(String messageId) {
+    int occurrences = getMessageTriggerOccurrences(messageId);
+    occurrences++;
+    saveMessageTriggerOccurrences(occurrences, messageId);
+  }
+
+  /**
+   * Tracks the "Held Back" event for a message and records the held back occurrences.
+   *
+   * @param messageId The spoofed ID of the message.
+   * @param originalMessageId The original ID of the held back message.
+   */
+  public void recordHeldBackImpression(String messageId, String originalMessageId) {
+    recordImpression(messageId, originalMessageId);
+  }
+
+  /**
+   * Tracks the "Open" event for a message and records it's occurrence.
+   *
+   * @param messageId The ID of the message
+   */
+  public void recordMessageImpression(String messageId) {
+    recordImpression(messageId, null);
+  }
+
+  /**
+   * Records the occurrence of a message and tracks the correct impression event.
+   *
+   * @param messageId The ID of the message.
+   * @param originalMessageId The original message ID of the held back message. Supply this only if
+   * the message is held back. Otherwise, use null.
+   */
+  private void recordImpression(String messageId, String originalMessageId) {
+    Map<String, String> requestArgs = new HashMap<>();
+    if (originalMessageId != null) {
+      // This is a held back message - track the event with the original message ID.
+      requestArgs.put(Constants.Params.MESSAGE_ID, originalMessageId);
+      LeanplumInternal.track(Constants.HELD_BACK_EVENT_NAME, 0.0, null, null, requestArgs);
+    } else {
+      // Track the message impression and occurrence.
+      requestArgs.put(Constants.Params.MESSAGE_ID, messageId);
+      LeanplumInternal.track(null, 0.0, null, null, requestArgs);
+    }
+
+    // Record session occurrences.
+    Number existing = sessionOccurrences.get(messageId);
+    if (existing == null) {
+      existing = 0L;
+    }
+    existing = existing.longValue() + 1L;
+    sessionOccurrences.put(messageId, existing);
+
+    // Record cross-session occurrences.
+    Map<String, Number> occurrences = getMessageImpressionOccurrences(messageId);
+    if (occurrences == null || occurrences.isEmpty()) {
+      occurrences = new HashMap<>();
+      occurrences.put("min", 0L);
+      occurrences.put("max", 0L);
+      occurrences.put("0", System.currentTimeMillis());
+    } else {
+      Number min = occurrences.get("min");
+      Number max = occurrences.get("max");
+      if (min == null) {
+        min = 0L;
+      }
+      if (max == null) {
+        max = 0L;
+      }
+      max = max.longValue() + 1L;
+      occurrences.put("" + max, System.currentTimeMillis());
+      if (max.longValue() - min.longValue() + 1 >
+          Constants.Messaging.MAX_STORED_OCCURRENCES_PER_MESSAGE) {
+        occurrences.remove("" + min);
+        min = min.longValue() + 1L;
+        occurrences.put("min", min);
+      }
+      occurrences.put("max", max);
+    }
+    saveMessageImpressionOccurrences(occurrences, messageId);
+  }
+
+  public void muteFutureMessagesOfKind(String messageId) {
+    if (messageId != null) {
+      Context context = Leanplum.getContext();
+      SharedPreferences preferences = context.getSharedPreferences(
+          PREFERENCES_NAME, Context.MODE_PRIVATE);
+      SharedPreferences.Editor editor = preferences.edit();
+      editor.putBoolean(
+          String.format(Constants.Defaults.MESSAGE_MUTED_KEY, messageId),
+          true);
+      try {
+        editor.apply();
+      } catch (NoSuchMethodError e) {
+        editor.commit();
+      }
+    }
+  }
+
+
+  public static void getForegroundandBackgroundRegionNames(Set<String> foregroundRegionNames,
+      Set<String> backgroundRegionNames) {
+    Map<String, Object> messages = VarCache.messages();
+    for (String messageId : messages.keySet()) {
+      Map<String, Object> messageConfig = CollectionUtil.uncheckedCast(messages.get(messageId));
+      Set<String> regionNames;
+      Object action = messageConfig.get("action");
+      if (action instanceof String) {
+        if (action.equals(PUSH_NOTIFICATION_ACTION_NAME)) {
+          regionNames = backgroundRegionNames;
+        } else {
+          regionNames = foregroundRegionNames;
+        }
+
+        Map<String, Object> whenTriggers = CollectionUtil.uncheckedCast(messageConfig.get
+            ("whenTriggers"));
+        Map<String, Object> unlessTriggers = CollectionUtil.uncheckedCast(messageConfig.get
+            ("unlessTriggers"));
+
+        addRegionNamesFromTriggersToSet(whenTriggers, regionNames);
+        addRegionNamesFromTriggersToSet(unlessTriggers, regionNames);
+      }
+    }
+  }
+
+  public static void addRegionNamesFromTriggersToSet(
+      Map<String, Object> triggerConfig, Set<String> set) {
+    if (triggerConfig == null) {
+      return;
+    }
+    List<Map<String, Object>> triggers = CollectionUtil.uncheckedCast(triggerConfig.get
+        ("children"));
+    for (Map<String, Object> trigger : triggers) {
+      String subject = (String) trigger.get("subject");
+      if (subject.equals("enterRegion") || subject.equals("exitRegion")) {
+        set.add((String) trigger.get("noun"));
+      }
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/BaseActionContext.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import java.util.Map;
+
+/**
+ * Base class for ActionContext that contains internal methods.
+ *
+ * @author Andrew First
+ */
+public abstract class BaseActionContext {
+  protected String messageId = null;
+  protected String originalMessageId = null;
+  protected int priority;
+  protected Map<String, Object> args;
+  protected boolean isRooted = true;
+  private boolean isPreview = false;
+
+  public BaseActionContext(String messageId, String originalMessageId) {
+    this.messageId = messageId;
+    this.originalMessageId = originalMessageId;
+  }
+
+  void setIsRooted(boolean value) {
+    isRooted = value;
+  }
+
+  void setIsPreview(boolean isPreview) {
+    this.isPreview = isPreview;
+  }
+
+  boolean isPreview() {
+    return isPreview;
+  }
+
+  public String getMessageId() {
+    return messageId;
+  }
+
+  public String getOriginalMessageId() {
+    return originalMessageId;
+  }
+
+  public int getPriority() {
+    return priority;
+  }
+
+  public Map<String, Object> getArgs() {
+    return args;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/CollectionUtil.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Helper class to easily create new list, map or set objects containing provided parameters.
+ *
+ * @author Ben Marten
+ */
+public class CollectionUtil {
+  /**
+   * Creates a new ArrayList and adds the passed arguments to it.
+   *
+   * @param items The items to add to the list.
+   * @param <T> The type of the list to be created.
+   * @return A typed list that contains the passed arguments.
+   */
+  @SafeVarargs
+  public static <T> ArrayList<T> newArrayList(T... items) {
+    ArrayList<T> result = new ArrayList<>((items != null) ? items.length : 0);
+    if (items != null) {
+      Collections.addAll(result, items);
+    }
+    return result;
+  }
+
+  /**
+   * Creates a new HashSet and adds the passed arguments to it.
+   *
+   * @param items The items to add to the set.
+   * @param <T> The type of the set to be created.
+   * @return A typed set that contains the passed arguments.
+   */
+  @SafeVarargs
+  static <T> HashSet<T> newHashSet(T... items) {
+    HashSet<T> result = new HashSet<>((items != null) ? items.length : 0);
+    if (items != null) {
+      Collections.addAll(result, items);
+    }
+    return result;
+  }
+
+  /**
+   * Creates a new HashMap and adds the passed arguments to it in pairs.
+   *
+   * @param items The keys and values, to add to the map in pairs.
+   * @param <T> The type of the map to be created.
+   * @return A typed map that contains the passed arguments.
+   * @throws IllegalArgumentException Throws an exception when an uneven number of arguments are
+   * passed.
+   */
+  @SuppressWarnings("unchecked")
+  public static <T, U> HashMap<T, U> newHashMap(Object... items) {
+    return (HashMap<T, U>) newMap(
+        new HashMap((items != null) ? items.length : 0),
+        (items != null) ? items : null);
+  }
+
+  /**
+   * Creates a new HashMap and adds the passed arguments to it in pairs.
+   *
+   * @param items The keys and values, to add to the map in pairs.
+   * @param <T> The type of the map to be created.
+   * @return A typed map that contains the passed arguments.
+   * @throws IllegalArgumentException Throws an exception when an uneven number of arguments are
+   * passed.
+   */
+  @SuppressWarnings("unchecked")
+  static <T, U> LinkedHashMap<T, U> newLinkedHashMap(Object... items) {
+    return (LinkedHashMap<T, U>) newMap(
+        new LinkedHashMap((items != null) ? items.length : 0),
+        (items != null) ? items : null);
+  }
+
+  /**
+   * Creates a new Map and adds the passed arguments to it in pairs.
+   *
+   * @param items The keys and values, to add to the map in pairs.
+   * @param <T> The type of the map to be created.
+   * @return A typed map that contains the passed arguments.
+   * @throws IllegalArgumentException Throws an exception when an even number of arguments are
+   * passed, or the type parameter is not a subclass of map.
+   */
+  @SuppressWarnings("unchecked")
+  private static <T, U> Map<T, U> newMap(Map<T, U> map, T[] items) {
+    if (items == null || items.length == 0) {
+      return map;
+    }
+    if (items.length % 2 != 0) {
+      throw new IllegalArgumentException("newMap requires an even number of items.");
+    }
+
+    for (int i = 0; i < items.length; i += 2) {
+      map.put(items[i], (U) items[i + 1]);
+    }
+    return map;
+  }
+
+  /**
+   * Returns the components of an array as concatenated String by calling toString() on each item.
+   *
+   * @param array The array to be concatenated.
+   * @param separator The separator between elements.
+   * @return A concatenated string of the items in list.
+   */
+  static <T> String concatenateArray(T[] array, String separator) {
+    if (array == null) {
+      return null;
+    }
+    return concatenateList(Arrays.asList(array), separator);
+  }
+
+  /**
+   * Returns the components of a list as concatenated String by calling toString() on each item.
+   *
+   * @param list The list to be concatenated.
+   * @param separator The separator between elements.
+   * @return A concatenated string of the items in list.
+   */
+  static String concatenateList(List<?> list, String separator) {
+    if (list == null) {
+      return null;
+    }
+    if (separator == null) {
+      separator = "";
+    }
+    StringBuilder stringBuilder = new StringBuilder();
+    for (Object item : list) {
+      if (item != null) {
+        stringBuilder.append(item.toString());
+        stringBuilder.append(separator);
+      }
+    }
+    String result = stringBuilder.toString();
+
+    if (result.length() > 0) {
+      return result.substring(0, result.length() - separator.length());
+    } else {
+      return result;
+    }
+  }
+
+  @SuppressWarnings({"unchecked"})
+  public static <T> T uncheckedCast(Object obj) {
+    return (T) obj;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/Constants.java
@@ -0,0 +1,263 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+//import com.leanplum.BuildConfig;
+
+/**
+ * Leanplum constants.
+ *
+ * @author Andrew First.
+ */
+public class Constants {
+  public static String API_HOST_NAME = "www.leanplum.com";
+  public static String SOCKET_HOST = "dev.leanplum.com";
+  public static int SOCKET_PORT = 80;
+  public static boolean API_SSL = true;
+  public static int NETWORK_TIMEOUT_SECONDS = 10;
+  public static int NETWORK_TIMEOUT_SECONDS_FOR_DOWNLOADS = 10;
+  static final String LEANPLUM_PACKAGE_IDENTIFIER = "s";//BuildConfig.LEANPLUM_PACKAGE_IDENTIFIER;
+
+  public static String LEANPLUM_VERSION = "2.2.2-SNAPSHOT";
+  public static String CLIENT = "android";
+
+  static final String INVALID_MAC_ADDRESS = "02:00:00:00:00:00";
+  static final String INVALID_MAC_ADDRESS_HASH = "0f607264fc6318a92b9e13c65db7cd3c";
+
+  /**
+   * From very old versions of the SDK, leading zeros were stripped from the mac address.
+   */
+  static final String OLD_INVALID_MAC_ADDRESS_HASH = "f607264fc6318a92b9e13c65db7cd3c";
+
+  static final String INVALID_ANDROID_ID = "9774d56d682e549c";
+  static final int MAX_DEVICE_ID_LENGTH = 400;
+  static final int MAX_USER_ID_LENGTH = 400;
+
+  public static String defaultDeviceId = null;
+  public static boolean hashFilesToDetermineModifications = true;
+  public static boolean isDevelopmentModeEnabled = false;
+  public static boolean loggingEnabled = false;
+  public static boolean isTestMode = false;
+  public static boolean enableVerboseLoggingInDevelopmentMode = false;
+  public static boolean enableFileUploadingInDevelopmentMode = true;
+  public static boolean canDownloadContentMidSessionInProduction = false;
+  static boolean isInPermanentFailureState = false;
+
+  public static boolean isNoop() {
+    return isTestMode || isInPermanentFailureState;
+  }
+
+  public static String API_SERVLET = "api";
+
+  public static class Defaults {
+    public static final String COUNT_KEY = "__leanplum_unsynced";
+    public static final String ITEM_KEY = "__leanplum_unsynced_%d";
+    public static final String VARIABLES_KEY = "__leanplum_variables";
+    public static final String ATTRIBUTES_KEY = "__leanplum_attributes";
+    public static final String TOKEN_KEY = "__leanplum_token";
+    public static final String MESSAGES_KEY = "__leanplum_messages";
+    public static final String UPDATE_RULES_KEY = "__leanplum_update_rules";
+    public static final String EVENT_RULES_KEY = "__leanplum_event_rules";
+    public static final String REGIONS_KEY = "regions";
+    public static final String MESSAGE_TRIGGER_OCCURRENCES_KEY =
+        "__leanplum_message_trigger_occurrences_%s";
+    public static final String MESSAGE_IMPRESSION_OCCURRENCES_KEY =
+        "__leanplum_message_occurrences_%s";
+    public static final String MESSAGE_MUTED_KEY = "__leanplum_message_muted_%s";
+    public static final String LOCAL_NOTIFICATION_KEY = "__leanplum_local_message_%s";
+    public static final String INBOX_KEY = "__leanplum_newsfeed";
+    public static final String LEANPLUM_PUSH = "__leanplum_push__";
+    public static final String APP_ID = "__app_id";
+    public static final String PROPERTY_REGISTRATION_ID = "registration_id";
+    public static final String PROPERTY_SENDER_IDS = "sender_ids";
+  }
+
+  public static class Methods {
+    public static final String ADVANCE = "advance";
+    public static final String DELETE_INBOX_MESSAGE = "deleteNewsfeedMessage";
+    public static final String DOWNLOAD_FILE = "downloadFile";
+    public static final String GET_INBOX_MESSAGES = "getNewsfeedMessages";
+    public static final String GET_VARS = "getVars";
+    public static final String HEARTBEAT = "heartbeat";
+    public static final String LOG = "log";
+    public static final String MARK_INBOX_MESSAGE_AS_READ = "markNewsfeedMessageAsRead";
+    public static final String MULTI = "multi";
+    public static final String PAUSE_SESSION = "pauseSession";
+    public static final String PAUSE_STATE = "pauseState";
+    public static final String REGISTER_FOR_DEVELOPMENT = "registerDevice";
+    public static final String RESUME_SESSION = "resumeSession";
+    public static final String RESUME_STATE = "resumeState";
+    public static final String SET_DEVICE_ATTRIBUTES = "setDeviceAttributes";
+    public static final String SET_TRAFFIC_SOURCE_INFO = "setTrafficSourceInfo";
+    public static final String SET_USER_ATTRIBUTES = "setUserAttributes";
+    public static final String SET_VARS = "setVars";
+    public static final String START = "start";
+    public static final String STOP = "stop";
+    public static final String TRACK = "track";
+    public static final String UPLOAD_FILE = "uploadFile";
+  }
+
+  public static class Params {
+    public static final String ACTION = "action";
+    public static final String ACTION_DEFINITIONS = "actionDefinitions";
+    public static final String APP_ID = "appId";
+    public static final String BACKGROUND = "background";
+    public static final String CLIENT = "client";
+    public static final String CLIENT_KEY = "clientKey";
+    public static final String DATA = "data";
+    public static final String DEV_MODE = "devMode";
+    public static final String DEVICE_ID = "deviceId";
+    public static final String DEVICE_MODEL = "deviceModel";
+    public static final String DEVICE_NAME = "deviceName";
+    public static final String DEVICE_PUSH_TOKEN = "gcmRegistrationId";
+    public static final String DEVICE_SYSTEM_NAME = "systemName";
+    public static final String DEVICE_SYSTEM_VERSION = "systemVersion";
+    public static final String EMAIL = "email";
+    public static final String EVENT = "event";
+    public static final String FILE = "file";
+    public static final String FILE_ATTRIBUTES = "fileAttributes";
+    public static final String GOOGLE_PLAY_PURCHASE_DATA = "googlePlayPurchaseData";
+    public static final String GOOGLE_PLAY_PURCHASE_DATA_SIGNATURE =
+        "googlePlayPurchaseDataSignature";
+    public static final String IAP_CURRENCY_CODE = "currencyCode";
+    public static final String IAP_ITEM = "item";
+    public static final String INCLUDE_DEFAULTS = "includeDefaults";
+    public static final String INCLUDE_MESSAGE_ID = "includeMessageId";
+    public static final String INFO = "info";
+    public static final String INSTALL_DATE = "installDate";
+    public static final String KINDS = "kinds";
+    public static final String LIMIT_TRACKING = "limitTracking";
+    public static final String MESSAGE = "message";
+    public static final String MESSAGE_ID = "messageId";
+    public static final String NEW_USER_ID = "newUserId";
+    public static final String INBOX_MESSAGE_ID = "newsfeedMessageId";
+    public static final String INBOX_MESSAGES = "newsfeedMessages";
+    public static final String PARAMS = "params";
+    public static final String SDK_VERSION = "sdkVersion";
+    public static final String STATE = "state";
+    public static final String TIME = "time";
+    public static final String TYPE = "type";
+    public static final String TOKEN = "token";
+    public static final String TRAFFIC_SOURCE = "trafficSource";
+    public static final String UPDATE_DATE = "updateDate";
+    public static final String USER_ID = "userId";
+    public static final String USER_ATTRIBUTES = "userAttributes";
+    public static final String VALUE = "value";
+    public static final String VARS = "vars";
+    public static final String VERSION_NAME = "versionName";
+  }
+
+  public static class Keys {
+    public static final String CITY = "city";
+    public static final String COUNTRY = "country";
+    public static final String DELIVERY_TIMESTAMP = "deliveryTimestamp";
+    public static final String EXPIRATION_TIMESTAMP = "expirationTimestamp";
+    public static final String FILENAME = "filename";
+    public static final String HASH = "hash";
+    public static final String INSTALL_TIME_INITIALIZED = "installTimeInitialized";
+    public static final String IS_READ = "isRead";
+    public static final String IS_REGISTERED = "isRegistered";
+    public static final String IS_REGISTERED_FROM_OTHER_APP = "isRegisteredFromOtherApp";
+    public static final String LATEST_VERSION = "latestVersion";
+    public static final String LOCALE = "locale";
+    public static final String LOCATION = "location";
+    public static final String LOCATION_ACCURACY_TYPE = "locationAccuracyType";
+    public static final String MESSAGE_DATA = "messageData";
+    public static final String MESSAGES = "messages";
+    public static final String UPDATE_RULES = "interfaceRules";
+    public static final String EVENT_RULES = "interfaceEvents";
+    public static final String INBOX_MESSAGES = "newsfeedMessages";
+    public static final String PUSH_MESSAGE_ACTION = "_lpx";
+    public static final String PUSH_MESSAGE_ID_NO_MUTE_WITH_ACTION = "_lpm";
+    public static final String PUSH_MESSAGE_ID_MUTE_WITH_ACTION = "_lpu";
+    public static final String PUSH_MESSAGE_ID_NO_MUTE = "_lpn";
+    public static final String PUSH_MESSAGE_ID_MUTE = "_lpv";
+    public static final String PUSH_MESSAGE_ID = "lp_messageId";
+    public static final String PUSH_MESSAGE_TEXT = "lp_message";
+    public static final String PUSH_MESSAGE_IMAGE_URL = "lp_imageUrl";
+    public static final String REGION = "region";
+    public static final String REGION_STATE = "regionState";
+    public static final String REGIONS = "regions";
+    public static final String SIZE = "size";
+    public static final String SUBTITLE = "Subtitle";
+    public static final String SYNC_INBOX = "syncNewsfeed";
+    public static final String LOGGING_ENABLED = "loggingEnabled";
+    public static final String TIMEZONE = "timezone";
+    public static final String TIMEZONE_OFFSET_SECONDS = "timezoneOffsetSeconds";
+    public static final String TITLE = "Title";
+    public static final String INBOX_IMAGE = "Image";
+    public static final String DATA = "Data";
+    public static final String TOKEN = "token";
+    public static final String VARIANTS = "variants";
+    public static final String VARS = "vars";
+    public static final String VARS_FROM_CODE = "varsFromCode";
+  }
+
+  public static class Kinds {
+    public static final String INT = "integer";
+    public static final String FLOAT = "float";
+    public static final String STRING = "string";
+    public static final String BOOLEAN = "bool";
+    public static final String FILE = "file";
+    public static final String DICTIONARY = "group";
+    public static final String ARRAY = "list";
+    public static final String ACTION = "action";
+    public static final String COLOR = "color";
+  }
+
+  static class Files {
+    public static final int MAX_UPLOAD_BATCH_SIZES = (25 * (1 << 20));
+    public static final int MAX_UPLOAD_BATCH_FILES = 16;
+  }
+
+  public static final String HELD_BACK_EVENT_NAME = "Held Back";
+  public static final String HELD_BACK_MESSAGE_PREFIX = "__held_back__";
+
+  public static class Values {
+    public static final String DETECT = "(detect)";
+    public static final String RESOURCES_VARIABLE = "__Android Resources";
+    public static final String ACTION_ARG = "__name__";
+    public static final String CHAIN_MESSAGE_ARG = "Chained message";
+    public static final String DEFAULT_PUSH_ACTION = "Open action";
+    public static final String CHAIN_MESSAGE_ACTION_NAME = "Chain to Existing Message";
+    public static final String DEFAULT_PUSH_MESSAGE = "Push message goes here.";
+    public static final String SDK_LOG = "sdkLog";
+    public static final String SDK_ERROR = "sdkError";
+    public static final String FILE_PREFIX = "__file__";
+  }
+
+  public static class Crypt {
+    public static final int ITER_COUNT = 1000;
+    public static final int KEY_LENGTH = 256;
+    public static final String SALT = "L3@nP1Vm"; // Must have 8 bytes
+    public static final String IV = "__l3anplum__iv__"; // Must have 16 bytes
+  }
+
+  public static class Messaging {
+    public static final int MAX_STORED_OCCURRENCES_PER_MESSAGE = 100;
+    public static final int DEFAULT_PRIORITY = 1000;
+  }
+
+  public static class ClassUtil {
+    public static final String UI_INTERFACE_EDITOR = "com.leanplum.uieditor.LeanplumUIEditor";
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/FileManager.java
@@ -0,0 +1,501 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.res.Resources;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.text.TextUtils;
+
+import com.leanplum.Leanplum;
+import com.leanplum.Var;
+
+import org.json.JSONObject;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.DigestInputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+/**
+ * Leanplum file manager.
+ *
+ * @author Andrew First
+ */
+public class FileManager {
+  interface ResourceUpdateCallback {
+    void onResourceSyncFinish();
+  }
+
+  private static ResourceUpdateCallback updateCallback;
+  private static boolean isInitialized = false;
+  private static boolean initializing = false;
+  static final Object initializingLock = new Object();
+  public static Var<HashMap<String, Object>> resources = null;
+
+  public enum DownloadFileResult {
+    NONE,
+    EXISTS_IN_ASSETS,
+    DOWNLOADING
+  }
+
+  static class HashResults {
+    final String hash;
+    final int size;
+
+    public HashResults(String hash, int size) {
+      this.hash = hash;
+      this.size = size;
+    }
+  }
+
+  public static DownloadFileResult maybeDownloadFile(boolean isResource, String stringValue,
+      String defaultValue, String urlValue, final Runnable onComplete) {
+    if (stringValue != null && !stringValue.equals(defaultValue) &&
+        (!isResource || VarCache.getResIdFromPath(stringValue) == 0)) {
+      InputStream inputStream = null;
+      try {
+        Context context = Leanplum.getContext();
+        inputStream = context.getResources().getAssets().open(stringValue);
+        if (inputStream != null) {
+          return DownloadFileResult.EXISTS_IN_ASSETS;
+        }
+      } catch (IOException ignored) {
+      } finally {
+        if (inputStream != null) {
+          try {
+            inputStream.close();
+          } catch (IOException e) {
+            Log.w("Failed to close InputStream.", e.getMessage());
+          }
+        }
+      }
+      String realPath = FileManager.fileRelativeToAppBundle(stringValue);
+      if (!FileManager.fileExistsAtPath(realPath)) {
+        realPath = FileManager.fileRelativeToDocuments(stringValue);
+        if (!FileManager.fileExistsAtPath(realPath)) {
+          Request downloadRequest = Request.get(Constants.Methods.DOWNLOAD_FILE, null);
+          downloadRequest.onResponse(new Request.ResponseCallback() {
+            @Override
+            public void response(JSONObject response) {
+              if (onComplete != null) {
+                onComplete.run();
+              }
+            }
+          });
+          downloadRequest.onError(new Request.ErrorCallback() {
+            @Override
+            public void error(Exception e) {
+              if (onComplete != null) {
+                onComplete.run();
+              }
+            }
+          });
+          downloadRequest.downloadFile(stringValue, urlValue);
+          return DownloadFileResult.DOWNLOADING;
+        }
+      }
+    }
+    return DownloadFileResult.NONE;
+  }
+
+  static HashResults fileMD5HashCreateWithPath(InputStream is) {
+    try {
+      MessageDigest md = MessageDigest.getInstance("MD5");
+      int size = 0;
+      try {
+        is = new DigestInputStream(is, md);
+        byte[] buffer = new byte[8192];
+        int bytesRead;
+        while ((bytesRead = is.read(buffer)) != -1) {
+          size += bytesRead;
+        }
+      } finally {
+        if (is != null) {
+          try {
+            is.close();
+          } catch (IOException e) {
+            Log.w("Failed to close InputStream.", e.getMessage());
+          }
+        }
+      }
+      byte[] digest = md.digest();
+
+      StringBuilder hexString = new StringBuilder();
+      for (byte dig : digest) {
+        String hex = Integer.toHexString(0xFF & dig);
+        if (hex.length() == 1) {
+          hexString.append('0');
+        }
+        hexString.append(hex);
+      }
+      return new HashResults(hexString.toString(), size);
+    } catch (NoSuchAlgorithmException e) {
+      e.printStackTrace();
+      return null;
+    } catch (IOException e) {
+      e.printStackTrace();
+      return null;
+    }
+  }
+
+  static int getFileSize(String path) {
+    if (path == null) {
+      return -1;
+    }
+    return (int) new File(path).length();
+  }
+
+  public static boolean fileExistsAtPath(String path) {
+    return path != null && new File(path).exists();
+  }
+
+  @SuppressWarnings("SameReturnValue")
+  private static String appBundlePath() {
+    return "";
+  }
+
+  private static String documentsPath() {
+    Context context = Leanplum.getContext();
+    if (context != null) {
+      return context.getDir("Leanplum_Documents", Context.MODE_PRIVATE).getAbsolutePath();
+    }
+    return null;
+  }
+
+  private static String bundlePath() {
+    Context context = Leanplum.getContext();
+    return context.getDir("Leanplum_Bundle", Context.MODE_PRIVATE).getAbsolutePath();
+  }
+
+  private static String fileRelativeTo(String root, String path) {
+    return root + "/" + path;
+  }
+
+  static String fileRelativeToAppBundle(String path) {
+    if (path == null) {
+      return null;
+    }
+    return fileRelativeTo(appBundlePath(), path);
+  }
+
+  static String fileRelativeToLPBundle(String path) {
+    if (path == null) {
+      return null;
+    }
+    return fileRelativeTo(bundlePath(), path);
+  }
+
+  public static String fileRelativeToDocuments(String path) {
+    if (path == null) {
+      return null;
+    }
+    return fileRelativeTo(documentsPath(), path);
+  }
+
+  static boolean isNewerLocally(
+      Map<String, Object> localAttributes,
+      Map<String, Object> serverAttributes) {
+    if (serverAttributes == null) {
+      return true;
+    }
+    String localHash = (String) localAttributes.get(Constants.Keys.HASH);
+    String serverHash = (String) serverAttributes.get(Constants.Keys.HASH);
+    Integer localSize = (Integer) localAttributes.get(Constants.Keys.SIZE);
+    Integer serverSize = (Integer) serverAttributes.get(Constants.Keys.SIZE);
+    return (serverSize == null || (localSize != null && !localSize.equals(serverSize))) ||
+        (localHash != null && (serverHash == null || !localHash.equals(serverHash)));
+  }
+
+  static void setResourceSyncFinishBlock(ResourceUpdateCallback callback) {
+    updateCallback = callback;
+  }
+
+  static boolean initializing() {
+    return initializing;
+  }
+
+  public static boolean isResourceSyncingEnabled() {
+    return initializing || isInitialized;
+  }
+
+  private static void enableResourceSyncing(List<Pattern> patternsToInclude,
+      List<Pattern> patternsToExclude) {
+    resources = Var.define(Constants.Values.RESOURCES_VARIABLE, new HashMap<String, Object>());
+
+    // This is from http://stackoverflow.com/questions/3052964/is-there-any-way-to-tell-what-folder-a-drawable-resource-was-inflated-from.
+    String drawableDirPrefix = "res/drawable";
+    String layoutDirPrefix = "res/layout";
+    ZipInputStream apk = null;
+    Context context = Leanplum.getContext();
+    try {
+      apk = new ZipInputStream(new FileInputStream(context.getPackageResourcePath()));
+      ZipEntry entry;
+      while ((entry = apk.getNextEntry()) != null) {
+        String resourcePath = entry.getName();
+        if (resourcePath.startsWith(drawableDirPrefix) ||
+            resourcePath.startsWith(layoutDirPrefix)) {
+          String unprefixedResourcePath = resourcePath.substring(4);
+
+          if (patternsToInclude != null &&
+              patternsToInclude.size() > 0) {
+            boolean included = false;
+            for (Pattern pattern : patternsToInclude) {
+              if (pattern.matcher(unprefixedResourcePath).matches()) {
+                included = true;
+                break;
+              }
+            }
+            if (!included) {
+              continue;
+            }
+          }
+          if (patternsToExclude != null) {
+            boolean excluded = false;
+            for (Pattern pattern : patternsToExclude) {
+              if (pattern.matcher(unprefixedResourcePath).matches()) {
+                excluded = true;
+                break;
+              }
+            }
+            if (excluded) {
+              continue;
+            }
+          }
+
+          ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+
+          int bytesRead;
+          int size = 0;
+          byte[] buffer = new byte[8192];
+          while ((bytesRead = apk.read(buffer)) != -1) {
+            outputStream.write(buffer, 0, bytesRead);
+            size += bytesRead;
+          }
+          apk.closeEntry();
+
+          String hash = ("" + entry.getTime()) + ("" + size);
+
+          Var.defineResource(
+              Constants.Values.RESOURCES_VARIABLE
+                  + "." + unprefixedResourcePath.replace(".", "\\.").replace('/', '.'),
+              resourcePath, size, hash, outputStream.toByteArray());
+        }
+      }
+    } catch (IOException e) {
+      Log.w("Error occurred when trying " +
+          "to enable resource syncing." + e.getMessage());
+    } finally {
+      if (apk != null) {
+        try {
+          apk.close();
+        } catch (IOException e) {
+          Log.w("Failed to close ZipInputStream.", e.getMessage());
+        }
+      }
+    }
+    isInitialized = true;
+    synchronized (initializingLock) {
+      initializing = false;
+      if (updateCallback != null) {
+        updateCallback.onResourceSyncFinish();
+      }
+    }
+  }
+
+  private static List<Pattern> compilePatterns(List<String> patterns) {
+    if (patterns == null) {
+      return new ArrayList<>(0);
+    }
+    List<Pattern> compiledPatterns = new ArrayList<>(patterns.size());
+    for (String pattern : patterns) {
+      try {
+        compiledPatterns.add(Pattern.compile(pattern));
+      } catch (PatternSyntaxException e) {
+        Log.e("Ignoring malformed resource syncing pattern: \"" + pattern +
+            "\". Patterns must be regular expressions.");
+      }
+    }
+    return compiledPatterns;
+  }
+
+  public static void enableResourceSyncing(final List<String> patternsToInclude,
+      final List<String> patternsToExclude, boolean isAsync) {
+    initializing = true;
+    if (isInitialized) {
+      return;
+    }
+
+    try {
+      final List<Pattern> compiledIncludePatterns = compilePatterns(patternsToInclude);
+      final List<Pattern> compiledExcludePatterns = compilePatterns(patternsToExclude);
+
+      if (isAsync) {
+        Util.executeAsyncTask(new AsyncTask<Void, Void, Void>() {
+          @Override
+          protected Void doInBackground(Void... params) {
+            try {
+              enableResourceSyncing(compiledIncludePatterns, compiledExcludePatterns);
+            } catch (Throwable t) {
+              Util.handleException(t);
+            }
+            return null;
+          }
+        });
+      } else {
+        enableResourceSyncing(compiledIncludePatterns, compiledExcludePatterns);
+      }
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * Returns an String with path for a specified file/resource/asset variable.
+   *
+   * @param stringValue Name of the file.
+   * @return String Path for a given variable.
+   */
+  public static String fileValue(String stringValue) {
+    return fileValue(stringValue, stringValue, null);
+  }
+
+  public static String fileValue(String stringValue, String defaultValue,
+      Boolean valueIsInAssets) {
+    String result;
+    if (stringValue == null) {
+      return null;
+    }
+    if (stringValue.equals(defaultValue)) {
+      result = FileManager.fileRelativeToAppBundle(defaultValue);
+      if (FileManager.fileExistsAtPath(result)) {
+        return result;
+      }
+    }
+
+    if (valueIsInAssets == null) {
+      InputStream inputStream = null;
+      try {
+        Context context = Leanplum.getContext();
+        inputStream = context.getAssets().open(stringValue);
+        return stringValue;
+      } catch (Exception ignored) {
+      } finally {
+        if (inputStream != null) {
+          try {
+            inputStream.close();
+          } catch (Exception e) {
+            Log.w("Failed to close InputStream: " + e);
+          }
+        }
+      }
+    } else if (valueIsInAssets) {
+      return stringValue;
+    }
+
+    result = FileManager.fileRelativeToLPBundle(stringValue);
+    if (!FileManager.fileExistsAtPath(result)) {
+      result = FileManager.fileRelativeToDocuments(stringValue);
+      if (!FileManager.fileExistsAtPath(result)) {
+        result = FileManager.fileRelativeToAppBundle(stringValue);
+        if (!FileManager.fileExistsAtPath(result)) {
+          result = FileManager.fileRelativeToLPBundle(defaultValue);
+          if (!FileManager.fileExistsAtPath(result)) {
+            result = FileManager.fileRelativeToAppBundle(defaultValue);
+            if (!FileManager.fileExistsAtPath(result)) {
+              return defaultValue;
+            }
+          }
+        }
+      }
+    }
+    return result;
+  }
+
+  /**
+   * Returns an InputStream for a specified file/resource/asset variable. It will automatically find
+   * a proper file based on provided data. File can be located in either assets, res or in-memory.
+   * Caller is responsible for closing the returned InputStream.
+   *
+   * @param isResource Whether variable is resource.
+   * @param isAsset Whether variable is asset.
+   * @param valueIsInAssets Whether variable is located in assets directory.
+   * @param value Name of the file.
+   * @param defaultValue Default name of the file.
+   * @param resourceData Data if file is not located on internal storage.
+   * @return InputStream for a given variable.
+   */
+  public static InputStream stream(boolean isResource, Boolean isAsset, Boolean valueIsInAssets,
+      String value, String defaultValue, byte[] resourceData) {
+    if (TextUtils.isEmpty(value)) {
+      return null;
+    }
+    try {
+      Context context = Leanplum.getContext();
+      if (isResource && value.equals(defaultValue) && resourceData != null) {
+        return new ByteArrayInputStream(resourceData);
+      } else if (isResource && value.equals(defaultValue) && resourceData == null) {
+        // Convert resource name into resource id.
+        int resourceId = Util.generateIdFromResourceName(value);
+        // Android only generates id's greater then 0.
+        if (resourceId != 0) {
+          Resources res = context.getResources();
+          // Based on resource Id, we can extract package it belongs, directory where it is stored
+          // and name of the file.
+          Uri resourceUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE +
+              "://" + res.getResourcePackageName(resourceId)
+              + '/' + res.getResourceTypeName(resourceId)
+              + '/' + res.getResourceEntryName(resourceId));
+          return context.getContentResolver().openInputStream(resourceUri);
+        }
+        return null;
+      }
+      if (valueIsInAssets == null) {
+        try {
+          return context.getAssets().open(value);
+        } catch (IOException ignored) {
+        }
+      } else if (valueIsInAssets || (isAsset && value.equals(defaultValue))) {
+        return context.getAssets().open(value);
+      }
+      return new FileInputStream(new File(value));
+    } catch (IOException e) {
+      Log.w("Failed to load a stream." + e.getMessage());
+      return null;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/HybiParser.java
@@ -0,0 +1,422 @@
+//
+// HybiParser.java: draft-ietf-hybi-thewebsocketprotocol-13 parser
+//
+// Based on code from the faye project.
+// https://github.com/faye/faye-websocket-node
+// Copyright (c) 2009-2012 James Coglan
+//
+// Ported from Javascript to Java by Eric Butler <eric@codebutler.com>
+//
+// (The MIT License)
+//
+// Permission is hereby granted, free of charge, to any person obtaining
+// a copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to
+// permit persons to whom the Software is furnished to do so, subject to
+// the following conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+package com.leanplum.internal;
+
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.Arrays;
+import java.util.List;
+
+class HybiParser {
+  private WebSocketClient mClient;
+
+  private boolean mMasking = true;
+
+  private int mStage;
+
+  private boolean mFinal;
+  private boolean mMasked;
+  private int mOpcode;
+  private int mLengthSize;
+  private int mLength;
+  private int mMode;
+
+  private byte[] mMask = new byte[0];
+  private byte[] mPayload = new byte[0];
+
+  private boolean mClosed = false;
+
+  private ByteArrayOutputStream mBuffer = new ByteArrayOutputStream();
+
+  private static final int BYTE = 255;
+  private static final int FIN = 128;
+  private static final int MASK = 128;
+  private static final int RSV1 = 64;
+  private static final int RSV2 = 32;
+  private static final int RSV3 = 16;
+  private static final int OPCODE = 15;
+  private static final int LENGTH = 127;
+
+  private static final int MODE_TEXT = 1;
+  private static final int MODE_BINARY = 2;
+
+  private static final int OP_CONTINUATION = 0;
+  private static final int OP_TEXT = 1;
+  private static final int OP_BINARY = 2;
+  private static final int OP_CLOSE = 8;
+  private static final int OP_PING = 9;
+  private static final int OP_PONG = 10;
+
+  private static final List<Integer> OPCODES = Arrays.asList(OP_CONTINUATION,
+      OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG);
+
+  private static final List<Integer> FRAGMENTED_OPCODES = Arrays.asList(
+      OP_CONTINUATION, OP_TEXT, OP_BINARY);
+
+  public HybiParser(WebSocketClient client) {
+    mClient = client;
+  }
+
+  private static byte[] mask(byte[] payload, byte[] mask, int offset) {
+    if (mask.length == 0)
+      return payload;
+
+    for (int i = 0; i < payload.length - offset; i++) {
+      payload[offset + i] = (byte) (payload[offset + i] ^ mask[i % 4]);
+    }
+    return payload;
+  }
+
+  public void start(HappyDataInputStream stream) throws IOException {
+    while (true) {
+      if (stream.available() == -1)
+        break;
+      switch (mStage) {
+        case 0:
+          parseOpcode(stream.readByte());
+          break;
+        case 1:
+          parseLength(stream.readByte());
+          break;
+        case 2:
+          parseExtendedLength(stream.readBytes(mLengthSize));
+          break;
+        case 3:
+          mMask = stream.readBytes(4);
+          mStage = 4;
+          break;
+        case 4:
+          mPayload = stream.readBytes(mLength);
+          emitFrame();
+          mStage = 0;
+          break;
+        default:
+          break;
+      }
+    }
+    mClient.getListener().onDisconnect(0, "EOF");
+  }
+
+  private void parseOpcode(byte data) throws ProtocolError {
+    boolean rsv1 = (data & RSV1) == RSV1;
+    boolean rsv2 = (data & RSV2) == RSV2;
+    boolean rsv3 = (data & RSV3) == RSV3;
+
+    if (rsv1 || rsv2 || rsv3) {
+      throw new ProtocolError("RSV not zero");
+    }
+
+    mFinal = (data & FIN) == FIN;
+    mOpcode = (data & OPCODE);
+    mMask = new byte[0];
+    mPayload = new byte[0];
+
+    if (!OPCODES.contains(mOpcode)) {
+      throw new ProtocolError("Bad opcode");
+    }
+
+    if (!FRAGMENTED_OPCODES.contains(mOpcode) && !mFinal) {
+      throw new ProtocolError("Expected non-final packet");
+    }
+
+    mStage = 1;
+  }
+
+  private void parseLength(byte data) {
+    mMasked = (data & MASK) == MASK;
+    mLength = (data & LENGTH);
+
+    if (mLength >= 0 && mLength <= 125) {
+      mStage = mMasked ? 3 : 4;
+    } else {
+      mLengthSize = (mLength == 126) ? 2 : 8;
+      mStage = 2;
+    }
+  }
+
+  private void parseExtendedLength(byte[] buffer) throws ProtocolError {
+    mLength = getInteger(buffer);
+    mStage = mMasked ? 3 : 4;
+  }
+
+  public byte[] frame(String data) {
+    return frame(data, OP_TEXT, -1);
+  }
+
+  public byte[] frame(byte[] data) {
+    return frame(data, OP_BINARY, -1);
+  }
+
+  private byte[] frame(byte[] data, int opcode, int errorCode) {
+    return frame((Object) data, opcode, errorCode);
+  }
+
+  private byte[] frame(String data, int opcode, int errorCode) {
+    return frame((Object) data, opcode, errorCode);
+  }
+
+  private byte[] frame(Object data, int opcode, int errorCode) {
+    if (mClosed)
+      return null;
+
+    // Log.d("Creating frame for: " + data + " op: " + opcode + " err: " +
+    // errorCode);
+
+    byte[] buffer = (data instanceof String) ? decode((String) data)
+        : (byte[]) data;
+    int insert = (errorCode > 0) ? 2 : 0;
+    int length = buffer.length + insert;
+    int header = (length <= 125) ? 2 : (length <= 65535 ? 4 : 10);
+    int offset = header + (mMasking ? 4 : 0);
+    int masked = mMasking ? MASK : 0;
+    byte[] frame = new byte[length + offset];
+
+    frame[0] = (byte) ((byte) FIN | (byte) opcode);
+
+    if (length <= 125) {
+      frame[1] = (byte) (masked | length);
+    } else if (length <= 65535) {
+      frame[1] = (byte) (masked | 126);
+      frame[2] = (byte) Math.floor(length / 256f);
+      frame[3] = (byte) (length & BYTE);
+    } else {
+      frame[1] = (byte) (masked | 127);
+      frame[2] = (byte) (((int) Math.floor(length / Math.pow(2, 56))) & BYTE);
+      frame[3] = (byte) (((int) Math.floor(length / Math.pow(2, 48))) & BYTE);
+      frame[4] = (byte) (((int) Math.floor(length / Math.pow(2, 40))) & BYTE);
+      frame[5] = (byte) (((int) Math.floor(length / Math.pow(2, 32))) & BYTE);
+      frame[6] = (byte) (((int) Math.floor(length / Math.pow(2, 24))) & BYTE);
+      frame[7] = (byte) (((int) Math.floor(length / Math.pow(2, 16))) & BYTE);
+      frame[8] = (byte) (((int) Math.floor(length / Math.pow(2, 8))) & BYTE);
+      frame[9] = (byte) (length & BYTE);
+    }
+
+    if (errorCode > 0) {
+      frame[offset] = (byte) (((int) Math.floor(errorCode / 256f)) & BYTE);
+      frame[offset + 1] = (byte) (errorCode & BYTE);
+    }
+    System.arraycopy(buffer, 0, frame, offset + insert, buffer.length);
+
+    if (mMasking) {
+      byte[] mask = {(byte) Math.floor(Math.random() * 256),
+          (byte) Math.floor(Math.random() * 256),
+          (byte) Math.floor(Math.random() * 256),
+          (byte) Math.floor(Math.random() * 256)};
+      System.arraycopy(mask, 0, frame, header, mask.length);
+      mask(frame, mask, offset);
+    }
+
+    return frame;
+  }
+
+  public void ping(String message) {
+    mClient.send(frame(message, OP_PING, -1));
+  }
+
+  public void close(int code, String reason) {
+    if (mClosed)
+      return;
+    mClient.send(frame(reason, OP_CLOSE, code));
+    mClosed = true;
+  }
+
+  private void emitFrame() throws IOException {
+    byte[] payload = mask(mPayload, mMask, 0);
+    int opcode = mOpcode;
+
+    if (opcode == OP_CONTINUATION) {
+      if (mMode == 0) {
+        throw new ProtocolError("Mode was not set.");
+      }
+      mBuffer.write(payload);
+      if (mFinal) {
+        byte[] message = mBuffer.toByteArray();
+        if (mMode == MODE_TEXT) {
+          mClient.getListener().onMessage(encode(message));
+        } else {
+          mClient.getListener().onMessage(message);
+        }
+        reset();
+      }
+
+    } else if (opcode == OP_TEXT) {
+      if (mFinal) {
+        String messageText = encode(payload);
+        mClient.getListener().onMessage(messageText);
+      } else {
+        mMode = MODE_TEXT;
+        mBuffer.write(payload);
+      }
+
+    } else if (opcode == OP_BINARY) {
+      if (mFinal) {
+        mClient.getListener().onMessage(payload);
+      } else {
+        mMode = MODE_BINARY;
+        mBuffer.write(payload);
+      }
+
+    } else if (opcode == OP_CLOSE) {
+      int code = (payload.length >= 2) ? 256 * payload[0] + payload[1] : 0;
+      String reason = (payload.length > 2) ? encode(slice(payload, 2)) : null;
+      // Log.d("Got close op! " + code + " " + reason);
+      mClient.getListener().onDisconnect(code, reason);
+
+    } else if (opcode == OP_PING) {
+      if (payload.length > 125) {
+        throw new ProtocolError("Ping payload too large");
+      }
+      // Log.d("Sending pong!!");
+      mClient.sendFrame(frame(payload, OP_PONG, -1));
+
+    } else if (opcode == OP_PONG) {
+      // String message = encode(payload);
+      // FIXME: Fire callback...
+      // Log.d("Got pong! " + message);
+    }
+  }
+
+  private void reset() {
+    mMode = 0;
+    mBuffer.reset();
+  }
+
+  private String encode(byte[] buffer) {
+    try {
+      return new String(buffer, "UTF-8");
+    } catch (UnsupportedEncodingException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private byte[] decode(String string) {
+    try {
+      return (string).getBytes("UTF-8");
+    } catch (UnsupportedEncodingException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private int getInteger(byte[] bytes) throws ProtocolError {
+    long i = byteArrayToLong(bytes, 0, bytes.length);
+    if (i < 0 || i > Integer.MAX_VALUE) {
+      throw new ProtocolError("Bad integer: " + i);
+    }
+    return (int) i;
+  }
+
+  /**
+   * Copied from AOSP Arrays.java.
+   */
+  /**
+   * Copies elements from {@code original} into a new array, from indexes start (inclusive) to end
+   * (exclusive). The original order of elements is preserved. If {@code end} is greater than {@code
+   * original.length}, the result is padded with the value {@code (byte) 0}.
+   *
+   * @param original the original array
+   * @param start the start index, inclusive
+   * @param end the end index, exclusive
+   * @return the new array
+   * @throws ArrayIndexOutOfBoundsException if {@code start < 0 || start > original.length}
+   * @throws IllegalArgumentException if {@code start > end}
+   * @throws NullPointerException if {@code original == null}
+   * @since 1.6
+   */
+  private static byte[] copyOfRange(byte[] original, int start, int end) {
+    if (start > end) {
+      throw new IllegalArgumentException();
+    }
+    int originalLength = original.length;
+    if (start < 0 || start > originalLength) {
+      throw new ArrayIndexOutOfBoundsException();
+    }
+    int resultLength = end - start;
+    int copyLength = Math.min(resultLength, originalLength - start);
+    byte[] result = new byte[resultLength];
+    System.arraycopy(original, start, result, 0, copyLength);
+    return result;
+  }
+
+  private byte[] slice(byte[] array, int start) {
+    return copyOfRange(array, start, array.length);
+  }
+
+  @SuppressWarnings("serial")
+  static class ProtocolError extends IOException {
+    public ProtocolError(String detailMessage) {
+      super(detailMessage);
+    }
+  }
+
+  private static long byteArrayToLong(byte[] b, int offset, int length) {
+    if (b.length < length)
+      throw new IllegalArgumentException(
+          "length must be less than or equal to b.length");
+
+    long value = 0;
+    for (int i = 0; i < length; i++) {
+      int shift = (length - 1 - i) * 8;
+      value += (b[i + offset] & 0x000000FF) << shift;
+    }
+    return value;
+  }
+
+  static class HappyDataInputStream extends DataInputStream {
+    public HappyDataInputStream(InputStream in) {
+      super(in);
+    }
+
+    public byte[] readBytes(int length) throws IOException {
+      byte[] buffer = new byte[length];
+
+      int total = 0;
+
+      while (total < length) {
+        int count = read(buffer, total, length - total);
+        if (count == -1) {
+          break;
+        }
+        total += count;
+      }
+
+      if (total != length) {
+        throw new IOException(
+            String.format("Read wrong number of bytes. Got: %s, Expected: %s.",
+                total, length));
+      }
+
+      return buffer;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/JsonConverter.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.text.Editable;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Converts objects to/from JSON.
+ *
+ * @author Andrew First
+ */
+public class JsonConverter {
+  public static String toJson(Map<String, ?> map) {
+    if (map == null) {
+      return null;
+    }
+    try {
+      return mapToJsonObject(map).toString();
+    } catch (JSONException e) {
+      Log.e("Error converting " + map + " to JSON", e);
+      return null;
+    }
+  }
+
+  public static Map<String, Object> fromJson(String json) {
+    if (json == null) {
+      return null;
+    }
+    try {
+      return mapFromJson(new JSONObject(json));
+    } catch (JSONException e) {
+      Log.e("Error converting " + json + " from JSON", e);
+      return null;
+    }
+  }
+
+  public static JSONObject mapToJsonObject(Map<String, ?> map) throws JSONException {
+    if (map == null) {
+      return null;
+    }
+    JSONObject obj = new JSONObject();
+    for (Map.Entry<String, ?> entry : map.entrySet()) {
+      String key = entry.getKey();
+      Object value = entry.getValue();
+      if (value instanceof Map) {
+        Map<String, ?> mappedValue = CollectionUtil.uncheckedCast(value);
+        value = mapToJsonObject(mappedValue);
+      } else if (value instanceof Iterable) {
+        value = listToJsonArray((Iterable<?>) value);
+      } else if (value instanceof Editable) {
+        value = value.toString();
+      } else if (value == null) {
+        value = JSONObject.NULL;
+      }
+      obj.put(key, value);
+    }
+    return obj;
+  }
+
+
+  public static JSONArray listToJsonArray(Iterable<?> list) throws JSONException {
+    if (list == null) {
+      return null;
+    }
+    JSONArray obj = new JSONArray();
+    for (Object value : list) {
+      if (value instanceof Map) {
+        Map<String, ?> mappedValue = CollectionUtil.uncheckedCast(value);
+        value = mapToJsonObject(mappedValue);
+      } else if (value instanceof Iterable) {
+        value = listToJsonArray((Iterable<?>) value);
+      } else if (value == null) {
+        value = JSONObject.NULL;
+      }
+      obj.put(value);
+    }
+    return obj;
+  }
+
+  public static <T> Map<String, T> mapFromJson(JSONObject object) {
+    if (object == null) {
+      return null;
+    }
+    Map<String, T> result = new HashMap<>();
+    Iterator<?> keysIterator = object.keys();
+    while (keysIterator.hasNext()) {
+      String key = (String) keysIterator.next();
+      Object value = object.opt(key);
+      if (value == null || value == JSONObject.NULL) {
+        value = null;
+      } else if (value instanceof JSONObject) {
+        value = mapFromJson((JSONObject) value);
+      } else if (value instanceof JSONArray) {
+        value = listFromJson((JSONArray) value);
+      } else if (JSONObject.NULL.equals(value)) {
+        value = null;
+      }
+      T castedValue = CollectionUtil.uncheckedCast(value);
+      result.put(key, castedValue);
+    }
+    return result;
+  }
+
+  public static <T> Map<String, T> mapFromJsonOrDefault(JSONObject object) {
+    if (object == null) {
+      return new HashMap<>();
+    }
+    return mapFromJson(object);
+  }
+
+  public static <T> List<T> listFromJson(JSONArray json) {
+    if (json == null) {
+      return null;
+    }
+    List<Object> result = new ArrayList<>(json.length());
+    for (int i = 0; i < json.length(); i++) {
+      Object value = json.opt(i);
+      if (value == null || value == JSONObject.NULL) {
+        value = null;
+      } else if (value instanceof JSONObject) {
+        value = mapFromJson((JSONObject) value);
+      } else if (value instanceof JSONArray) {
+        value = listFromJson((JSONArray) value);
+      } else if (JSONObject.NULL.equals(value)) {
+        value = null;
+      }
+      result.add(value);
+    }
+    return CollectionUtil.uncheckedCast(result);
+  }
+
+  public static <T> List<T> listFromJsonOrDefault(JSONArray json) {
+    if (json == null) {
+      return new ArrayList<>();
+    }
+    return listFromJson(json);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/LeanplumInternal.java
@@ -0,0 +1,666 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.location.Address;
+import android.location.Geocoder;
+import android.location.Location;
+import android.os.AsyncTask;
+
+import com.leanplum.ActionContext;
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+import com.leanplum.LeanplumException;
+import com.leanplum.LeanplumLocationAccuracyType;
+import com.leanplum.callbacks.ActionCallback;
+import com.leanplum.callbacks.StartCallback;
+import com.leanplum.callbacks.VariablesChangedCallback;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.ConcurrentModificationException;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Internal leanplum methods that are very generic but should not be exposed to the public.
+ *
+ * @author Ben Marten
+ */
+public class LeanplumInternal {
+  private static final String ACTION = "action";
+  private static boolean hasStartedAndRegisteredAsDeveloper;
+  private static final Map<String, List<ActionCallback>> actionHandlers = new HashMap<>();
+  private static boolean issuedStart;
+  private static boolean hasStarted;
+  private static boolean startSuccessful;
+  private static boolean calledStart;
+  private static boolean isPaused;
+
+  private static boolean startedInBackground;
+  private static boolean inForeground;
+  private static final Object inForegroundLock = new Object();
+  private static final Queue<Map<String, ?>> userAttributeChanges = new ConcurrentLinkedQueue<>();
+  private static final ArrayList<Runnable> startIssuedHandlers = new ArrayList<>();
+  private static boolean isScreenTrackingEnabled = false;
+
+  private static void onHasStartedAndRegisteredAsDeveloperAndFinishedSyncing() {
+    if (!hasStartedAndRegisteredAsDeveloper) {
+      hasStartedAndRegisteredAsDeveloper = true;
+    }
+  }
+
+  /**
+   * Called when the development mode has started and the device is registered successfully.
+   */
+  public static void onHasStartedAndRegisteredAsDeveloper() {
+    synchronized (FileManager.initializingLock) {
+      if (FileManager.initializing()) {
+        FileManager.setResourceSyncFinishBlock(new FileManager.ResourceUpdateCallback() {
+          @Override
+          public void onResourceSyncFinish() {
+            try {
+              onHasStartedAndRegisteredAsDeveloperAndFinishedSyncing();
+            } catch (Throwable t) {
+              Util.handleException(t);
+            }
+          }
+        });
+      } else {
+        onHasStartedAndRegisteredAsDeveloperAndFinishedSyncing();
+      }
+    }
+  }
+
+  public static void triggerAction(ActionContext context) {
+    triggerAction(context, null);
+  }
+
+  private static void triggerAction(final ActionContext context,
+      final VariablesChangedCallback handledCallback) {
+    List<ActionCallback> callbacks;
+    synchronized (actionHandlers) {
+      List<ActionCallback> handlers = actionHandlers.get(context.actionName());
+      if (handlers == null) {
+        // Handled by default.
+        if (handledCallback != null) {
+          handledCallback.variablesChanged();
+        }
+        return;
+      }
+      callbacks = new ArrayList<>(handlers);
+    }
+    final AtomicBoolean handled = new AtomicBoolean(false);
+    for (final ActionCallback callback : callbacks) {
+      OsHandler.getInstance().post(new Runnable() {
+        @Override
+        public void run() {
+          if (callback.onResponse(context) && handledCallback != null) {
+            if (!handled.getAndSet(true)) {
+              handledCallback.variablesChanged();
+            }
+          }
+        }
+      });
+    }
+  }
+
+  public static void maybePerformActions(String when, String eventName, int filter,
+      String fromMessageId, ActionContext.ContextualValues contextualValues) {
+    maybePerformActions(new String[] {when}, eventName, filter, fromMessageId, contextualValues);
+  }
+
+  static void maybePerformActions(String[] whenConditions, String eventName, int filter,
+      String fromMessageId, ActionContext.ContextualValues contextualValues) {
+    Map<String, Object> messages = VarCache.messages();
+    if (messages == null) {
+      return;
+    }
+
+    ArrayList<ActionContext> actionContexts = new ArrayList<>();
+    for (final String messageId : messages.keySet()) {
+      if (messageId != null && messageId.equals(fromMessageId)) {
+        continue;
+      }
+
+      Map<String, Object> messageConfig = CollectionUtil.uncheckedCast(messages.get(messageId));
+
+      String actionType = (String) messageConfig.get(ACTION);
+      if (actionType == null) {
+        continue;
+      }
+
+      final String internalMessageId;
+      if (actionType.equals(ActionManager.HELD_BACK_ACTION_NAME)) {
+        // Spoof the message ID for held back messages,
+        // and store the original ID for the track event.
+        internalMessageId = Constants.HELD_BACK_MESSAGE_PREFIX + messageId;
+      } else {
+        // If this is not a holdback, the internal message ID is the same as the original.
+        internalMessageId = messageId;
+      }
+
+      boolean isForeground = !actionType.equals(ActionManager.PUSH_NOTIFICATION_ACTION_NAME);
+      if (isForeground &&
+          ((filter & LeanplumMessageMatchFilter.LEANPLUM_ACTION_FILTER_FOREGROUND) == 0)) {
+        continue;
+      }
+
+      ActionManager.MessageMatchResult result = new ActionManager.MessageMatchResult();
+      for (String when : whenConditions) {
+        ActionManager.MessageMatchResult conditionResult = ActionManager.getInstance()
+            .shouldShowMessage(internalMessageId, messageConfig, when, eventName, contextualValues);
+        result.matchedTrigger |= conditionResult.matchedTrigger;
+        result.matchedUnlessTrigger |= conditionResult.matchedUnlessTrigger;
+        result.matchedLimit |= conditionResult.matchedLimit;
+      }
+
+      // Make sure we cancel before matching in case the criteria overlap.
+      if (result.matchedUnlessTrigger) {
+        String cancelActionName = "__Cancel" + messageConfig.get(ACTION);
+        LeanplumInternal.triggerAction(new ActionContext(
+                cancelActionName, new HashMap<String, Object>(), messageId),
+            new VariablesChangedCallback() {
+              @Override
+              public void variablesChanged() {
+                // Track cancel.
+                try {
+                  Map<String, String> requestArgs = new HashMap<>();
+                  requestArgs.put(Constants.Params.MESSAGE_ID, messageId);
+                  track("Cancel", 0.0, null, null, requestArgs);
+                } catch (Throwable t) {
+                  Util.handleException(t);
+                }
+              }
+            });
+      }
+
+      if (result.matchedTrigger) {
+        ActionManager.getInstance().recordMessageTrigger(internalMessageId);
+
+        if (result.matchedLimit) {
+          int priority;
+          if (messageConfig.containsKey("priority")) {
+            priority = (int) messageConfig.get("priority");
+          } else {
+            priority = Constants.Messaging.DEFAULT_PRIORITY;
+          }
+          Map<String, Object> vars = CollectionUtil.uncheckedCast(messageConfig.get(Constants
+              .Keys.VARS));
+          ActionContext actionContext = new ActionContext(
+              messageConfig.get(ACTION).toString(),
+              vars,
+              internalMessageId,
+              messageId,
+              priority);
+          actionContext.setContextualValues(contextualValues);
+          actionContexts.add(actionContext);
+        }
+      }
+    }
+
+    if (!actionContexts.isEmpty()) {
+      Collections.sort(actionContexts);
+      int priorityThreshold = actionContexts.get(0).getPriority();
+      for (final ActionContext actionContext : actionContexts) {
+        if (actionContext.getPriority() <= priorityThreshold) {
+          if (actionContext.actionName().equals(ActionManager.HELD_BACK_ACTION_NAME)) {
+            ActionManager.getInstance().recordHeldBackImpression(
+                actionContext.getMessageId(), actionContext.getOriginalMessageId());
+          } else {
+            LeanplumInternal.triggerAction(actionContext, new VariablesChangedCallback() {
+              @Override
+              public void variablesChanged() {
+                try {
+                  ActionManager.getInstance().recordMessageImpression(actionContext.getMessageId());
+                } catch (Throwable t) {
+                  Util.handleException(t);
+                }
+              }
+            });
+          }
+        } else {
+          break;
+        }
+      }
+    }
+  }
+
+  public static void track(final String event, double value, String info,
+      Map<String, ?> params, Map<String, String> args) {
+    if (Constants.isNoop()) {
+      return;
+    }
+
+    try {
+      final Map<String, Object> requestParams = new HashMap<>();
+      if (args != null) {
+        requestParams.putAll(args);
+      }
+      requestParams.put(Constants.Params.VALUE, Double.toString(value));
+      requestParams.put(Constants.Params.INFO, info);
+      if (event != null) {
+        requestParams.put(Constants.Params.EVENT, event);
+      }
+      if (params != null) {
+        params = validateAttributes(params, "params", false);
+        requestParams.put(Constants.Params.PARAMS, JsonConverter.toJson(params));
+      }
+      if (!inForeground || LeanplumActivityHelper.isActivityPaused()) {
+        requestParams.put("allowOffline", Boolean.TRUE.toString());
+      }
+
+      trackInternalWhenStarted(event, params, requestParams);
+    } catch (Throwable t) {
+      Util.handleException(t);
+    }
+  }
+
+  /**
+   * Performs the track operation once Leanplum has started. If Leanplum is already started, perform
+   * the track immediately.
+   *
+   * @param event The event name. Event may be empty for message impression events.
+   * @param params The event parameters.
+   * @param requestArgs The arguments to send with the API request.
+   */
+  private static void trackInternalWhenStarted(final String event,
+      final Map<String, ?> params, final Map<String, Object> requestArgs) {
+    if (issuedStart) {
+      trackInternal(event, params, requestArgs);
+    } else {
+      addStartIssuedHandler(new Runnable() {
+        @Override
+        public void run() {
+          try {
+            trackInternal(event, params, requestArgs);
+          } catch (Throwable t) {
+            Util.handleException(t);
+          }
+        }
+      });
+    }
+  }
+
+  /**
+   * Performs the track API and any actions that are associated with the event.
+   *
+   * @param event The event name. Event may be empty for message impression events.
+   * @param params The event parameters.
+   * @param requestArgs The arguments to send with the API request.
+   */
+  private static void trackInternal(String event, Map<String, ?> params,
+      Map<String, Object> requestArgs) {
+    Request.post(Constants.Methods.TRACK, requestArgs).send();
+
+    String eventTriggerName = event;
+    String messageId = null;
+    if (requestArgs.get(Constants.Params.MESSAGE_ID) != null) {
+      messageId = requestArgs.get(Constants.Params.MESSAGE_ID).toString();
+      eventTriggerName = ".m" + messageId;
+      if (event != null && event.length() > 0) {
+        eventTriggerName += " " + event;
+      }
+    }
+
+    ActionContext.ContextualValues contextualValues = new ActionContext.ContextualValues();
+    contextualValues.parameters = params;
+    contextualValues.arguments = requestArgs;
+    if (contextualValues.arguments.containsKey(Constants.Params.PARAMS)) {
+      try {
+        contextualValues.arguments.put(Constants.Params.PARAMS,
+            new JSONObject(contextualValues.arguments.get(Constants.Params.PARAMS).toString()));
+      } catch (JSONException ignored) {
+      }
+    }
+    LeanplumInternal.maybePerformActions("event", eventTriggerName,
+        LeanplumMessageMatchFilter.LEANPLUM_ACTION_FILTER_ALL, messageId, contextualValues);
+  }
+
+  public static void performTrackedAction(String actionName, String messageId) {
+    Map<String, Object> messages = VarCache.messages();
+    if (messages == null) {
+      return;
+    }
+    Map<String, Object> messageConfig = CollectionUtil.uncheckedCast(messages.get(messageId));
+    if (messageConfig == null) {
+      return;
+    }
+    Map<String, Object> vars = CollectionUtil.uncheckedCast(messageConfig.get(Constants.Keys.VARS));
+    ActionContext context = new ActionContext(
+        messageConfig.get(ACTION).toString(),
+        vars,
+        messageId);
+    context.runTrackedActionNamed(actionName);
+  }
+
+  /**
+   * Callback for setUserLocationAttribute.
+   */
+  public interface locationAttributeRequestsCallback {
+    void response(boolean success);
+  }
+
+  /**
+   * Callback for setUserLocationAttribute.
+   *
+   * @param location Location of the device.
+   * @param locationAccuracyType LocationAccuracyType of the location.
+   * @param callback Callback for the requests was made.
+   */
+  public static void setUserLocationAttribute(final Location location,
+      final LeanplumLocationAccuracyType locationAccuracyType,
+      final locationAttributeRequestsCallback callback) {
+    Leanplum.addStartResponseHandler(new StartCallback() {
+      @Override
+      public void onResponse(final boolean success) {
+        // Geocoder query must be executed on background thread.
+        Util.executeAsyncTask(new AsyncTask<Void, Void, Void>() {
+          @Override
+          protected Void doInBackground(Void... voids) {
+            if (!success) {
+              return null;
+            }
+            if (location == null) {
+              Log.e("Location can't be null in setUserLocationAttribute.");
+              return null;
+            }
+            String latLongLocation = String.format(Locale.US, "%.6f,%.6f", location.getLatitude(),
+                location.getLongitude());
+            HashMap<String, Object> params = new HashMap<>();
+            params.put(Constants.Keys.LOCATION, latLongLocation);
+            params.put(Constants.Keys.LOCATION_ACCURACY_TYPE,
+                locationAccuracyType.name().toLowerCase());
+            if (Leanplum.getContext() != null && Locale.US != null) {
+              Geocoder geocoder = new Geocoder(Leanplum.getContext(), Locale.US);
+              try {
+                List<Address> addresses = geocoder.getFromLocation(location.getLatitude(),
+                    location.getLongitude(), 1);
+                if (addresses != null && addresses.size() > 0) {
+                  Address address = addresses.get(0);
+                  params.put(Constants.Keys.CITY, address.getLocality());
+                  params.put(Constants.Keys.REGION, address.getAdminArea());
+                  params.put(Constants.Keys.COUNTRY, address.getCountryCode());
+                }
+              } catch (IOException e) {
+                Log.e("Failed to connect to Geocoder: " + e);
+              }
+            }
+            Request req = Request.post(Constants.Methods.SET_USER_ATTRIBUTES, params);
+            req.onResponse(new Request.ResponseCallback() {
+              @Override
+              public void response(JSONObject response) {
+                callback.response(true);
+              }
+            });
+            req.onError(new Request.ErrorCallback() {
+              @Override
+              public void error(Exception e) {
+                callback.response(false);
+                Log.e("setUserAttributes failed when specifying location with error: " +
+                    e.getMessage());
+              }
+            });
+            req.send();
+            return null;
+          }
+        });
+      }
+    });
+  }
+
+  public static void recordAttributeChanges() {
+    boolean madeChanges = false;
+    for (Map<String, ?> attributes : userAttributeChanges) {
+      Map<String, Object> existingAttributes = VarCache.userAttributes();
+      if (existingAttributes == null) {
+        existingAttributes = new HashMap<>();
+      }
+      for (String attributeName : attributes.keySet()) {
+        Object existingValue = existingAttributes.get(attributeName);
+        Object value = attributes.get(attributeName);
+        if ((existingValue == null && value != null) ||
+            (existingValue != null && !existingValue.equals(value))) {
+          ActionContext.ContextualValues contextualValues = new ActionContext.ContextualValues();
+          contextualValues.previousAttributeValue = existingValue;
+          contextualValues.attributeValue = value;
+          existingAttributes.put(attributeName, value);
+          LeanplumInternal.maybePerformActions("userAttribute", attributeName,
+              LeanplumMessageMatchFilter.LEANPLUM_ACTION_FILTER_ALL, null, contextualValues);
+          madeChanges = true;
+        }
+      }
+    }
+    userAttributeChanges.clear();
+    if (madeChanges) {
+      VarCache.saveUserAttributes();
+    }
+  }
+
+  public static void moveToForeground() {
+    synchronized (inForegroundLock) {
+      if (inForeground) {
+        return;
+      }
+      inForeground = true;
+    }
+
+    Leanplum.addStartResponseHandler(new StartCallback() {
+      @Override
+      public void onResponse(boolean success) {
+        if (!success) {
+          return;
+        }
+        if (Constants.isDevelopmentModeEnabled && !Constants.isNoop()) {
+          Socket.getInstance();
+        }
+        maybePerformActions(new String[] {"start", "resume"}, null,
+            LeanplumMessageMatchFilter.LEANPLUM_ACTION_FILTER_ALL, null, null);
+        recordAttributeChanges();
+      }
+    });
+  }
+
+  public static void addStartIssuedHandler(Runnable handler) {
+    if (handler == null) {
+      Log.e("addStartIssuedHandler - Invalid handler parameter provided.");
+      return;
+    }
+
+    synchronized (startIssuedHandlers) {
+      if (!issuedStart) {
+        startIssuedHandlers.add(handler);
+        return;
+      }
+    }
+    handler.run();
+  }
+
+  public static void triggerStartIssued() {
+    synchronized (startIssuedHandlers) {
+      LeanplumInternal.setIssuedStart(true);
+      for (Runnable callback : startIssuedHandlers) {
+        OsHandler.getInstance().post(callback);
+      }
+      startIssuedHandlers.clear();
+    }
+  }
+
+  /**
+   * Validates that the attributes are of the correct format. User attributes must be Strings,
+   * Booleans, or Numbers.
+   *
+   * @param argName Argument name used for error messages.
+   * @param allowLists Whether attribute values can be lists.
+   */
+  public static <T> Map<String, T> validateAttributes(Map<String, T> attributes, String argName,
+      boolean allowLists) {
+    if (attributes == null) {
+      return null;
+    }
+    Map<String, T> validAttributes = new HashMap<>();
+    try {
+      for (Map.Entry<String, T> entry : attributes.entrySet()) {
+        T value = entry.getValue();
+
+        if (value == null) {
+          continue;
+        }
+
+        // Validate lists.
+        if (allowLists && value instanceof Iterable<?>) {
+          boolean valid = true;
+          Iterable<Object> iterable = CollectionUtil.uncheckedCast(value);
+          for (Object item : iterable) {
+            if (!isValidScalarValue(item, argName)) {
+              valid = false;
+              break;
+            }
+          }
+          if (!valid) {
+            continue;
+          }
+
+          // Validate scalars.
+        } else {
+          if (value instanceof Date) {
+            Date date = CollectionUtil.uncheckedCast(value);
+            value = CollectionUtil.uncheckedCast(date.getTime());
+          }
+          if (!isValidScalarValue(value, argName)) {
+            continue;
+          }
+        }
+        validAttributes.put(entry.getKey(), value);
+      }
+    } catch (ConcurrentModificationException e) {
+      maybeThrowException(new LeanplumException("ConcurrentModificationException: You cannot " +
+          "modify Map<String, ?> attributes/parameters. Will override with an empty map"));
+      validAttributes = new HashMap<>();
+    }
+    return validAttributes;
+  }
+
+  @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+  private static boolean isValidScalarValue(Object value, String argName) {
+    if (!(value instanceof Number) && !(value instanceof String) && !(value instanceof Boolean)) {
+      maybeThrowException(new LeanplumException(
+          argName + " values must be of type String, Number, or Boolean."));
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * Throws a LeanplumException when in development mode. Otherwise, logs the exception to prevent
+   * the app from crashing.
+   */
+  public static void maybeThrowException(RuntimeException e) {
+    if (Constants.isDevelopmentModeEnabled) {
+      throw e;
+    } else {
+      Log.e(e.getMessage() + " This error is only thrown in development mode.", e);
+    }
+  }
+
+  /***
+   * Getters & Setters
+   ***/
+  public static boolean hasStartedAndRegisteredAsDeveloper() {
+    return hasStartedAndRegisteredAsDeveloper;
+  }
+
+  public static Map<String, List<ActionCallback>> getActionHandlers() {
+    return actionHandlers;
+  }
+
+  public static boolean issuedStart() {
+    return issuedStart;
+  }
+
+  @SuppressWarnings("SameParameterValue")
+  private static void setIssuedStart(boolean issuedStart) {
+    LeanplumInternal.issuedStart = issuedStart;
+  }
+
+  public static boolean hasStarted() {
+    return hasStarted;
+  }
+
+  public static void setHasStarted(boolean hasStarted) {
+    LeanplumInternal.hasStarted = hasStarted;
+  }
+
+  public static boolean isStartSuccessful() {
+    return startSuccessful;
+  }
+
+  public static void setStartSuccessful(boolean startSuccessful) {
+    LeanplumInternal.startSuccessful = startSuccessful;
+  }
+
+  public static boolean hasCalledStart() {
+    return calledStart;
+  }
+
+  public static void setCalledStart(boolean calledStart) {
+    LeanplumInternal.calledStart = calledStart;
+  }
+
+  public static boolean isPaused() {
+    return isPaused;
+  }
+
+  public static void setIsPaused(boolean isPaused) {
+    LeanplumInternal.isPaused = isPaused;
+  }
+
+  public static boolean hasStartedInBackground() {
+    return startedInBackground;
+  }
+
+  public static void setStartedInBackground(boolean startedInBackground) {
+    LeanplumInternal.startedInBackground = startedInBackground;
+  }
+
+  public static Queue<Map<String, ?>> getUserAttributeChanges() {
+    return userAttributeChanges;
+  }
+
+  public static boolean getIsScreenTrackingEnabled() {
+    return isScreenTrackingEnabled;
+  }
+
+  public static void enableAutomaticScreenTracking() {
+    isScreenTrackingEnabled = true;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/LeanplumManifestHelper.java
@@ -0,0 +1,583 @@
+/*
+ * Copyright 2017, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.text.TextUtils;
+
+import com.leanplum.Leanplum;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import java.io.ByteArrayInputStream;
+import java.io.DataInputStream;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.jar.JarFile;
+import java.util.zip.ZipEntry;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+/**
+ * LeanplumManifestHelper class to work with AndroidManifest components.
+ *
+ * @author Anna Orlova
+ */
+public class LeanplumManifestHelper {
+  private static final String MANIFEST = "manifest";
+  private static final String APPLICATION = "application";
+  private static final String SERVICE = "service";
+  private static final String ACTIVITY = "activity";
+  private static final String ACTIVITY_ALIAS = "activity-alias";
+  private static final String RECEIVER = "receiver";
+  private static final String PROVIDER = "provider";
+  private static final String ANDROID_NAME = "android:name";
+  private static final String ANDROID_SCHEME = "android:scheme";
+  private static final String ACTION = "action";
+  private static final String CATEGORY = "category";
+  private static final String DATA = "data";
+  private static final String ANDROID_HOST = "android:host";
+  private static final String ANDROID_PORT = "android:port";
+  private static final String ANDROID_PATH = "android:path";
+  private static final String ANDROID_PATH_PATTERN = "android:pathPattern";
+  private static final String ANDROID_PATH_PREFIX = "android:pathPrefix";
+  private static final String ANDROID_MIME_TYPE = "android:mimeType";
+  private static final String ANDROID_TYPE = "android:type";
+  private static final String INTENT_FILTER = "intent-filter";
+  private static final String ANDROID_PERMISSION = "android:permission";
+  private static final String ANDROID_MANIFEST = "AndroidManifest.xml";
+  private static final String VERSION_NAME = "versionName";
+
+  private static ManifestData manifestData;
+
+  /**
+   * Gets application components from AndroidManifest.xml file.
+   */
+  private static void parseManifestNodeChildren() {
+    manifestData = new ManifestData();
+    byte[] manifestXml = getByteArrayOfManifest();
+    Document manifestDocument = getManifestDocument(manifestXml);
+    parseManifestDocument(manifestDocument);
+  }
+
+  /**
+   * Parse AndroidManifest.xml file to byte array.
+   *
+   * @return byte[] Byte array of AndroidManifest.xml file
+   */
+  private static byte[] getByteArrayOfManifest() {
+    Context context = Leanplum.getContext();
+    if (context == null) {
+      Log.e("Context is null. Cannot parse " + ANDROID_MANIFEST + " file.");
+      return null;
+    }
+    byte[] manifestXml = null;
+    try {
+      JarFile jarFile = new JarFile(context.getPackageResourcePath());
+      ZipEntry entry = jarFile.getEntry(ANDROID_MANIFEST);
+      manifestXml = new byte[(int) entry.getSize()];
+      DataInputStream dataInputStream = new DataInputStream(jarFile.getInputStream(entry));
+      dataInputStream.readFully(manifestXml);
+      dataInputStream.close();
+    } catch (Exception e) {
+      Log.e("Cannot parse " + ANDROID_MANIFEST + " file: " + e.getMessage());
+    }
+    return manifestXml;
+  }
+
+  /**
+   * Gets Document {@link Document} of AndroidManifest.xml file.
+   *
+   * @param manifestXml Byte array of AndroidManifest.xml file data.
+   * @return Document Document with date of AndroidManifest.xml file.
+   */
+  private static Document getManifestDocument(byte[] manifestXml) {
+    Document document = null;
+    try {
+      DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
+      DocumentBuilder builder = builderFactory.newDocumentBuilder();
+      document = builder.parse(new ByteArrayInputStream(
+          LeanplumManifestParser.decompressXml(manifestXml).getBytes("UTF-8")));
+    } catch (Exception e) {
+      Log.e("Cannot parse " + ANDROID_MANIFEST + " file: " + e.getMessage());
+    }
+    return document;
+  }
+
+  /**
+   * Parse data from Document {@link Document} with date of AndroidManifest.xml file.
+   *
+   * @param document Document {@link Document} with date of AndroidManifest.xml file.
+   */
+  private static void parseManifestDocument(Document document) {
+    if (document == null) {
+      return;
+    }
+    parseManifestNode(document.getElementsByTagName(MANIFEST).item(0));
+  }
+
+  private static void parseManifestNode(Node manifestNode) {
+    if (manifestNode == null) {
+      return;
+    }
+    manifestData.appVersionName = getAttribute(manifestNode.getAttributes(), VERSION_NAME);
+    NodeList manifestChildren = manifestNode.getChildNodes();
+    if (manifestChildren == null) {
+      return;
+    }
+    for (int i = 0; i < manifestChildren.getLength(); i++) {
+      Node currentNode = manifestChildren.item(i);
+      if (currentNode == null) {
+        continue;
+      }
+      String currentNodeName = currentNode.getNodeName();
+      if (APPLICATION.equals(currentNodeName)) {
+        parseChildNodeList(currentNode.getChildNodes());
+      }
+    }
+  }
+
+  private static void parseChildNodeList(NodeList childrenList) {
+    if (childrenList == null) {
+      return;
+    }
+    for (int j = 0; j < childrenList.getLength(); j++) {
+      parseChildNode(childrenList.item(j));
+    }
+  }
+
+  private static void parseChildNode(Node child) {
+    if (child == null) {
+      return;
+    }
+    String childName = child.getNodeName();
+    if (childName == null) {
+      return;
+    }
+    switch (childName) {
+      case SERVICE:
+        manifestData.services.add(parseManifestComponent(child,
+            ManifestComponent.ApplicationComponent.SERVICE));
+        break;
+      case RECEIVER:
+        manifestData.receivers.add(parseManifestComponent(child,
+            ManifestComponent.ApplicationComponent.RECEIVER));
+        break;
+      case ACTIVITY:
+      case ACTIVITY_ALIAS:
+        manifestData.activities.add(parseManifestComponent(child,
+            ManifestComponent.ApplicationComponent.ACTIVITY));
+        break;
+      case PROVIDER:
+        manifestData.providers.add(parseManifestComponent(child,
+            ManifestComponent.ApplicationComponent.PROVIDER));
+        break;
+      default:
+        break;
+    }
+  }
+
+  /**
+   * Parse AndroidManifest.xml components from XML.
+   *
+   * @param node XML node to parse.
+   * @param type Type of application component {@link ManifestComponent.ApplicationComponent}.
+   * @return Return ManifestComponent {@link ManifestComponent} with information from manifest.
+   */
+  private static ManifestComponent parseManifestComponent(Node node,
+      ManifestComponent.ApplicationComponent type) {
+    ManifestComponent manifestComponent = new ManifestComponent(type);
+    NamedNodeMap attributes = node.getAttributes();
+    manifestComponent.name = getAttribute(attributes, ANDROID_NAME);
+    manifestComponent.permission = getAttribute(attributes, ANDROID_PERMISSION);
+    List<ManifestIntentFilter> intentFilters = new ArrayList<>();
+    NodeList childrenList = node.getChildNodes();
+    for (int i = 0; i < childrenList.getLength(); i++) {
+      Node child = childrenList.item(i);
+      String childName = child.getNodeName();
+      if (INTENT_FILTER.equals(childName)) {
+        ManifestIntentFilter intentFilter = parseManifestIntentFilter(child);
+        if (intentFilter != null) {
+          intentFilters.add(intentFilter);
+        }
+      }
+    }
+    manifestComponent.intentFilters = intentFilters;
+    return manifestComponent;
+  }
+
+  /**
+   * Parse intent filter from XML node.
+   *
+   * @param intentNode XML node to parse.
+   * @return Return ManifestIntentFilter {@link ManifestIntentFilter} with information from
+   * manifest.
+   */
+  private static ManifestIntentFilter parseManifestIntentFilter(Node intentNode) {
+    if (intentNode == null) {
+      return null;
+    }
+
+    NodeList intentChildren = intentNode.getChildNodes();
+    if (intentChildren == null) {
+      return null;
+    }
+
+    ManifestIntentFilter intentFilter = new ManifestIntentFilter();
+    intentFilter.attributes = intentNode.getAttributes();
+    for (int j = 0; j < intentChildren.getLength(); j++) {
+      Node intentChild = intentChildren.item(j);
+      String intentChildName = intentChild.getNodeName();
+      NamedNodeMap intentChildAttributes = intentChild.getAttributes();
+      switch (intentChildName) {
+        case ACTION:
+          intentFilter.actions.add(getAttribute(intentChildAttributes, ANDROID_NAME));
+          break;
+        case CATEGORY:
+          intentFilter.categories.add(getAttribute(intentChildAttributes, ANDROID_NAME));
+          break;
+        case DATA:
+          String scheme = getAttribute(intentChildAttributes, ANDROID_SCHEME);
+          String host = getAttribute(intentChildAttributes, ANDROID_HOST);
+          String port = getAttribute(intentChildAttributes, ANDROID_PORT);
+          String path = getAttribute(intentChildAttributes, ANDROID_PATH);
+          String pathPattern = getAttribute(intentChildAttributes, ANDROID_PATH_PATTERN);
+          String pathPrefix = getAttribute(intentChildAttributes, ANDROID_PATH_PREFIX);
+          String mimeType = getAttribute(intentChildAttributes, ANDROID_MIME_TYPE);
+          String type = getAttribute(intentChildAttributes, ANDROID_TYPE);
+          intentFilter.dataList.add(new ManifestIntentFilter.IntentData(scheme, host, port, path,
+              pathPattern, pathPrefix, mimeType, type));
+          break;
+        default:
+          break;
+      }
+    }
+    return intentFilter;
+  }
+
+  /**
+   * @return Return String with attribute value or null if attribute with this name not found.
+   */
+  private static String getAttribute(NamedNodeMap namedNodeMap, String name) {
+    Node node = namedNodeMap.getNamedItem(name);
+    if (node == null) {
+      if (name.startsWith("android:")) {
+        name = name.substring("android:".length());
+      }
+      node = namedNodeMap.getNamedItem(name);
+      if (node == null) {
+        return null;
+      }
+    }
+    return node.getNodeValue();
+  }
+
+  /**
+   * @return Return List of services from manifest.
+   */
+  public static List<ManifestComponent> getServices() {
+    if (manifestData == null) {
+      parseManifestNodeChildren();
+    }
+    return manifestData.services;
+  }
+
+  /**
+   * @return Return List of activities from manifest.
+   */
+  static List<ManifestComponent> getActivities() {
+    if (manifestData == null) {
+      parseManifestNodeChildren();
+    }
+    return manifestData.activities;
+  }
+
+  /**
+   * @return Return List of providers from manifest.
+   */
+  static List<ManifestComponent> getProviders() {
+    if (manifestData == null) {
+      parseManifestNodeChildren();
+    }
+    return manifestData.providers;
+  }
+
+  /**
+   * @return Return List of receivers from manifest.
+   */
+  public static List<ManifestComponent> getReceivers() {
+    if (manifestData == null) {
+      parseManifestNodeChildren();
+    }
+    return manifestData.receivers;
+  }
+
+  /**
+   * @return String String of application version name.
+   */
+  public static String getAppVersionName() {
+    if (manifestData == null) {
+      parseManifestNodeChildren();
+    }
+    return manifestData.appVersionName;
+  }
+
+  /**
+   * Verifies that a certain component (receiver or service) is implemented in the
+   * AndroidManifest.xml file or the application, in order to make sure that push notifications
+   * work.
+   *
+   * @param componentsList List of application components(services or receivers).
+   * @param name The name of the class.
+   * @param exported What the exported option should be.
+   * @param permission Whether we need any permission.
+   * @param actions What actions we need to check for in the intent-filter.
+   * @param packageName The package name for the category tag, if we require one.
+   * @return true if the respective component is in the manifest file, and false otherwise.
+   */
+  public static boolean checkComponent(List<ManifestComponent> componentsList,
+      String name, boolean exported, String permission, List<String> actions, String packageName) {
+    boolean hasComponent = hasComponent(componentsList, name, permission, actions);
+    if (!hasComponent && !componentsList.isEmpty()) {
+      Log.e(getComponentError(componentsList.get(0).type, name, exported, permission, actions,
+          packageName));
+    }
+    return hasComponent;
+  }
+
+  /**
+   * Check if list of application components contains class instance of class with name className.
+   *
+   * @param componentsList List of application components(services or receivers).
+   * @param className The name of the class.
+   * @param permission Whether we need any permission..
+   * @param actions What actions we need to check for in the intent-filter.
+   * @return boolean True if componentList contains class instance of class with name className.
+   */
+  private static boolean hasComponent(List<ManifestComponent> componentsList, String className,
+      String permission, List<String> actions) {
+    for (ManifestComponent component : componentsList) {
+      if (isInstance(component, className)) {
+        if (hasPermission(component, permission, actions)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Check if component instance of class with name className.
+   *
+   * @param component Application component(service or receiver).
+   * @param className The name of the class.
+   * @return boolean True if component instance of class with name className.
+   */
+  private static boolean isInstance(ManifestComponent component, String className) {
+    try {
+      if (component.name.equals(className)) {
+        return true;
+      } else {
+        Class clazz = null;
+        try {
+          clazz = Class.forName(component.name);
+        } catch (Throwable ignored) {
+        }
+        if (clazz == null) {
+          Log.w("Cannot find class with name: " + component.name);
+          return false;
+        }
+        while (clazz != Object.class) {
+          clazz = clazz.getSuperclass();
+          if (clazz.getName().equals(className)) {
+            return true;
+          }
+        }
+      }
+      return false;
+    } catch (Exception e) {
+      Util.handleException(e);
+      return false;
+    }
+  }
+
+  /**
+   * Check if application component has permission with provided actions.
+   *
+   * @param component application component(service or receiver).
+   * @param permission Whether we need any permission.
+   * @param actions What actions we need to check for in the intent-filter.
+   * @return boolean True if component has permission with actions.
+   */
+  private static boolean hasPermission(ManifestComponent component, String permission,
+      List<String> actions) {
+    Boolean hasPermissions = TextUtils.equals(component.permission, permission);
+    if (hasPermissions && actions != null) {
+      HashSet<String> actionsToCheck = new HashSet<>(actions);
+      for (ManifestIntentFilter intentFilter : component.intentFilters) {
+        actionsToCheck.removeAll(intentFilter.actions);
+      }
+      if (actionsToCheck.isEmpty()) {
+        return true;
+      }
+    } else if (hasPermissions) {
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Gets string of error message with instruction how to set up AndroidManifest.xml for push
+   * notifications.
+   *
+   * @return String String of error message with instruction how to set up AndroidManifest.xml for
+   * push notifications.
+   */
+  private static String getComponentError(ManifestComponent.ApplicationComponent componentType,
+      String name, boolean exported, String permission, List<String> actions, String packageName) {
+    StringBuilder errorMessage = new StringBuilder("Push notifications requires you to add the " +
+        componentType.name().toLowerCase() + " " + name + " to your AndroidManifest.xml file." +
+        "Add this code within the <application> section:\n");
+    errorMessage.append("<").append(componentType.name().toLowerCase()).append("\n");
+    errorMessage.append("    android:name=\"").append(name).append("\"\n");
+    errorMessage.append("    android:exported=\"").append(Boolean.toString(exported)).append("\"");
+    if (permission != null) {
+      errorMessage.append("\n    android:permission=\"").append(permission).append("\"");
+    }
+    errorMessage.append(">\n");
+    if (actions != null) {
+      errorMessage.append("    <intent-filter>\n");
+      for (String action : actions) {
+        errorMessage.append("        <action android:name=\"").append(action).append("\" />\n");
+      }
+      if (packageName != null) {
+        errorMessage.append("        <category android:name=\"").append(packageName)
+            .append("\" />\n");
+      }
+      errorMessage.append("    </intent-filter>\n");
+    }
+    errorMessage.append("</").append(componentType.name().toLowerCase()).append(">");
+    return errorMessage.toString();
+  }
+
+  /**
+   * Check if the application has registered for a certain permission.
+   *
+   * @param permission Requested permission.
+   * @param definesPermission Permission need definition or not.
+   * @param logError Need print log or not.
+   * @return boolean True if application has permission.
+   */
+  public static boolean checkPermission(String permission, boolean definesPermission,
+      boolean logError) {
+    Context context = Leanplum.getContext();
+    if (context == null) {
+      return false;
+    }
+    if (context.checkCallingOrSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
+      String definition;
+      if (definesPermission) {
+        definition = "<permission android:name=\"" + permission +
+            "\" android:protectionLevel=\"signature\" />\n";
+      } else {
+        definition = "";
+      }
+      if (logError) {
+        Log.e("In order to use push notifications, you need to enable " +
+            "the " + permission + " permission in your AndroidManifest.xml file. " +
+            "Add this within the <manifest> section:\n" +
+            definition + "<uses-permission android:name=\"" + permission + "\" />");
+      }
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * Class with Android manifest data.
+   */
+  private static class ManifestData {
+    private List<ManifestComponent> services = new ArrayList<>();
+    private List<ManifestComponent> activities = new ArrayList<>();
+    private List<ManifestComponent> receivers = new ArrayList<>();
+    private List<ManifestComponent> providers = new ArrayList<>();
+    private String appVersionName;
+  }
+
+  /**
+   * Class with application component from AndroidManifest.xml.
+   */
+  private static class ManifestComponent {
+    enum ApplicationComponent {SERVICE, RECEIVER, ACTIVITY, PROVIDER}
+
+    ApplicationComponent type;
+    String name;
+    String permission;
+    List<ManifestIntentFilter> intentFilters = new ArrayList<>();
+
+    ManifestComponent(ApplicationComponent type) {
+      this.type = type;
+    }
+  }
+
+  /**
+   * Class for declaration of intent filter from AndroidManifest.
+   */
+  private static class ManifestIntentFilter {
+    final List<String> actions = new ArrayList<>();
+    final List<String> categories = new ArrayList<>();
+    final List<IntentData> dataList = new ArrayList<>();
+    public NamedNodeMap attributes;
+
+    /**
+     * Class for data of intent filter from AndroidManifest.
+     */
+    static class IntentData {
+      final String scheme;
+      final String host;
+      final String port;
+      final String path;
+      final String pathPattern;
+      final String pathPrefix;
+      final String mimeType;
+      final String type;
+
+      IntentData(String scheme, String host, String port, String path, String pathPattern,
+          String pathPrefix, String mimeType, String type) {
+        this.scheme = scheme;
+        this.host = host;
+        this.port = port;
+        this.path = path;
+        this.pathPattern = pathPattern;
+        this.pathPrefix = pathPrefix;
+        this.mimeType = mimeType;
+        this.type = type;
+      }
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/LeanplumManifestParser.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2017, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+/**
+ * LeanplumManifestParser class for get AndroidManifest.xml. http://stackoverflow.com/questions/2097813/how-to-parse-the-androidmanifest-xml-file-inside-an-apk-package
+ *
+ * @author Anna Orlova
+ */
+class LeanplumManifestParser {
+  // XML tags and attributes:
+  // Every XML start and end tag consists of 6 32 bit words:
+  //   0th word: 02011000 for START_TAG and 03011000 for END_TAG
+  //   1st word: a flag?, like 38000000
+  //   2nd word: Line of where this tag appeared in the original source file
+  //   3rd word: FFFFFFFF ??
+  //   4th word: StringIndex of NameSpace name, or FFFFFFFF for default NS
+  //   5th word: StringIndex of Element Name
+  //   (Note: 01011000 in 0th word means end of XML document, END_DOC_TAG).
+
+  // Start tags (not end tags) contain 3 more words:
+  //   6th word: 14001400 meaning??
+  //   7th word: Number of Attributes that follow this tag(follow word 8th)
+  //   8th word: 00000000 meaning??
+
+  // Attributes consist of 5 words:
+  //   0th word: StringIndex of Attribute Name's Namespace, or FFFFFFFF
+  //   1st word: StringIndex of Attribute Name
+  //   2nd word: StringIndex of Attribute Value, or FFFFFFF if ResourceId used
+  //   3rd word: Flags?
+  //   4th word: str ind of attr value again, or ResourceId of value.
+  // END_DOC_TAG = 0x00100101;
+  private static final int START_TAG = 0x00100102;
+  private static final int END_TAG = 0x00100103;
+  private static final String SPACES = "                                             ";
+
+  /**
+   * Parse the 'compressed' binary form of Android XML docs such as for AndroidManifest.xml in .apk
+   * files.
+   *
+   * @param xml byte array of AndroidManifest.xml.
+   * @return String with data of AndroidManifest.xml.
+   */
+  static String decompressXml(byte[] xml) {
+    String out = "";
+    // Compressed XML file/bytes starts with 24x bytes of data,
+    // 9 32 bit words in little endian order (LSB first):
+    //   0th word is 03 00 08 00
+    //   3rd word SEEMS TO BE:  Offset at then of StringTable
+    //   4th word is: Number of strings in string table
+    // WARNING: Sometime I indiscriminately display or refer to word in
+    //   little endian storage format, or in integer format (ie MSB first).
+    int numbStrings = littleEndianValue(xml, 4 * 4);
+    // StringIndexTable starts at offset 24x, an array of 32 bit LE offsets
+    // of the length/string data in the StringTable.
+    int sitOff = 0x24;  // Offset of start of StringIndexTable.
+    // StringTable, each string is represented with a 16 bit little endian
+    // character count, followed by that number of 16 bit (LE) (Unicode) chars.
+    int stOff = sitOff + numbStrings * 4;  // StringTable follows StrIndexTable.
+    // Step through the XML tree element tags and attributes.
+    int off = scanForFirstStartTag(xml);
+    int indent = 0;
+
+    while (off < xml.length) {
+      int tag0 = littleEndianValue(xml, off);
+      int nameSi = littleEndianValue(xml, off + 5 * 4);
+      if (tag0 == START_TAG) {
+        int numbAttrs = littleEndianValue(xml, off + 7 * 4);  // Number of Attributes to follow.
+        off += 9 * 4;  // Skip over 6+3 words of START_TAG data
+        String name = compXmlString(xml, sitOff, stOff, nameSi);
+        // Look for the Attributes
+        StringBuilder sb = new StringBuilder();
+        for (int ii = 0; ii < numbAttrs; ii++) {
+          int attrNameSi = littleEndianValue(xml, off + 4);  // AttrName String Index.
+          int attrValueSi = littleEndianValue(xml, off + 2 * 4); // AttrValue Str Ind, or FFFFFFFF.
+          int attrResId = littleEndianValue(xml, off + 4 * 4);  // AttrValue ResourceId or dup.
+          // AttrValue StrInd.
+          off += 5 * 4;  // Skip over the 5 words of an attribute.
+          String attrName = compXmlString(xml, sitOff, stOff, attrNameSi);
+          String attrValue = attrValueSi != -1
+              ? compXmlString(xml, sitOff, stOff, attrValueSi)
+              : "resourceID 0x" + Integer.toHexString(attrResId);
+          sb.append(" ").append(attrName).append("=\"").append(attrValue).append("\"");
+        }
+        out += SPACES.substring(0, Math.min(indent * 2, SPACES.length())) + "<" + name + sb + ">";
+        indent++;
+      } else if (tag0 == END_TAG) {
+        indent--;
+        off += 6 * 4;  // Skip over 6 words of END_TAG data
+        String name = compXmlString(xml, sitOff, stOff, nameSi);
+        out += SPACES.substring(0, Math.min(indent * 2, SPACES.length())) + "</" + name + ">";
+
+      } else {
+        break;
+      }
+    }
+    return out;
+  }
+
+  private static String compXmlString(byte[] xml, int sitOff, int stOff, int strInd) {
+    if (strInd < 0) return null;
+    int strOff = stOff + littleEndianValue(xml, sitOff + strInd * 4);
+    return compXmlStringAt(xml, strOff);
+  }
+
+  /**
+   * @return Return the string stored in StringTable format at offset strOff.  This offset points to
+   * the 16 bit string length, which is followed by that number of 16 bit (Unicode) chars.
+   */
+  private static String compXmlStringAt(byte[] arr, int strOff) {
+    int strLen = arr[strOff + 1] << 8 & 0xff00 | arr[strOff] & 0xff;
+    byte[] chars = new byte[strLen];
+    for (int ii = 0; ii < strLen; ii++) {
+      chars[ii] = arr[strOff + 2 + ii * 2];
+    }
+    return new String(chars);  // Hack, just use 8 byte chars.
+  }
+
+  /**
+   * @return Return value of a Little Endian 32 bit word from the byte array at offset off.
+   */
+  private static int littleEndianValue(byte[] arr, int off) {
+    return arr[off + 3] << 24 & 0xff000000 | arr[off + 2] << 16 & 0xff0000
+        | arr[off + 1] << 8 & 0xff00 | arr[off] & 0xFF;
+  }
+
+  private static int scanForFirstStartTag(byte[] xml) {
+    // XMLTags, The XML tag tree starts after some unknown content after the
+    // StringTable.  There is some unknown data after the StringTable, scan forward
+    // from this point to the flag for the start of an XML start tag.
+    int xmlTagOff = littleEndianValue(xml, 3 * 4);  // Start from the offset in the 3rd word.
+    // Scan forward until we find the bytes: 0x02011000(x00100102 in normal int).
+    for (int ii = xmlTagOff; ii < xml.length - 4; ii += 4) {
+      if (littleEndianValue(xml, ii) == START_TAG) {
+        xmlTagOff = ii;
+        break;
+      }
+    }
+    return xmlTagOff;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/LeanplumMessageMatchFilter.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+/**
+ * Messaging filter.
+ *
+ * @author Atanas Dobrev
+ */
+public class LeanplumMessageMatchFilter {
+  public static final int LEANPLUM_ACTION_FILTER_FOREGROUND = 1;
+  public static final int LEANPLUM_ACTION_FILTER_BACKGROUND = 2;
+  public static final int LEANPLUM_ACTION_FILTER_ALL = 3;
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/LeanplumUIEditorWrapper.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2017, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.app.Activity;
+
+import com.leanplum.LeanplumEditorMode;
+import com.leanplum.LeanplumUIEditor;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+import static com.leanplum.internal.Constants.ClassUtil.UI_INTERFACE_EDITOR;
+
+/**
+ * Wrapper class for the UI Editor. Method calls will be forwarded to UI Editor package if its
+ * available.
+ */
+public class LeanplumUIEditorWrapper implements LeanplumUIEditor {
+  private static LeanplumUIEditor interfaceEditorSingleton;
+  private static LeanplumUIEditorWrapper instance = null;
+
+  protected LeanplumUIEditorWrapper() {
+    // Exists only to defeat instantiation.
+  }
+
+  static {
+    Class<?> clazz = null;
+    try {
+      clazz = Class.forName(UI_INTERFACE_EDITOR);
+    } catch (ClassNotFoundException ignored) {
+    }
+    if (clazz != null) {
+      Method method = null;
+      try {
+        method = clazz.getMethod("getInstance");
+      } catch (NoSuchMethodException e) {
+        Util.handleException(e);
+      }
+      if (method != null) {
+        try {
+          interfaceEditorSingleton = (LeanplumUIEditor) method.invoke(null);
+          if (interfaceEditorSingleton != null) {
+            interfaceEditorSingleton.allowInterfaceEditing(Constants.isDevelopmentModeEnabled);
+          }
+        } catch (IllegalAccessException e) {
+          Util.handleException(e);
+        } catch (InvocationTargetException e) {
+          Util.handleException(e);
+        }
+      }
+    }
+  }
+
+  public static LeanplumUIEditorWrapper getInstance() {
+    if (instance == null) {
+      instance = new LeanplumUIEditorWrapper();
+    }
+    return instance;
+  }
+
+  public static boolean isUIEditorAvailable() {
+    return interfaceEditorSingleton != null;
+  }
+
+  @Override
+  public void allowInterfaceEditing(Boolean isDevelopmentModeEnabled) {
+    if (interfaceEditorSingleton != null) {
+      interfaceEditorSingleton.allowInterfaceEditing(isDevelopmentModeEnabled);
+    }
+  }
+
+  @Override
+  public void applyInterfaceEdits(Activity activity) {
+    if (interfaceEditorSingleton != null && activity != null) {
+      interfaceEditorSingleton.applyInterfaceEdits(activity);
+    }
+  }
+
+  /**
+   * Sets the update flag to true.
+   */
+  @Override
+  public void startUpdating() {
+    if (interfaceEditorSingleton != null) {
+      interfaceEditorSingleton.startUpdating();
+    }
+  }
+
+  /**
+   * Sets the update flag to false.
+   */
+  @Override
+  public void stopUpdating() {
+    if (interfaceEditorSingleton != null) {
+      interfaceEditorSingleton.stopUpdating();
+    }
+  }
+
+  @Override
+  public void sendUpdate() {
+    if (interfaceEditorSingleton != null) {
+      interfaceEditorSingleton.sendUpdate();
+    }
+  }
+
+  @Override
+  public void sendUpdateDelayed(int delay) {
+    if (interfaceEditorSingleton != null) {
+      interfaceEditorSingleton.sendUpdateDelayed(delay);
+    }
+  }
+
+  @Override
+  public void sendUpdateDelayedDefault() {
+    if (interfaceEditorSingleton != null) {
+      interfaceEditorSingleton.sendUpdateDelayedDefault();
+    }
+  }
+
+  @Override
+  public LeanplumEditorMode getMode() {
+    if (interfaceEditorSingleton != null) {
+      return interfaceEditorSingleton.getMode();
+    }
+    return null;
+  }
+
+  @Override
+  public void setMode(LeanplumEditorMode mode) {
+    if (interfaceEditorSingleton != null) {
+      interfaceEditorSingleton.setMode(mode);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/Log.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+//import com.leanplum.BuildConfig;
+
+import java.util.HashMap;
+
+/**
+ * Handles logging within the Leanplum SDK.
+ *
+ * @author Ben Marten
+ */
+public class Log {
+  public enum LeanplumLogType {
+    /**
+     * Always visible to customers. Sent to us when remote logging is enabled.
+     */
+    ERROR,
+
+    /**
+     * Always visible to customers. Sent to us when remote logging is enabled.
+     */
+    WARNING,
+
+    /**
+     * Always visible to customers. Sent to us when remote logging is enabled.
+     */
+    INFO,
+
+    /**
+     * Visible to customers only when verbose logging is enabled. Sent to us when remote logging is
+     * enabled.
+     */
+    VERBOSE,
+
+    /**
+     * Not visible to customers. Sent to us when remote logging is enabled.
+     */
+    PRIVATE,
+
+    /**
+     * Used only for Leanplum SDK debugging. Not visible to customers. Not sent to us when remote
+     * logging is enabled.
+     */
+    DEBUG
+  }
+
+  private static final ThreadLocal<Boolean> isLogging = new ThreadLocal<Boolean>() {
+    @Override
+    protected Boolean initialValue() {
+      return false;
+    }
+  };
+
+  public static void e(Object... objects) {
+    log(LeanplumLogType.ERROR, CollectionUtil.concatenateArray(objects, ", "));
+  }
+
+  public static void w(Object... objects) {
+    log(LeanplumLogType.WARNING, CollectionUtil.concatenateArray(objects, ", "));
+  }
+
+  public static void i(Object... objects) {
+    log(LeanplumLogType.INFO, CollectionUtil.concatenateArray(objects, ", "));
+  }
+
+  public static void v(Object... objects) {
+    log(LeanplumLogType.VERBOSE, CollectionUtil.concatenateArray(objects, ", "));
+  }
+
+  public static void p(Object... objects) {
+    log(LeanplumLogType.PRIVATE, CollectionUtil.concatenateArray(objects, ", "));
+  }
+
+  public static void d(Object... objects) {
+    log(LeanplumLogType.DEBUG, CollectionUtil.concatenateArray(objects, ", "));
+  }
+
+  /**
+   * Handle Leanplum log messages, which may be sent to the server for remote logging if
+   * Constants.loggingEnabled is set.
+   * <p/>
+   * This will format the string in all cases, and is therefore less efficient than checking the
+   * conditionals inline. Avoid this in performance-critical code.
+   *
+   * @param type The log type level of the message.
+   * @param message The message to be logged.
+   */
+  public static void log(LeanplumLogType type, String message) {
+    String tag = generateTag(type);
+    String prefix = generateMessagePrefix();
+
+    switch (type) {
+      case ERROR:
+        android.util.Log.e(tag, prefix + message);
+        maybeSendLog(tag + prefix + message);
+        return;
+      case WARNING:
+        android.util.Log.w(tag, prefix + message);
+        maybeSendLog(tag + prefix + message);
+        return;
+      case INFO:
+        android.util.Log.i(tag, prefix + message);
+        maybeSendLog(tag + prefix + message);
+        return;
+      case VERBOSE:
+        if (Constants.isDevelopmentModeEnabled
+            && Constants.enableVerboseLoggingInDevelopmentMode) {
+          android.util.Log.v(tag, prefix + message);
+          maybeSendLog(tag + prefix + message);
+        }
+        return;
+      case PRIVATE:
+        maybeSendLog(tag + prefix + message);
+        return;
+      default:
+    }
+  }
+
+  /**
+   * Generates tag for logging purpose in format [LogType][Leanplum]
+   *
+   * @param type log type
+   * @return generated tag
+   */
+  private static String generateTag(LeanplumLogType type) {
+    return "[" + type.name() + "][Leanplum]";
+  }
+
+  /**
+   * Generates a log message prefix based on current class and method name in format
+   * [ClassName::MethodName::LineNumber].
+   * This shouldn't be called directly, since getting className and methodName is hardcoded and
+   * extracted based on StackTrace.
+   *
+   * @return a message prefix for logging purpose
+   */
+  private static String generateMessagePrefix() {
+    // Since this is called from log method, caller method should be on index 5.
+    int callerIndex = 5;
+    int minimumStackTraceIndex = 5;
+
+    StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();
+    if (stackTraceElements.length >= minimumStackTraceIndex) {
+      String tag = "[";
+      tag += stackTraceElements[callerIndex].getClassName();
+      tag += "::";
+      tag += stackTraceElements[callerIndex].getMethodName();
+      tag += "::";
+      tag += stackTraceElements[callerIndex].getLineNumber();
+      tag += "]: ";
+      return tag;
+    }
+    return "";
+  }
+
+  private static void maybeSendLog(String message) {
+    if (!Constants.loggingEnabled || isLogging.get()) {
+      return;
+    }
+
+    isLogging.set(true);
+    try {
+      HashMap<String, Object> params = new HashMap<>();
+      params.put(Constants.Params.TYPE, Constants.Values.SDK_LOG);
+      params.put(Constants.Params.MESSAGE, message);
+      Request.post(Constants.Methods.LOG, params).sendEventually();
+    } catch (Throwable t) {
+      android.util.Log.e("Leanplum", "Unable to send log.", t);
+    } finally {
+      isLogging.remove();
+    }
+  }
+
+  /**
+   * Handy function to get a loggable stack trace from a Throwable.
+   *
+   * @param throwable An exception to log.
+   */
+  public static String getStackTraceString(Throwable throwable) {
+    return android.util.Log.getStackTraceString(throwable);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/OsHandler.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.os.Handler;
+import android.os.Looper;
+
+/**
+ * Wraps Handler while allowing overriding of methods that are needed for unit testing
+ *
+ * @author kkafadarov
+ */
+public class OsHandler {
+  // Posts to UI thread. Visible for testing.
+  public static OsHandler instance;
+
+  final Handler handler = new Handler(Looper.getMainLooper());
+
+  public Boolean post(Runnable runnable) {
+    return handler.post(runnable);
+  }
+
+  public Boolean postDelayed(Runnable runnable, long lng) {
+    return handler.postDelayed(runnable, lng);
+  }
+
+  public static OsHandler getInstance() {
+    if (instance == null) {
+      instance = new OsHandler();
+    }
+    return instance;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/Registration.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import com.leanplum.callbacks.StartCallback;
+
+import org.json.JSONObject;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class Registration {
+  public static void registerDevice(String email, final StartCallback callback) {
+    Map<String, Object> params = new HashMap<>();
+    params.put(Constants.Params.EMAIL, email);
+    Request request = Request.post(Constants.Methods.REGISTER_FOR_DEVELOPMENT, params);
+    request.onResponse(new Request.ResponseCallback() {
+      @Override
+      public void response(final JSONObject response) {
+        OsHandler.getInstance().post(new Runnable() {
+          @Override
+          public void run() {
+            try {
+              JSONObject registerResponse = Request.getLastResponse(response);
+              boolean isSuccess = Request.isResponseSuccess(registerResponse);
+              if (isSuccess) {
+                if (callback != null) {
+                  callback.onResponse(true);
+                }
+              } else {
+                Log.e(Request.getResponseError(registerResponse));
+                if (callback != null) {
+                  callback.onResponse(false);
+                }
+              }
+            } catch (Throwable t) {
+              Util.handleException(t);
+            }
+          }
+        });
+      }
+    });
+    request.onError(new Request.ErrorCallback() {
+      @Override
+      public void error(final Exception e) {
+        OsHandler.getInstance().post(new Runnable() {
+          @Override
+          public void run() {
+            if (callback != null) {
+              callback.onResponse(false);
+            }
+          }
+        });
+      }
+    });
+    request.sendIfConnected();
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/Request.java
@@ -0,0 +1,886 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.AsyncTask;
+
+import com.leanplum.Leanplum;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.EOFException;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Stack;
+
+/**
+ * Leanplum request class.
+ *
+ * @author Andrew First
+ */
+public class Request {
+  private static final long DEVELOPMENT_MIN_DELAY_MS = 100;
+  private static final long DEVELOPMENT_MAX_DELAY_MS = 5000;
+  private static final long PRODUCTION_DELAY = 60000;
+  private static final String LEANPLUM = "__leanplum__";
+
+  private static String appId;
+  private static String accessKey;
+  private static String deviceId;
+  private static String userId;
+  private static final Map<String, Boolean> fileTransferStatus = new HashMap<>();
+  private static int pendingDownloads;
+  private static NoPendingDownloadsCallback noPendingDownloadsBlock;
+
+  // The token is saved primarily for legacy SharedPreferences decryption. This could
+  // likely be removed in the future.
+  private static String token = null;
+  private static final Object lock = Request.class;
+  private static final Map<File, Long> fileUploadSize = new HashMap<>();
+  private static final Map<File, Double> fileUploadProgress = new HashMap<>();
+  private static String fileUploadProgressString = "";
+  private static long lastSendTimeMs;
+  private static final Object uploadFileLock = new Object();
+
+  private final String httpMethod;
+  private final String apiMethod;
+  private final Map<String, Object> params;
+  private ResponseCallback response;
+  private ErrorCallback error;
+  private boolean sent;
+
+  private static ApiResponseCallback apiResponse;
+
+  public static void setAppId(String appId, String accessKey) {
+    Request.appId = appId;
+    Request.accessKey = accessKey;
+  }
+
+  public static void setDeviceId(String deviceId) {
+    Request.deviceId = deviceId;
+  }
+
+  public static void setUserId(String userId) {
+    Request.userId = userId;
+  }
+
+  public static void setToken(String token) {
+    Request.token = token;
+  }
+
+  public static String token() {
+    return token;
+  }
+
+  public static void loadToken() {
+    Context context = Leanplum.getContext();
+    SharedPreferences defaults = context.getSharedPreferences(
+        LEANPLUM, Context.MODE_PRIVATE);
+    String token = defaults.getString(Constants.Defaults.TOKEN_KEY, null);
+    if (token == null) {
+      return;
+    }
+    setToken(token);
+  }
+
+  public static void saveToken() {
+    Context context = Leanplum.getContext();
+    SharedPreferences defaults = context.getSharedPreferences(
+        LEANPLUM, Context.MODE_PRIVATE);
+    SharedPreferences.Editor editor = defaults.edit();
+    editor.putString(Constants.Defaults.TOKEN_KEY, Request.token());
+    try {
+      editor.apply();
+    } catch (NoSuchMethodError e) {
+      editor.commit();
+    }
+  }
+
+  public static String appId() {
+    return appId;
+  }
+
+  public static String deviceId() {
+    return deviceId;
+  }
+
+  public static String userId() {
+    return Request.userId;
+  }
+
+  public Request(String httpMethod, String apiMethod, Map<String, Object> params) {
+    this.httpMethod = httpMethod;
+    this.apiMethod = apiMethod;
+    this.params = params != null ? params : new HashMap<String, Object>();
+
+    // Make sure the Handler is initialized on the main thread.
+    OsHandler.getInstance();
+  }
+
+  public static Request get(String apiMethod, Map<String, Object> params) {
+    Log.LeanplumLogType level = Constants.Methods.LOG.equals(apiMethod) ?
+        Log.LeanplumLogType.DEBUG : Log.LeanplumLogType.VERBOSE;
+    Log.log(level, "Will call API method " + apiMethod + " with arguments " + params);
+    return RequestFactory.getInstance().createRequest("GET", apiMethod, params);
+  }
+
+  public static Request post(String apiMethod, Map<String, Object> params) {
+    Log.LeanplumLogType level = Constants.Methods.LOG.equals(apiMethod) ?
+        Log.LeanplumLogType.DEBUG : Log.LeanplumLogType.VERBOSE;
+    Log.log(level, "Will call API method " + apiMethod + " with arguments " + params);
+    return RequestFactory.getInstance().createRequest("POST", apiMethod, params);
+  }
+
+  public void onResponse(ResponseCallback response) {
+    this.response = response;
+  }
+
+  public void onError(ErrorCallback error) {
+    this.error = error;
+  }
+
+  public void onApiResponse(ApiResponseCallback apiResponse) {
+    Request.apiResponse = apiResponse;
+  }
+
+  private Map<String, Object> createArgsDictionary() {
+    Map<String, Object> args = new HashMap<>();
+    args.put(Constants.Params.DEVICE_ID, deviceId);
+    args.put(Constants.Params.USER_ID, userId);
+    args.put(Constants.Params.ACTION, apiMethod);
+    args.put(Constants.Params.SDK_VERSION, Constants.LEANPLUM_VERSION);
+    args.put(Constants.Params.DEV_MODE, Boolean.toString(Constants.isDevelopmentModeEnabled));
+    args.put(Constants.Params.TIME, Double.toString(new Date().getTime() / 1000.0));
+    if (token != null) {
+      args.put(Constants.Params.TOKEN, token);
+    }
+    args.putAll(params);
+    return args;
+  }
+
+  private static void saveRequestForLater(Map<String, Object> args) {
+    synchronized (lock) {
+      Context context = Leanplum.getContext();
+      SharedPreferences preferences = context.getSharedPreferences(
+          LEANPLUM, Context.MODE_PRIVATE);
+      SharedPreferences.Editor editor = preferences.edit();
+      int count = preferences.getInt(Constants.Defaults.COUNT_KEY, 0);
+      String itemKey = String.format(Locale.US, Constants.Defaults.ITEM_KEY, count);
+      editor.putString(itemKey, JsonConverter.toJson(args));
+      count++;
+      editor.putInt(Constants.Defaults.COUNT_KEY, count);
+      try {
+        editor.apply();
+      } catch (NoSuchMethodError e) {
+        editor.commit();
+      }
+    }
+  }
+
+  public void send() {
+    this.sendEventually();
+    if (Constants.isDevelopmentModeEnabled) {
+      long currentTimeMs = System.currentTimeMillis();
+      long delayMs;
+      if (lastSendTimeMs == 0 || currentTimeMs - lastSendTimeMs > DEVELOPMENT_MAX_DELAY_MS) {
+        delayMs = DEVELOPMENT_MIN_DELAY_MS;
+      } else {
+        delayMs = (lastSendTimeMs + DEVELOPMENT_MAX_DELAY_MS) - currentTimeMs;
+      }
+      OsHandler.getInstance().postDelayed(new Runnable() {
+        @Override
+        public void run() {
+          try {
+            sendIfConnected();
+          } catch (Throwable t) {
+            Util.handleException(t);
+          }
+        }
+      }, delayMs);
+    }
+  }
+
+  /**
+   * Wait 1 second for potential other API calls, and then sends the call synchronously if no other
+   * call has been sent within 1 minute.
+   */
+  public void sendIfDelayed() {
+    sendEventually();
+    OsHandler.getInstance().postDelayed(new Runnable() {
+      @Override
+      public void run() {
+        try {
+          sendIfDelayedHelper();
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+      }
+    }, 1000);
+  }
+
+  /**
+   * Sends the call synchronously if no other call has been sent within 1 minute.
+   */
+  private void sendIfDelayedHelper() {
+    if (Constants.isDevelopmentModeEnabled) {
+      send();
+    } else {
+      long currentTimeMs = System.currentTimeMillis();
+      if (lastSendTimeMs == 0 || currentTimeMs - lastSendTimeMs > PRODUCTION_DELAY) {
+        sendIfConnected();
+      }
+    }
+  }
+
+  public void sendIfConnected() {
+    if (Util.isConnected()) {
+      this.sendNow();
+    } else {
+      this.sendEventually();
+      Log.i("Device is offline, will send later");
+      triggerErrorCallback(new Exception("Not connected to the Internet"));
+    }
+  }
+
+  private void triggerErrorCallback(Exception e) {
+    if (error != null) {
+      error.error(e);
+    }
+    if (apiResponse != null) {
+      List<Map<String, Object>> requests = getUnsentRequests();
+      apiResponse.response(requests, null);
+    }
+  }
+
+  @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+  private boolean attachApiKeys(Map<String, Object> dict) {
+    if (appId == null || accessKey == null) {
+      Log.e("API keys are not set. Please use Leanplum.setAppIdForDevelopmentMode or "
+          + "Leanplum.setAppIdForProductionMode.");
+      return false;
+    }
+    dict.put(Constants.Params.APP_ID, appId);
+    dict.put(Constants.Params.CLIENT_KEY, accessKey);
+    dict.put(Constants.Params.CLIENT, Constants.CLIENT);
+    return true;
+  }
+
+  public interface ResponseCallback {
+    void response(JSONObject response);
+  }
+
+  public interface ApiResponseCallback {
+    void response(List<Map<String, Object>> requests, JSONObject response);
+  }
+
+  public interface ErrorCallback {
+    void error(Exception e);
+  }
+
+  public interface NoPendingDownloadsCallback {
+    void noPendingDownloads();
+  }
+
+  private void parseResponseJson(JSONObject responseJson, List<Map<String, Object>> requestsToSend,
+      Exception error) {
+    if (apiResponse != null) {
+      apiResponse.response(requestsToSend, responseJson);
+    }
+
+    if (responseJson != null) {
+      Exception lastResponseError = null;
+      int numResponses = Request.numResponses(responseJson);
+      for (int i = 0; i < numResponses; i++) {
+        JSONObject response = Request.getResponseAt(responseJson, i);
+        if (!Request.isResponseSuccess(response)) {
+          String errorMessage = Request.getResponseError(response);
+          if (errorMessage == null || errorMessage.length() == 0) {
+            errorMessage = "API error";
+          } else if (errorMessage.startsWith("App not found")) {
+            errorMessage = "No app matching the provided app ID was found.";
+            Constants.isInPermanentFailureState = true;
+          } else if (errorMessage.startsWith("Invalid access key")) {
+            errorMessage = "The access key you provided is not valid for this app.";
+            Constants.isInPermanentFailureState = true;
+          } else if (errorMessage.startsWith("Development mode requested but not permitted")) {
+            errorMessage = "A call to Leanplum.setAppIdForDevelopmentMode "
+                + "with your production key was made, which is not permitted.";
+            Constants.isInPermanentFailureState = true;
+          } else {
+            errorMessage = "API error: " + errorMessage;
+          }
+          Log.e(errorMessage);
+          if (i == numResponses - 1) {
+            lastResponseError = new Exception(errorMessage);
+          }
+        }
+      }
+
+      if (lastResponseError == null) {
+        lastResponseError = error;
+      }
+
+      if (lastResponseError != null && this.error != null) {
+        this.error.error(lastResponseError);
+      } else if (this.response != null) {
+        this.response.response(responseJson);
+      }
+    } else if (error != null && this.error != null) {
+      this.error.error(error);
+    }
+  }
+
+  private void sendNow() {
+    if (Constants.isTestMode) {
+      return;
+    }
+    if (appId == null) {
+      Log.e("Cannot send request. appId is not set.");
+      return;
+    }
+    if (accessKey == null) {
+      Log.e("Cannot send request. accessKey is not set.");
+      return;
+    }
+
+    this.sendEventually();
+
+    final List<Map<String, Object>> requestsToSend = popUnsentRequests();
+    if (requestsToSend.isEmpty()) {
+      return;
+    }
+
+    final Map<String, Object> multiRequestArgs = new HashMap<>();
+    multiRequestArgs.put(Constants.Params.DATA, jsonEncodeUnsentRequests(requestsToSend));
+    multiRequestArgs.put(Constants.Params.SDK_VERSION, Constants.LEANPLUM_VERSION);
+    multiRequestArgs.put(Constants.Params.ACTION, Constants.Methods.MULTI);
+    multiRequestArgs.put(Constants.Params.TIME, Double.toString(new Date().getTime() / 1000.0));
+    if (!this.attachApiKeys(multiRequestArgs)) {
+      return;
+    }
+
+    Util.executeAsyncTask(new AsyncTask<Void, Void, Void>() {
+      @Override
+      protected Void doInBackground(Void... params) {
+        JSONObject result = null;
+        HttpURLConnection op = null;
+        try {
+          try {
+            op = Util.operation(
+                Constants.API_HOST_NAME,
+                Constants.API_SERVLET,
+                multiRequestArgs,
+                httpMethod,
+                Constants.API_SSL,
+                Constants.NETWORK_TIMEOUT_SECONDS);
+
+            result = Util.getJsonResponse(op);
+            int statusCode = op.getResponseCode();
+
+            Exception errorException = null;
+            if (statusCode >= 400) {
+              errorException = new Exception("HTTP error " + statusCode);
+              if (statusCode == 408 || (statusCode >= 500 && statusCode <= 599)) {
+                pushUnsentRequests(requestsToSend);
+              }
+            } else {
+              if (result != null) {
+                int numResponses = Request.numResponses(result);
+                if (numResponses != requestsToSend.size()) {
+                  Log.w("Sent " + requestsToSend.size() +
+                      " requests but only" + " received " + numResponses);
+                }
+              } else {
+                errorException = new Exception("Response JSON is null.");
+              }
+            }
+            parseResponseJson(result, requestsToSend, errorException);
+          } catch (JSONException e) {
+            Log.e("Error parsing JSON response: " + e.toString() + "\n" +
+                Log.getStackTraceString(e));
+            parseResponseJson(null, requestsToSend, e);
+          } catch (Exception e) {
+            pushUnsentRequests(requestsToSend);
+            Log.e("Unable to send request: " + e.toString() + "\n" +
+                Log.getStackTraceString(e));
+            parseResponseJson(result, requestsToSend, e);
+          } finally {
+            if (op != null) {
+              op.disconnect();
+            }
+          }
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+        return null;
+      }
+    });
+  }
+
+  public void sendEventually() {
+    if (Constants.isTestMode) {
+      return;
+    }
+    if (!sent) {
+      sent = true;
+      Map<String, Object> args = createArgsDictionary();
+      saveRequestForLater(args);
+    }
+  }
+
+  static List<Map<String, Object>> popUnsentRequests() {
+    return getUnsentRequests(true);
+  }
+
+  static List<Map<String, Object>> getUnsentRequests() {
+    return getUnsentRequests(false);
+  }
+
+  private static List<Map<String, Object>> getUnsentRequests(boolean remove) {
+    List<Map<String, Object>> requestData = new ArrayList<>();
+
+    synchronized (lock) {
+      lastSendTimeMs = System.currentTimeMillis();
+
+      Context context = Leanplum.getContext();
+      SharedPreferences preferences = context.getSharedPreferences(
+          LEANPLUM, Context.MODE_PRIVATE);
+      SharedPreferences.Editor editor = preferences.edit();
+
+      int count = preferences.getInt(Constants.Defaults.COUNT_KEY, 0);
+      if (count == 0) {
+        return new ArrayList<>();
+      }
+      if (remove) {
+        editor.remove(Constants.Defaults.COUNT_KEY);
+      }
+
+      for (int i = 0; i < count; i++) {
+        String itemKey = String.format(Locale.US, Constants.Defaults.ITEM_KEY, i);
+        Map<String, Object> requestArgs;
+        try {
+          requestArgs = JsonConverter.mapFromJson(new JSONObject(
+              preferences.getString(itemKey, "{}")));
+          requestData.add(requestArgs);
+        } catch (JSONException e) {
+          e.printStackTrace();
+        }
+        if (remove) {
+          editor.remove(itemKey);
+        }
+      }
+      if (remove) {
+        try {
+          editor.apply();
+        } catch (NoSuchMethodError e) {
+          editor.commit();
+        }
+      }
+    }
+
+    requestData = removeIrrelevantBackgroundStartRequests(requestData);
+    return requestData;
+  }
+
+  /**
+   * In various scenarios we can end up batching a big number of requests (e.g. device is offline,
+   * background sessions), which could make the stored API calls batch look something like:
+   * <p>
+   * <code>start(B), start(B), start(F), track, start(B), track, start(F), resumeSession</code>
+   * <p>
+   * where <code>start(B)</code> indicates a start in the background, and <code>start(F)</code>
+   * one in the foreground.
+   * <p>
+   * In this case the first two <code>start(B)</code> can be dropped because they don't contribute
+   * any relevant information for the batch call.
+   * <p>
+   * Essentially we drop every <code>start(B)</code> call, that is directly followed by any kind of
+   * a <code>start</code> call.
+   *
+   * @param requestData A list of the requests, stored on the device.
+   * @return A list of only these requests, which contain relevant information for the API call.
+   */
+  private static List<Map<String, Object>> removeIrrelevantBackgroundStartRequests(
+      List<Map<String, Object>> requestData) {
+    List<Map<String, Object>> relevantRequests = new ArrayList<>();
+
+    int requestCount = requestData.size();
+    if (requestCount > 0) {
+      for (int i = 0; i < requestCount; i++) {
+        Map<String, Object> currentRequest = requestData.get(i);
+        if (i < requestCount - 1
+            && Constants.Methods.START.equals(requestData.get(i + 1).get(Constants.Params.ACTION))
+            && Constants.Methods.START.equals(currentRequest.get(Constants.Params.ACTION))
+            && Boolean.TRUE.toString().equals(currentRequest.get(Constants.Params.BACKGROUND))) {
+          continue;
+        }
+        relevantRequests.add(currentRequest);
+      }
+    }
+
+    return relevantRequests;
+  }
+
+  private static String jsonEncodeUnsentRequests(List<Map<String, Object>> requestData) {
+    Map<String, Object> data = new HashMap<>();
+    data.put(Constants.Params.DATA, requestData);
+    return JsonConverter.toJson(data);
+  }
+
+  private static void pushUnsentRequests(List<Map<String, Object>> requestData) {
+    if (requestData == null) {
+      return;
+    }
+    for (Map<String, Object> args : requestData) {
+      Object retryCountString = args.get("retryCount");
+      int retryCount;
+      if (retryCountString != null) {
+        retryCount = Integer.parseInt(retryCountString.toString()) + 1;
+      } else {
+        retryCount = 1;
+      }
+      args.put("retryCount", Integer.toString(retryCount));
+      saveRequestForLater(args);
+    }
+  }
+
+  private static String getSizeAsString(int bytes) {
+    if (bytes < (1 << 10)) {
+      return bytes + " B";
+    } else if (bytes < (1 << 20)) {
+      return (bytes >> 10) + " KB";
+    } else {
+      return (bytes >> 20) + " MB";
+    }
+  }
+
+  private static void printUploadProgress() {
+    int totalFiles = fileUploadSize.size();
+    int sentFiles = 0;
+    int totalBytes = 0;
+    int sentBytes = 0;
+    for (Map.Entry<File, Long> entry : fileUploadSize.entrySet()) {
+      File file = entry.getKey();
+      long fileSize = entry.getValue();
+      double fileProgress = fileUploadProgress.get(file);
+      if (fileProgress == 1) {
+        sentFiles++;
+      }
+      sentBytes += (int) (fileSize * fileProgress);
+      totalBytes += fileSize;
+    }
+    String progressString = "Uploading resources. " +
+        sentFiles + '/' + totalFiles + " files completed; " +
+        getSizeAsString(sentBytes) + '/' + getSizeAsString(totalBytes) + " transferred.";
+    if (!fileUploadProgressString.equals(progressString)) {
+      fileUploadProgressString = progressString;
+      Log.i(progressString);
+    }
+  }
+
+  public void sendFilesNow(final List<String> filenames, final List<InputStream> streams) {
+    if (Constants.isTestMode) {
+      return;
+    }
+    final Map<String, Object> dict = createArgsDictionary();
+    if (!attachApiKeys(dict)) {
+      return;
+    }
+    final List<File> filesToUpload = new ArrayList<>();
+
+    // First set up the files for upload
+    for (int i = 0; i < filenames.size(); i++) {
+      String filename = filenames.get(i);
+      if (filename == null || Boolean.TRUE.equals(fileTransferStatus.get(filename))) {
+        continue;
+      }
+      File file = new File(filename);
+      long size;
+      try {
+        size = streams.get(i).available();
+      } catch (IOException e) {
+        size = file.length();
+      } catch (NullPointerException e) {
+        // Not good. Can't read asset.
+        Log.e("Unable to read file " + filename);
+        continue;
+      }
+      fileTransferStatus.put(filename, true);
+      filesToUpload.add(file);
+      fileUploadSize.put(file, size);
+      fileUploadProgress.put(file, 0.0);
+    }
+    if (filesToUpload.size() == 0) {
+      return;
+    }
+
+    printUploadProgress();
+
+    // Now upload the files
+    Util.executeAsyncTask(new AsyncTask<Void, Void, Void>() {
+      @Override
+      protected Void doInBackground(Void... params) {
+        synchronized (uploadFileLock) {  // Don't overload app and server with many upload tasks
+          JSONObject result;
+          HttpURLConnection op = null;
+
+          try {
+            op = Util.uploadFilesOperation(
+                Constants.Params.FILE,
+                filesToUpload,
+                streams,
+                Constants.API_HOST_NAME,
+                Constants.API_SERVLET,
+                dict,
+                httpMethod,
+                Constants.API_SSL,
+                60);
+
+            if (op != null) {
+              result = Util.getJsonResponse(op);
+              int statusCode = op.getResponseCode();
+              if (statusCode != 200) {
+                throw new Exception("Leanplum: Error sending request: " + statusCode);
+              }
+              if (Request.this.response != null) {
+                Request.this.response.response(result);
+              }
+            } else {
+              if (error != null) {
+                error.error(new Exception("Leanplum: Unable to read file."));
+              }
+            }
+          } catch (JSONException e) {
+            Log.e("Unable to convert to JSON.", e);
+            if (error != null) {
+              error.error(e);
+            }
+          } catch (SocketTimeoutException e) {
+            Log.e("Timeout uploading files. Try again or limit the number of files " +
+                "to upload with parameters to syncResourcesAsync.");
+            if (error != null) {
+              error.error(e);
+            }
+          } catch (Exception e) {
+            Log.e("Unable to send file.", e);
+            if (error != null) {
+              error.error(e);
+            }
+          } finally {
+            if (op != null) {
+              op.disconnect();
+            }
+          }
+
+          for (File file : filesToUpload) {
+            fileUploadProgress.put(file, 1.0);
+          }
+          printUploadProgress();
+
+          return null;
+        }
+      }
+    });
+
+    // TODO: Upload progress
+  }
+
+  void downloadFile(final String path, final String url) {
+    if (Constants.isTestMode) {
+      return;
+    }
+    if (Boolean.TRUE.equals(fileTransferStatus.get(path))) {
+      return;
+    }
+    pendingDownloads++;
+    Log.i("Downloading resource " + path);
+    fileTransferStatus.put(path, true);
+    final Map<String, Object> dict = createArgsDictionary();
+    dict.put(Constants.Keys.FILENAME, path);
+    if (!attachApiKeys(dict)) {
+      return;
+    }
+
+    Util.executeAsyncTask(new AsyncTask<Void, Void, Void>() {
+      @Override
+      protected Void doInBackground(Void... params) {
+        try {
+          downloadHelper(Constants.API_HOST_NAME, Constants.API_SERVLET, path, url, dict);
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+        return null;
+      }
+    });
+    // TODO: Download progress
+  }
+
+  private void downloadHelper(String hostName, String servlet, final String path, final String url,
+      final Map<String, Object> dict) {
+    HttpURLConnection op = null;
+    URL originalURL = null;
+    try {
+      if (url == null) {
+        op = Util.operation(
+            hostName,
+            servlet,
+            dict,
+            httpMethod,
+            Constants.API_SSL,
+            Constants.NETWORK_TIMEOUT_SECONDS_FOR_DOWNLOADS);
+      } else {
+        op = Util.createHttpUrlConnection(url, httpMethod, url.startsWith("https://"),
+            Constants.NETWORK_TIMEOUT_SECONDS_FOR_DOWNLOADS);
+      }
+      originalURL = op.getURL();
+      op.connect();
+      int statusCode = op.getResponseCode();
+      if (statusCode != 200) {
+        throw new Exception("Leanplum: Error sending request to: " + hostName +
+            ", HTTP status code: " + statusCode);
+      }
+      Stack<String> dirs = new Stack<>();
+      String currentDir = path;
+      while ((currentDir = new File(currentDir).getParent()) != null) {
+        dirs.push(currentDir);
+      }
+      while (!dirs.isEmpty()) {
+        String directory = FileManager.fileRelativeToDocuments(dirs.pop());
+        boolean isCreated = new File(directory).mkdir();
+        if (!isCreated) {
+          Log.w("Failed to create directory: ", directory);
+        }
+      }
+
+      FileOutputStream out = new FileOutputStream(
+          new File(FileManager.fileRelativeToDocuments(path)));
+      Util.saveResponse(op, out);
+      pendingDownloads--;
+      if (Request.this.response != null) {
+        Request.this.response.response(null);
+      }
+      if (pendingDownloads == 0 && noPendingDownloadsBlock != null) {
+        noPendingDownloadsBlock.noPendingDownloads();
+      }
+    } catch (Exception e) {
+      if (e instanceof EOFException) {
+        if (op != null && !op.getURL().equals(originalURL)) {
+          downloadHelper(null, op.getURL().toString(), path, url, new HashMap<String, Object>());
+          return;
+        }
+      }
+      Log.e("Error downloading resource:" + path, e);
+      pendingDownloads--;
+      if (error != null) {
+        error.error(e);
+      }
+      if (pendingDownloads == 0 && noPendingDownloadsBlock != null) {
+        noPendingDownloadsBlock.noPendingDownloads();
+      }
+    } finally {
+      if (op != null) {
+        op.disconnect();
+      }
+    }
+  }
+
+  public static int numPendingDownloads() {
+    return pendingDownloads;
+  }
+
+  public static void onNoPendingDownloads(NoPendingDownloadsCallback block) {
+    noPendingDownloadsBlock = block;
+  }
+
+
+  public static int numResponses(JSONObject response) {
+    if (response == null) {
+      return 0;
+    }
+    try {
+      return response.getJSONArray("response").length();
+    } catch (JSONException e) {
+      Log.e("Could not parse JSON response.", e);
+      return 0;
+    }
+  }
+
+  public static JSONObject getResponseAt(JSONObject response, int index) {
+    try {
+      return response.getJSONArray("response").getJSONObject(index);
+    } catch (JSONException e) {
+      Log.e("Could not parse JSON response.", e);
+      return null;
+    }
+  }
+
+  public static JSONObject getLastResponse(JSONObject response) {
+    int numResponses = numResponses(response);
+    if (numResponses > 0) {
+      return getResponseAt(response, numResponses - 1);
+    } else {
+      return null;
+    }
+  }
+
+  public static boolean isResponseSuccess(JSONObject response) {
+    if (response == null) {
+      return false;
+    }
+    try {
+      return response.getBoolean("success");
+    } catch (JSONException e) {
+      Log.e("Could not parse JSON response.", e);
+      return false;
+    }
+  }
+
+  public static String getResponseError(JSONObject response) {
+    if (response == null) {
+      return null;
+    }
+    try {
+      JSONObject error = response.optJSONObject("error");
+      if (error == null) {
+        return null;
+      }
+      return error.getString("message");
+    } catch (JSONException e) {
+      Log.e("Could not parse JSON response.", e);
+      return null;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/RequestFactory.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import java.util.Map;
+
+public class RequestFactory {
+  public static RequestFactory defaultFactory;
+
+  public synchronized static RequestFactory getInstance() {
+    if (defaultFactory == null) {
+      defaultFactory = new RequestFactory();
+    }
+    return defaultFactory;
+  }
+
+  public Request createRequest(
+      String httpMethod, String apiMethod, Map<String, Object> params) {
+    return new Request(httpMethod, apiMethod, params);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/ResourceQualifiers.java
@@ -0,0 +1,538 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.content.res.Configuration;
+import android.os.Build;
+import android.util.DisplayMetrics;
+
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.Map;
+
+public class ResourceQualifiers {
+  public static abstract class QualifierFilter {
+    abstract Object getMatch(String str);
+
+    public abstract boolean isMatch(Object value, Configuration config, DisplayMetrics display);
+
+    public Map<String, Object> bestMatch(Map<String, Object> values, Configuration config, DisplayMetrics display) {
+      return values;
+    }
+  }
+
+  public enum Qualifier {
+    MCC(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if (str.startsWith("mcc")) {
+          return Integer.getInteger(str.substring(3));
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return config.mcc == ((Integer) value);
+      }
+    }),
+    MNC(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if (str.startsWith("mnc")) {
+          return Integer.getInteger(str.substring(3));
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return config.mnc == ((Integer) value);
+      }
+    }),
+    LANGUAGE(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if (str.length() == 2) {
+          return str;
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return config.locale.getLanguage().equals(value);
+      }
+    }),
+    REGION(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if (str.startsWith("r") && str.length() == 3) {
+          return str.substring(1);
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return config.locale.getCountry().toLowerCase().equals(value);
+      }
+    }),
+    LAYOUT_DIRECTION(new QualifierFilter() {
+      // From http://developer.android.com/reference/android/content/res/Configuration.html#SMALLEST_SCREEN_WIDTH_DP_UNDEFINED
+      public static final int SCREENLAYOUT_LAYOUTDIR_LTR = 0x00000040;
+      public static final int SCREENLAYOUT_LAYOUTDIR_RTL = 0x00000080;
+      public static final int SCREENLAYOUT_LAYOUTDIR_MASK = 0x000000c0;
+
+      @Override
+      public Object getMatch(String str) {
+        if ("ldrtl".equals(str)) {
+          return SCREENLAYOUT_LAYOUTDIR_RTL;
+        } else if ("ldltr".equals(str)) {
+          return SCREENLAYOUT_LAYOUTDIR_LTR;
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return (config.screenLayout & SCREENLAYOUT_LAYOUTDIR_MASK) == (Integer) value;
+      }
+    }),
+    SMALLEST_WIDTH(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if (str.startsWith("sw") && str.endsWith("dp")) {
+          return Integer.getInteger(str.substring(2, str.length() - 2));
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        try {
+          Field field = config.getClass().getField("smallestScreenWidthDp");
+          int smallestWidthDp = (int) (Integer) field.get(config);
+          return smallestWidthDp >= (Integer) value;
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+        return false;
+      }
+
+      @Override
+      public Map<String, Object> bestMatch(Map<String, Object> values, Configuration config, DisplayMetrics display) {
+        Map<String, Object> result = new HashMap<>();
+        int max = Integer.MIN_VALUE;
+        for (Map.Entry<String, Object> entry : values.entrySet()) {
+          Integer intObj = (Integer) entry.getValue();
+          if (intObj > max) {
+            max = intObj;
+            result.clear();
+          }
+          if (intObj == max) {
+            result.put(entry.getKey(), intObj);
+          }
+        }
+        return result;
+      }
+    }),
+    AVAILABLE_WIDTH(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if (str.startsWith("w") && str.endsWith("dp")) {
+          return Integer.getInteger(str.substring(1, str.length() - 2));
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        try {
+          Field field = config.getClass().getField("screenWidthDp");
+          int screenWidthDp = (int) (Integer) field.get(config);
+          return screenWidthDp >= (Integer) value;
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+        return false;
+      }
+
+      @Override
+      public Map<String, Object> bestMatch(Map<String, Object> values, Configuration config, DisplayMetrics display) {
+        Map<String, Object> result = new HashMap<>();
+        int max = Integer.MIN_VALUE;
+        for (Map.Entry<String, Object> entry : values.entrySet()) {
+          Integer intObj = (Integer) entry.getValue();
+          if (intObj > max) {
+            max = intObj;
+            result.clear();
+          }
+          if (intObj == max) {
+            result.put(entry.getKey(), intObj);
+          }
+        }
+        return result;
+      }
+    }),
+    AVAILABLE_HEIGHT(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if (str.startsWith("h") && str.endsWith("dp")) {
+          return Integer.getInteger(str.substring(1, str.length() - 2));
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        try {
+          Field field = config.getClass().getField("screenHeightDp");
+          int screenHeightDp = (int) (Integer) field.get(config);
+          return screenHeightDp >= (Integer) value;
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+        return false;
+      }
+
+      @Override
+      public Map<String, Object> bestMatch(Map<String, Object> values, Configuration config, DisplayMetrics display) {
+        Map<String, Object> result = new HashMap<>();
+        int max = Integer.MIN_VALUE;
+        for (Map.Entry<String, Object> entry : values.entrySet()) {
+          Integer intObj = (Integer) entry.getValue();
+          if (intObj > max) {
+            max = intObj;
+            result.clear();
+          }
+          if (intObj == max) {
+            result.put(entry.getKey(), intObj);
+          }
+        }
+        return result;
+      }
+    }),
+    SCREEN_SIZE(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if ("small".equals(str)) {
+          return Configuration.SCREENLAYOUT_SIZE_SMALL;
+        } else if ("normal".equals(str)) {
+          return Configuration.SCREENLAYOUT_SIZE_NORMAL;
+        } else if ("large".equals(str)) {
+          return Configuration.SCREENLAYOUT_SIZE_LARGE;
+        } else if ("xlarge".equals(str)) {
+          return Configuration.SCREENLAYOUT_SIZE_XLARGE;
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return (config.screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) <= (Integer) value;
+      }
+
+      @Override
+      public Map<String, Object> bestMatch(Map<String, Object> values, Configuration config, DisplayMetrics display) {
+        Map<String, Object> result = new HashMap<>();
+        int max = Integer.MIN_VALUE;
+        for (Map.Entry<String, Object> entry : values.entrySet()) {
+          Integer intObj = (Integer) entry.getValue();
+          if (intObj > max) {
+            max = intObj;
+            result.clear();
+          }
+          if (intObj == max) {
+            result.put(entry.getKey(), intObj);
+          }
+        }
+        return result;
+      }
+    }),
+    SCREEN_ASPECT(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if ("long".equals(str)) {
+          return Configuration.SCREENLAYOUT_LONG_YES;
+        } else if ("notlong".equals(str)) {
+          return Configuration.SCREENLAYOUT_LONG_NO;
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return (config.screenLayout & Configuration.SCREENLAYOUT_LONG_MASK) == (Integer) value;
+      }
+    }),
+    SCREEN_ORIENTATION(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if ("port".equals(str)) {
+          return Configuration.ORIENTATION_PORTRAIT;
+        } else if ("land".equals(str)) {
+          return Configuration.ORIENTATION_LANDSCAPE;
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return config.orientation == (Integer) value;
+      }
+    }),
+    UI_MODE(new QualifierFilter() {
+      public static final int UI_MODE_TYPE_TELEVISION = 0x00000004;
+      public static final int UI_MODE_TYPE_APPLIANCE = 0x00000005;
+
+      @Override
+      public Object getMatch(String str) {
+        if ("car".equals(str)) {
+          return Configuration.UI_MODE_TYPE_CAR;
+        } else if ("desk".equals(str)) {
+          return Configuration.UI_MODE_TYPE_DESK;
+        } else if ("television".equals(str)) {
+          return UI_MODE_TYPE_TELEVISION;
+        } else if ("appliance".equals(str)) {
+          return UI_MODE_TYPE_APPLIANCE;
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return (config.uiMode & Configuration.UI_MODE_TYPE_MASK) == (Integer) value;
+      }
+    }),
+    NIGHT_MODE(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if ("night".equals(str)) {
+          return Configuration.UI_MODE_NIGHT_YES;
+        } else if ("notnight".equals(str)) {
+          return Configuration.UI_MODE_NIGHT_NO;
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return (config.uiMode & Configuration.UI_MODE_NIGHT_MASK) == (Integer) value;
+      }
+    }),
+    SCREEN_PIXEL_DENSITY(new QualifierFilter() {
+      public static final int DENSITY_TV = 0x000000d5;
+      public static final int DENSITY_XXHIGH = 0x000001e0;
+      public static final int DENSITY_NONE = 0;
+
+      @Override
+      public Object getMatch(String str) {
+        if ("ldpi".equals(str)) {
+          return DisplayMetrics.DENSITY_LOW;
+        } else if ("mdpi".equals(str)) {
+          return DisplayMetrics.DENSITY_MEDIUM;
+        } else if ("hdpi".equals(str)) {
+          return DisplayMetrics.DENSITY_HIGH;
+        } else if ("xhdpi".equals(str)) {
+          return DisplayMetrics.DENSITY_XHIGH;
+        } else if ("nodpi".equals(str)) {
+          return DENSITY_NONE;
+        } else if ("tvdpi".equals(str)) {
+          return DENSITY_TV;
+        } else if ("xxhigh".equals(str)) {
+          return DENSITY_XXHIGH;
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return true;
+      }
+
+      @Override
+      public Map<String, Object> bestMatch(Map<String, Object> values, Configuration config, DisplayMetrics display) {
+        Map<String, Object> result = new HashMap<>();
+        int min = Integer.MAX_VALUE;
+
+        for (Map.Entry<String, Object> entry : values.entrySet()) {
+          Integer intObj = (Integer) entry.getValue();
+          if (intObj < min && intObj >= display.densityDpi) {
+            min = intObj;
+            result.clear();
+          }
+          if (intObj == min) {
+            result.put(entry.getKey(), intObj);
+          }
+        }
+        if (result.size() == 0) {
+          int max = Integer.MIN_VALUE;
+          for (String key : values.keySet()) {
+            Integer intObj = (Integer) values.get(key);
+            if (intObj > max) {
+              max = intObj;
+              result.clear();
+            }
+            if (intObj == max) {
+              result.put(key, intObj);
+            }
+          }
+        }
+        return result;
+      }
+    }),
+    TOUCHSCREEN_TYPE(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if ("notouch".equals(str)) {
+          return Configuration.TOUCHSCREEN_NOTOUCH;
+        } else if ("finger".equals(str)) {
+          return Configuration.TOUCHSCREEN_FINGER;
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return config.touchscreen == (Integer) value;
+      }
+    }),
+    KEYBOARD_AVAILABILITY(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if ("keysexposed".equals(str)) {
+          return Configuration.KEYBOARDHIDDEN_NO;
+        } else if ("keyshidden".equals(str)) {
+          return Configuration.KEYBOARDHIDDEN_YES;
+        } else if ("keyssoft".equals(str)) {
+          return 0;
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return (Integer) value == 0 || config.keyboardHidden == (Integer) value;
+      }
+    }),
+    PRIMARY_TEXT_INPUTMETHOD(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if ("nokeys".equals(str)) {
+          return Configuration.KEYBOARD_NOKEYS;
+        } else if ("qwerty".equals(str)) {
+          return Configuration.KEYBOARD_QWERTY;
+        } else if ("12key".equals(str)) {
+          return Configuration.KEYBOARD_12KEY;
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return config.keyboard == (Integer) value;
+      }
+    }),
+    NAVIGATION_KEY_AVAILABILITY(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if ("navexposed".equals(str)) {
+          return Configuration.NAVIGATIONHIDDEN_NO;
+        } else if ("navhidden".equals(str)) {
+          return Configuration.NAVIGATIONHIDDEN_YES;
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return config.navigationHidden == (Integer) value;
+      }
+    }),
+    PRIMARY_NON_TOUCH_NAVIGATION_METHOD(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if ("nonav".equals(str)) {
+          return Configuration.NAVIGATION_NONAV;
+        } else if ("dpad".equals(str)) {
+          return Configuration.NAVIGATION_DPAD;
+        } else if ("trackball".equals(str)) {
+          return Configuration.NAVIGATION_TRACKBALL;
+        } else if ("wheel".equals(str)) {
+          return Configuration.NAVIGATION_WHEEL;
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return config.navigation == (Integer) value;
+      }
+    }),
+    PLATFORM_VERSION(new QualifierFilter() {
+      @Override
+      public Object getMatch(String str) {
+        if (str.startsWith("v")) {
+          return Integer.getInteger(str.substring(1));
+        }
+        return null;
+      }
+
+      @Override
+      public boolean isMatch(Object value, Configuration config, DisplayMetrics display) {
+        return Build.VERSION.SDK_INT >= (Integer) value;
+      }
+    });
+
+    private QualifierFilter filter;
+
+    Qualifier(QualifierFilter filter) {
+      this.filter = filter;
+    }
+
+    public QualifierFilter getFilter() {
+      return filter;
+    }
+  }
+
+  public Map<Qualifier, Object> qualifiers = new HashMap<>();
+
+  public static ResourceQualifiers fromFolder(String folderName) {
+    ResourceQualifiers result = new ResourceQualifiers();
+    String[] nameParts = folderName.toLowerCase().split("-");
+    int qualifierIndex = 0;
+    for (String part : nameParts) {
+      boolean isMatch = false;
+      while (!isMatch && qualifierIndex < Qualifier.values().length) {
+        Qualifier qualifier = Qualifier.values()[qualifierIndex];
+        Object match = qualifier.getFilter().getMatch(part);
+        if (match != null) {
+          result.qualifiers.put(qualifier, match);
+          isMatch = true;
+        }
+        qualifierIndex++;
+      }
+    }
+    return result;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/Socket.java
@@ -0,0 +1,332 @@
+/*
+ * Copyright 2016, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+
+import com.leanplum.ActionContext;
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+import com.leanplum.LeanplumEditorMode;
+import com.leanplum.callbacks.VariablesChangedCallback;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Timer;
+import java.util.TimerTask;
+
+/**
+ * Leanplum socket class, that handles connections to the Leanplum remote socket.
+ *
+ * @author Andrew First, Ben Marten
+ */
+public class Socket {
+  private static final String TAG = "Leanplum";
+  private static final String EVENT_CONTENT_RESPONSE = "getContentResponse";
+  private static final String EVENT_UPDATE_VARS = "updateVars";
+  private static final String EVENT_GET_VIEW_HIERARCHY = "getViewHierarchy";
+  private static final String EVENT_PREVIEW_UPDATE_RULES = "previewUpdateRules";
+  private static final String EVENT_TRIGGER = "trigger";
+  private static final String EVENT_GET_VARIABLES = "getVariables";
+  private static final String EVENT_GET_ACTIONS = "getActions";
+  private static final String EVENT_REGISTER_DEVICE = "registerDevice";
+
+  private static Socket instance = new Socket();
+  private SocketIOClient sio;
+  private boolean authSent;
+  private boolean connected = false;
+  private boolean connecting = false;
+
+  public Socket() {
+    createSocketClient();
+  }
+
+  public static Socket getInstance() {
+    return instance;
+  }
+
+  private void createSocketClient() {
+    SocketIOClient.Handler socketIOClientHandler = new SocketIOClient.Handler() {
+      @Override
+      public void onError(Exception error) {
+        Log.e("Development socket error", error);
+      }
+
+      @Override
+      public void onDisconnect(int code, String reason) {
+        Log.i("Disconnected from development server");
+        connected = false;
+        connecting = false;
+        authSent = false;
+      }
+
+      @Override
+      public void onConnect() {
+        if (!authSent) {
+          Log.i("Connected to development server");
+          try {
+            Map<String, String> args = Util.newMap(
+                Constants.Params.APP_ID, Request.appId(),
+                Constants.Params.DEVICE_ID, Request.deviceId());
+            try {
+              sio.emit("auth", new JSONArray(Collections.singletonList(new JSONObject(args))));
+            } catch (JSONException e) {
+              e.printStackTrace();
+            }
+          } catch (Throwable t) {
+            Util.handleException(t);
+          }
+          authSent = true;
+          connected = true;
+          connecting = false;
+        }
+      }
+
+      @Override
+      public void on(String event, JSONArray arguments) {
+        try {
+          switch (event) {
+            case EVENT_UPDATE_VARS:
+              Leanplum.forceContentUpdate();
+              break;
+            case EVENT_TRIGGER:
+              handleTriggerEvent(arguments);
+              break;
+            case EVENT_GET_VIEW_HIERARCHY:
+              LeanplumUIEditorWrapper.getInstance().startUpdating();
+              LeanplumUIEditorWrapper.getInstance().sendUpdate();
+              break;
+            case EVENT_PREVIEW_UPDATE_RULES:
+              previewUpdateRules(arguments);
+              break;
+            case EVENT_GET_VARIABLES:
+              handleGetVariablesEvent();
+              break;
+            case EVENT_GET_ACTIONS:
+              handleGetActionsEvent();
+              break;
+            case EVENT_REGISTER_DEVICE:
+              handleRegisterDeviceEvent(arguments);
+              break;
+            default:
+              break;
+          }
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+      }
+    };
+
+    try {
+      sio = new SocketIOClient(new URI("http://" + Constants.SOCKET_HOST + ":" +
+          Constants.SOCKET_PORT), socketIOClientHandler);
+    } catch (URISyntaxException e) {
+      Log.e(e.getMessage());
+    }
+    connect();
+    Timer reconnectTimer = new Timer();
+    reconnectTimer.schedule(new TimerTask() {
+      @Override
+      public void run() {
+        try {
+          reconnect();
+        } catch (Throwable t) {
+          Util.handleException(t);
+        }
+      }
+    }, 0, 5000);
+  }
+
+  /**
+   * Connect to the remote socket.
+   */
+  private void connect() {
+    connecting = true;
+    sio.connect();
+  }
+
+  /**
+   * Disconnect from the remote socket.
+   */
+  private void reconnect() {
+    if (!connected && !connecting) {
+      connect();
+    }
+  }
+
+  /**
+   * Send a given event and data to the remote socket server.
+   *
+   * @param eventName The name of the event.
+   * @param data The data to be sent to the remote server.
+   */
+  public <T> void sendEvent(String eventName, Map<String, T> data) {
+    try {
+      Log.p("Sending event: " + eventName + " & data over socket:\n" + data);
+      sio.emit(eventName,
+          new JSONArray(Collections.singletonList(JsonConverter.mapToJsonObject(data))));
+    } catch (JSONException e) {
+      Log.e("Failed to create JSON data object: " + e.getMessage());
+    }
+  }
+
+  /**
+   * Handles the "trigger" event received from server.
+   *
+   * @param arguments The arguments received from server.
+   */
+  void handleTriggerEvent(JSONArray arguments) {
+    // Trigger a custom action.
+    try {
+      JSONObject payload = arguments.getJSONObject(0);
+      JSONObject actionJson = payload.getJSONObject(Constants.Params.ACTION);
+      if (actionJson != null) {
+        String messageId = payload.getString(Constants.Params.MESSAGE_ID);
+        boolean isRooted = payload.getBoolean("isRooted");
+        String actionType = actionJson.getString(Constants.Values.ACTION_ARG);
+        Map<String, Object> defaultDefinition = CollectionUtil.uncheckedCast(
+            VarCache.actionDefinitions().get(actionType));
+        Map<String, Object> defaultArgs = null;
+        if (defaultDefinition != null) {
+          defaultArgs = CollectionUtil.uncheckedCast(defaultDefinition.get("values"));
+        }
+        Map<String, Object> action = JsonConverter.mapFromJson(actionJson);
+        action = CollectionUtil.uncheckedCast(VarCache.mergeHelper(defaultArgs, action));
+        ActionContext context = new ActionContext(actionType, action, messageId);
+        context.preventRealtimeUpdating();
+        ((BaseActionContext) context).setIsRooted(isRooted);
+        ((BaseActionContext) context).setIsPreview(true);
+        context.update();
+        LeanplumInternal.triggerAction(context);
+        ActionManager.getInstance().recordMessageImpression(messageId);
+      }
+    } catch (JSONException e) {
+      Log.e("Error getting action info", e);
+    }
+  }
+
+  /**
+   * Handles the "getVariables" event received from server.
+   */
+  public void handleGetVariablesEvent() {
+    boolean sentValues = VarCache.sendVariablesIfChanged();
+    VarCache.maybeUploadNewFiles();
+    sendEvent(EVENT_CONTENT_RESPONSE, Util.newMap("updated", sentValues));
+  }
+
+  /**
+   * Handles the "getActions" event received from server.
+   */
+  void handleGetActionsEvent() {
+    boolean sentValues = VarCache.sendActionsIfChanged();
+    VarCache.maybeUploadNewFiles();
+    sendEvent(EVENT_CONTENT_RESPONSE, Util.newMap("updated", sentValues));
+  }
+
+  /**
+   * Handles the "registerDevice" event received from server.
+   *
+   * @param arguments The arguments received from server.
+   */
+  void handleRegisterDeviceEvent(JSONArray arguments) {
+    LeanplumInternal.onHasStartedAndRegisteredAsDeveloper();
+    String emailArg = null;
+    try {
+      emailArg = arguments.getJSONObject(0).getString("email");
+    } catch (JSONException e) {
+      Log.v("Socket - No developer e-mail provided.");
+    }
+    final String email = (emailArg == null) ? "a Leanplum account" : emailArg;
+    OsHandler.getInstance().post(new Runnable() {
+      @Override
+      public void run() {
+        LeanplumActivityHelper.queueActionUponActive(new VariablesChangedCallback() {
+          @Override
+          public void variablesChanged() {
+            Activity activity = LeanplumActivityHelper.getCurrentActivity();
+            AlertDialog.Builder alert = new AlertDialog.Builder(activity);
+            alert.setTitle(TAG);
+            alert.setMessage("Your device is registered to " + email + ".");
+            alert.setPositiveButton("OK", new DialogInterface.OnClickListener() {
+              @Override
+              public void onClick(DialogInterface dialog, int which) {
+              }
+            });
+            alert.show();
+          }
+        });
+      }
+    });
+  }
+
+  void previewUpdateRules(JSONArray arguments) {
+    JSONObject packetData;
+    try {
+      packetData = arguments.getJSONObject(0);
+    } catch (Exception e) {
+      Log.e("Error parsing data");
+      return;
+    }
+
+    if (!packetData.optBoolean("closed")) {
+      LeanplumUIEditorWrapper.getInstance().startUpdating();
+    } else {
+      LeanplumUIEditorWrapper.getInstance().stopUpdating();
+    }
+
+    LeanplumEditorMode mode;
+    int intMode = packetData.optInt("mode");
+    if (intMode >= LeanplumEditorMode.values().length) {
+      Log.p("Invalid editor mode in packet");
+      mode = LeanplumEditorMode.LP_EDITOR_MODE_INTERFACE;
+    } else {
+      mode = LeanplumEditorMode.values()[intMode];
+    }
+    LeanplumUIEditorWrapper.getInstance().setMode(mode);
+
+    JSONArray rules = packetData.optJSONArray("rules");
+    if (rules != null) {
+      List<Map<String, Object>> ruleDiffs = JsonConverter.listFromJson(rules);
+      VarCache.applyUpdateRuleDiffs(ruleDiffs);
+    }
+
+    LeanplumUIEditorWrapper.getInstance().sendUpdateDelayedDefault();
+  }
+
+  /**
+   * Returns whether the socket connection is established
+   *
+   * @return true if connected
+   */
+  public boolean isConnected() {
+    return connected;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/SocketIOClient.java
@@ -0,0 +1,280 @@
+// Copyright (c) 2009-2012 James Coglan
+// Copyright (c) 2012 Eric Butler 
+// Copyright (c) 2012 Koushik Dutta 
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of
+// this software and associated documentation files (the 'Software'), to deal in
+// the Software without restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+// Software, and to permit persons to whom the Software is furnished to do so,
+// subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+//
+// From https://github.com/koush/android-websockets
+
+package com.leanplum.internal;
+
+import android.os.Looper;
+
+import com.leanplum.Leanplum;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.HashSet;
+
+class SocketIOClient {
+  interface Handler {
+    void onConnect();
+
+    void on(String event, JSONArray arguments);
+
+    void onDisconnect(int code, String reason);
+
+    void onError(Exception error);
+  }
+
+  private String mURL;
+  private Handler mHandler;
+  private String mSession;
+  private int mHeartbeat;
+  private WebSocketClient mClient;
+  private android.os.Handler mSendHandler;
+  private Looper mSendLooper;
+
+  public SocketIOClient(URI uri, Handler handler) {
+    // remove trailing "/" from URI, in case user provided e.g. http://test.com/
+    mURL = uri.toString().replaceAll("/$", "") + "/socket.io/1/";
+    mHandler = handler;
+  }
+
+  private static String userAgentString() {
+    String appName = (Leanplum.getContext() != null) ?
+        Util.getApplicationName(Leanplum.getContext()) + "/" + Util.getVersionName() : "websocket";
+    return appName + "(" + Request.appId() + "; " + Constants.CLIENT + "; "
+        + Constants.LEANPLUM_VERSION + "/" + Constants.LEANPLUM_PACKAGE_IDENTIFIER + ")";
+  }
+
+  private String downloadUriAsString()
+          throws IOException {
+    URL url = new URL(this.mURL);
+    HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+
+    try {
+      InputStream inputStream = connection.getInputStream();
+      BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
+
+      String tempStr;
+      StringBuffer stringBuffer = new StringBuffer();
+
+      while ((tempStr = bufferedReader.readLine()) != null) {
+        stringBuffer.append(tempStr);
+      }
+
+      bufferedReader.close();
+      inputStream.close();
+      return stringBuffer.toString();
+
+    } finally {
+      if (connection != null) {
+        connection.disconnect();
+      }
+    }
+  }
+
+  private static byte[] readToEndAsArray(InputStream input) throws IOException {
+    DataInputStream dis = new DataInputStream(input);
+    byte[] stuff = new byte[1024];
+    ByteArrayOutputStream buff = new ByteArrayOutputStream();
+    int read;
+    while ((read = dis.read(stuff)) != -1) {
+      buff.write(stuff, 0, read);
+    }
+
+    return buff.toByteArray();
+  }
+
+  private static String readToEnd(InputStream input) throws IOException {
+    return new String(readToEndAsArray(input));
+  }
+
+  public void emit(String name, JSONArray args) throws JSONException {
+    final JSONObject event = new JSONObject();
+    event.put("name", name);
+    event.put("args", args);
+    // Log.d("Emitting event: " + event.toString());
+    mSendHandler.post(new Runnable() {
+      @Override
+      public void run() {
+        mClient.send(String.format("5:::%s", event.toString()));
+      }
+    });
+  }
+
+  private void connectSession() throws URISyntaxException {
+    mClient = new WebSocketClient(new URI(mURL + "websocket/" + mSession),
+        new WebSocketClient.Listener() {
+          @Override
+          public void onMessage(byte[] data) {
+            cleanup();
+            mHandler.onError(new Exception("Unexpected binary data"));
+          }
+
+          @Override
+          public void onMessage(String message) {
+            try {
+              // Log.d("Message: " + message);
+              String[] parts = message.split(":", 4);
+              int code = Integer.parseInt(parts[0]);
+              switch (code) {
+                case 1:
+                  onConnect();
+                  break;
+                case 2:
+                  // heartbeat
+                  mClient.send("2::");
+                  break;
+                case 3:
+                  // message
+                case 4:
+                  // json message
+                  throw new Exception("message type not supported");
+                case 5: {
+                  final String messageId = parts[1];
+                  final String dataString = parts[3];
+                  JSONObject data = new JSONObject(dataString);
+                  String event = data.getString("name");
+                  JSONArray args;
+                  try {
+                    args = data.getJSONArray("args");
+                  } catch (JSONException e) {
+                    args = new JSONArray();
+                  }
+                  if (!"".equals(messageId)) {
+                    mSendHandler.post(new Runnable() {
+                      @Override
+                      public void run() {
+                        mClient.send(String.format("6:::%s", messageId));
+                      }
+                    });
+                  }
+                  mHandler.on(event, args);
+                  break;
+                }
+                case 6:
+                  // ACK
+                  break;
+                case 7:
+                  // error
+                  throw new Exception(message);
+                case 8:
+                  // noop
+                  break;
+                default:
+                  throw new Exception("unknown code");
+              }
+            } catch (Exception ex) {
+              cleanup();
+              onError(ex);
+            }
+          }
+
+          @Override
+          public void onError(Exception error) {
+            cleanup();
+            mHandler.onError(error);
+          }
+
+          @Override
+          public void onDisconnect(int code, String reason) {
+            cleanup();
+            // attempt reconnect with same session?
+            mHandler.onDisconnect(code, reason);
+          }
+
+          @Override
+          public void onConnect() {
+            mSendHandler.postDelayed(new Runnable() {
+              @Override
+              public void run() {
+                mSendHandler.postDelayed(this, mHeartbeat);
+                mClient.send("2:::");
+              }
+            }, mHeartbeat);
+            mHandler.onConnect();
+          }
+        }, null);
+    mClient.connect();
+  }
+
+  public void disconnect() throws IOException {
+    cleanup();
+  }
+
+  private void cleanup() {
+    if (mClient != null) {
+      mClient.disconnect();
+      mClient = null;
+    }
+
+    if (mSendLooper != null) {
+      mSendLooper.quit();
+    }
+    mSendLooper = null;
+    mSendHandler = null;
+  }
+
+  public void connect() {
+    if (mClient != null)
+      return;
+    new Thread() {
+      public void run() {
+
+        try {
+          String line = downloadUriAsString();
+          String[] parts = line.split(":");
+          mSession = parts[0];
+          String heartbeat = parts[1];
+          if (!"".equals(heartbeat))
+            mHeartbeat = Integer.parseInt(heartbeat) / 2 * 1000;
+          String transportsLine = parts[3];
+          String[] transports = transportsLine.split(",");
+          HashSet<String> set = new HashSet<>(Arrays.asList(transports));
+          if (!set.contains("websocket"))
+            throw new Exception("websocket not supported");
+
+          Looper.prepare();
+          mSendLooper = Looper.myLooper();
+          mSendHandler = new android.os.Handler();
+
+          connectSession();
+
+          Looper.loop();
+        } catch (Exception e) {
+          mHandler.onError(e);
+        }
+      }
+    }.start();
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/Util.java
@@ -0,0 +1,972 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.res.Resources;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.net.Uri;
+import android.net.wifi.WifiInfo;
+import android.net.wifi.WifiManager;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.provider.Settings.Secure;
+import android.support.annotation.RequiresPermission;
+import android.text.TextUtils;
+import android.util.TypedValue;
+
+import com.google.android.gms.ads.identifier.AdvertisingIdClient;
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+import com.leanplum.LeanplumDeviceIdMode;
+import com.leanplum.LeanplumException;
+import com.leanplum.internal.Constants.Methods;
+import com.leanplum.internal.Constants.Params;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.json.JSONTokener;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+import java.nio.charset.UnsupportedCharsetException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.SSLSocketFactory;
+
+/**
+ * Leanplum utilities.
+ *
+ * @author Andrew First
+ */
+public class Util {
+  private static final Executor asyncExecutor = Executors.newCachedThreadPool();
+
+  private static final String ACCESS_WIFI_STATE_PERMISSION = "android.permission.ACCESS_WIFI_STATE";
+
+  private static String appName = null;
+  private static String versionName = null;
+
+  private static boolean hasPlayServicesCalled = false;
+  private static boolean hasPlayServices = false;
+
+  public static class DeviceIdInfo {
+    public final String id;
+    public boolean limitAdTracking;
+
+    public DeviceIdInfo(String id) {
+      this.id = id;
+    }
+
+    public DeviceIdInfo(String id, boolean limitAdTracking) {
+      this.id = id;
+      this.limitAdTracking = limitAdTracking;
+    }
+  }
+
+  /**
+   * Gets MD5 hash of given string.
+   *
+   * @param string String for which want to have MD5 hash.
+   * @return String with MD5 hash of given string.
+   */
+  private static String md5(String string) throws Exception {
+    MessageDigest messageDigest = MessageDigest.getInstance("MD5");
+    messageDigest.update(string.getBytes(Charset.forName("UTF-8")));
+    byte digest[] = messageDigest.digest();
+
+    StringBuilder result = new StringBuilder();
+    for (byte dig : digest) {
+      result.append(String.format("%02x", dig));
+    }
+    return result.toString();
+  }
+
+  /**
+   * Gets SHA-256 hash of given string.
+   */
+  public static String sha256(String string) throws NoSuchAlgorithmException {
+    MessageDigest messageDigest = MessageDigest.getInstance("SHA256");
+    messageDigest.update(string.getBytes(Charset.forName("UTF-8")));
+    byte digest[] = messageDigest.digest();
+
+    StringBuilder result = new StringBuilder();
+    for (byte dig : digest) {
+      result.append(String.format("%02x", dig));
+    }
+    return result.toString();
+  }
+
+  private static String checkDeviceId(String deviceIdMethod, String deviceId) {
+    if (deviceId != null) {
+      if (!isValidDeviceId(deviceId)) {
+        Log.e("Invalid device id generated (" + deviceIdMethod + "): " + deviceId);
+        return null;
+      }
+    }
+    return deviceId;
+  }
+
+  @RequiresPermission(ACCESS_WIFI_STATE_PERMISSION)
+  private static String getWifiMacAddressHash(Context context) {
+    String logPrefix = "Skipping wifi device id; ";
+    if (context.checkCallingOrSelfPermission(ACCESS_WIFI_STATE_PERMISSION) !=
+        PackageManager.PERMISSION_GRANTED) {
+      Log.v(logPrefix + "no wifi state permissions.");
+      return null;
+    }
+    try {
+      WifiManager manager = (WifiManager) context.getApplicationContext()
+          .getSystemService(Context.WIFI_SERVICE);
+      WifiInfo wifiInfo = manager.getConnectionInfo();
+      if (wifiInfo == null) {
+        Log.i(logPrefix + "null WifiInfo.");
+        return null;
+      }
+      @SuppressLint("HardwareIds")
+      String macAddress = wifiInfo.getMacAddress();
+      if (macAddress == null || macAddress.isEmpty()) {
+        Log.i(logPrefix + "no mac address returned.");
+        return null;
+      }
+      if (Constants.INVALID_MAC_ADDRESS.equals(macAddress)) {
+        // Note(ed): this is the expected case for Marshmallow and later, as they return
+        // INVALID_MAC_ADDRESS; we intend to fall back to the Android id for Marshmallow devices.
+        Log.v(logPrefix + "Marshmallow and later returns a fake MAC address.");
+        return null;
+      }
+      @SuppressLint("HardwareIds")
+      String deviceId = md5(wifiInfo.getMacAddress());
+      Log.v("Using wifi device id: " + deviceId);
+      return checkDeviceId("mac address", deviceId);
+    } catch (Exception e) {
+      Log.w("Error getting wifi MAC address.");
+    }
+    return null;
+  }
+
+  /**
+   * Retrieves the advertising ID. Requires Google Play Services. Note: This method must not run on
+   * the main thread.
+   */
+  private static DeviceIdInfo getAdvertisingId(Context caller) throws Exception {
+    try {
+      AdvertisingIdClient.Info info = AdvertisingIdClient.getAdvertisingIdInfo(caller);
+      if (info != null) {
+        String advertisingId = info.getId();
+        String deviceId = checkDeviceId("advertising id", advertisingId);
+        if (deviceId != null) {
+          boolean limitedTracking = info.isLimitAdTrackingEnabled();
+          return new DeviceIdInfo(deviceId, limitedTracking);
+        }
+      }
+    } catch (Throwable t) {
+      Log.e("Error getting advertising ID. Google Play Services are not available: ", t);
+    }
+    return null;
+  }
+
+  private static String getAndroidId(Context context) {
+    @SuppressLint("HardwareIds")
+    String androidId = Secure.getString(context.getContentResolver(), Secure.ANDROID_ID);
+    if (androidId == null || androidId.isEmpty()) {
+      Log.i("Skipping Android device id; no id returned.");
+      return null;
+    }
+    if (Constants.INVALID_ANDROID_ID.equals(androidId)) {
+      Log.v("Skipping Android device id; got invalid " + "device id: " + androidId);
+      return null;
+    }
+    Log.v("Using Android device id: " + androidId);
+    return checkDeviceId("android id", androidId);
+  }
+
+  /**
+   * Final fallback device id -- generate a random device id.
+   */
+  private static String generateRandomDeviceId() {
+    // Mark random IDs to be able to identify them.
+    String randomId = UUID.randomUUID().toString() + "-LP";
+    Log.v("Using generated device id: " + randomId);
+    return randomId;
+  }
+
+  private static boolean isValidForCharset(String id, String charsetName) {
+    CharsetEncoder encoder = null;
+    try {
+      Charset charset = Charset.forName(charsetName);
+      encoder = charset.newEncoder();
+    } catch (UnsupportedCharsetException e) {
+      Log.w("Unsupported charset: " + charsetName);
+    }
+    if (encoder != null && !encoder.canEncode(id)) {
+      Log.v("Invalid id (contains invalid characters): " + id);
+      return false;
+    }
+    return true;
+  }
+
+  public static boolean isValidUserId(String userId) {
+    String logPrefix = "Invalid user id ";
+    if (userId == null || userId.isEmpty()) {
+      Log.v(logPrefix + "(sentinel): " + userId);
+      return false;
+    }
+    if (userId.length() > Constants.MAX_USER_ID_LENGTH) {
+      Log.v(logPrefix + "(too long): " + userId);
+      return false;
+    }
+    if (userId.contains("\n")) {
+      Log.v(logPrefix + "(contains newline): " + userId);
+      return false;
+    }
+    if (userId.contains("\"") || userId.contains("\'")) {
+      Log.v(logPrefix + "(contains quotes): " + userId);
+      return false;
+    }
+    return isValidForCharset(userId, "UTF-8");
+  }
+
+  public static boolean isValidDeviceId(String deviceId) {
+    String logPrefix = "Invalid device id ";
+    if (deviceId == null || deviceId.isEmpty() ||
+        Constants.INVALID_ANDROID_ID.equals(deviceId) ||
+        Constants.INVALID_MAC_ADDRESS_HASH.equals(deviceId) ||
+        Constants.OLD_INVALID_MAC_ADDRESS_HASH.equals(deviceId)) {
+      Log.v(logPrefix + "(sentinel): " + deviceId);
+      return false;
+    }
+    if (deviceId.length() > Constants.MAX_DEVICE_ID_LENGTH) {
+      Log.v(logPrefix + "(too long): " + deviceId);
+      return false;
+    }
+    if (deviceId.contains("[")) {
+      Log.v(logPrefix + "(contains brackets): " + deviceId);
+      return false;
+    }
+    if (deviceId.contains("\n")) {
+      Log.v(logPrefix + "(contains newline): " + deviceId);
+      return false;
+    }
+    if (deviceId.contains(",")) {
+      Log.v(logPrefix + "(contains comma): " + deviceId);
+      return false;
+    }
+    if (deviceId.contains("\"") || deviceId.contains("\'")) {
+      Log.v(logPrefix + "(contains quotes): " + deviceId);
+      return false;
+    }
+    return isValidForCharset(deviceId, "US-ASCII");
+  }
+
+  @RequiresPermission(ACCESS_WIFI_STATE_PERMISSION)
+  public static DeviceIdInfo getDeviceId(LeanplumDeviceIdMode mode) {
+    Context context = Leanplum.getContext();
+
+    if (mode.equals(LeanplumDeviceIdMode.ADVERTISING_ID)) {
+      try {
+        DeviceIdInfo info = getAdvertisingId(context);
+        if (info != null) {
+          return info;
+        }
+      } catch (Exception e) {
+        Log.e("Error getting advertising ID", e);
+      }
+    }
+
+    if (isSimulator() || mode.equals(LeanplumDeviceIdMode.ANDROID_ID)) {
+      String androidId = getAndroidId(context);
+      if (androidId != null) {
+        return new DeviceIdInfo(getAndroidId(context));
+      }
+    }
+
+    String macAddressHash = getWifiMacAddressHash(context);
+    if (macAddressHash != null) {
+      return new DeviceIdInfo(macAddressHash);
+    }
+
+    String androidId = getAndroidId(context);
+    if (androidId != null) {
+      return new DeviceIdInfo(androidId);
+    }
+
+    return new DeviceIdInfo(generateRandomDeviceId());
+  }
+
+  public static String getVersionName() {
+    if (versionName != null) {
+      return versionName;
+    }
+    Context context = Leanplum.getContext();
+    try {
+      versionName = LeanplumManifestHelper.getAppVersionName();
+      // If we didn't get application version name from AndroidManifest.xml - will try to get it
+      // from PackageInfo.
+      if (TextUtils.isEmpty(versionName)) {
+        PackageInfo pInfo = context.getPackageManager().getPackageInfo(
+            context.getPackageName(), 0);
+        versionName = pInfo.versionName;
+      }
+    } catch (Exception e) {
+      Log.w("Could not extract versionName from Manifest or PackageInfo.");
+    }
+    return versionName;
+  }
+
+  public static String getDeviceModel() {
+    if (isSimulator()) {
+      return "Android Emulator";
+    }
+    String manufacturer = Build.MANUFACTURER;
+    String model = Build.MODEL;
+    if (model.startsWith(manufacturer)) {
+      return capitalize(model);
+    } else {
+      return capitalize(manufacturer) + " " + model;
+    }
+  }
+
+  public static String getApplicationName(Context context) {
+    if (appName != null) {
+      return appName;
+    }
+    int stringId = context.getApplicationInfo().labelRes;
+    if (stringId == 0) {
+      appName = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString();
+    } else {
+      appName = context.getString(stringId);
+    }
+    return appName;
+  }
+
+  private static String capitalize(String s) {
+    if (s == null || s.length() == 0) {
+      return "";
+    }
+    char first = s.charAt(0);
+    if (Character.isUpperCase(first)) {
+      return s;
+    } else {
+      return Character.toUpperCase(first) + s.substring(1);
+    }
+  }
+
+  @SuppressWarnings("SameReturnValue")
+  public static String getSystemName() {
+    return "Android OS";
+  }
+
+  @SuppressWarnings("SameReturnValue")
+  public static String getSystemVersion() {
+    return Build.VERSION.RELEASE;
+  }
+
+  public static boolean isSimulator() {
+    String model = android.os.Build.MODEL.toLowerCase(Locale.getDefault());
+    return model.contains("google_sdk")
+        || model.contains("emulator")
+        || model.contains("sdk");
+  }
+
+  public static String getDeviceName() {
+    if (isSimulator()) {
+      return "Android Emulator";
+    }
+    return getDeviceModel();
+  }
+
+  public static String getLocale() {
+    String language = Locale.getDefault().getLanguage();
+    if ("".equals(language)) {
+      language = "xx";
+    }
+    String country = Locale.getDefault().getCountry();
+    if ("".equals(country)) {
+      country = "XX";
+    }
+    return language + "_" + country;
+  }
+
+  /**
+   * Builds a query from Map containing parameters.
+   *
+   * @param params Params used to build a query.
+   * @return Query string or empty string in case params are null.
+   */
+  private static String getQuery(Map<String, Object> params) {
+    if (params == null) {
+      return "";
+    }
+    Uri.Builder builder = new Uri.Builder();
+    for (Map.Entry<String, Object> pair : params.entrySet()) {
+      if (pair.getValue() == null) {
+        Log.w("Request parameter for key: " + pair.getKey() + " is null.");
+        continue;
+      }
+      builder.appendQueryParameter(pair.getKey(), pair.getValue().toString());
+    }
+    return builder.build().getEncodedQuery();
+  }
+
+  public static HttpURLConnection operation(
+      String hostName,
+      String path,
+      Map<String, Object> params,
+      String httpMethod,
+      boolean ssl,
+      int timeoutSeconds) throws IOException {
+    if ("GET".equals(httpMethod)) {
+      path = attachGetParameters(path, params);
+    }
+    HttpURLConnection urlConnection = createHttpUrlConnection(hostName, path,
+        httpMethod, ssl, timeoutSeconds);
+
+    if (!"GET".equals(httpMethod)) {
+      attachPostParameters(params, urlConnection);
+    }
+
+    if (Constants.enableVerboseLoggingInDevelopmentMode
+        && Constants.isDevelopmentModeEnabled) {
+      Log.d("Sending request at path " + path + " with parameters " + params);
+    }
+    return urlConnection;
+  }
+
+  /**
+   * Converts and attaches GET parameters to specified path.
+   *
+   * @param path Path on which to attach parameters.
+   * @param params Params to convert and attach.
+   * @return Path with attached parameters.
+   */
+  private static String attachGetParameters(String path, Map<String, Object> params) {
+    if (params == null) {
+      return path;
+    }
+    Uri.Builder builder = Uri.parse(path).buildUpon();
+    for (Map.Entry<String, Object> pair : params.entrySet()) {
+      if (pair.getValue() == null) {
+        continue;
+      }
+      builder.appendQueryParameter(pair.getKey(), pair.getValue().toString());
+    }
+    return builder.build().toString();
+  }
+
+  /**
+   * Converts and writes POST parameters directly to an option http connection.
+   *
+   * @param params Params to post.
+   * @param urlConnection URL connection on which to write parameters.
+   * @throws IOException Throws in case it fails.
+   */
+  private static void attachPostParameters(Map<String, Object> params,
+      HttpURLConnection urlConnection) throws IOException {
+    OutputStream os = urlConnection.getOutputStream();
+    BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(os, "UTF-8"));
+    String query = getQuery(params);
+    writer.write(query);
+    writer.close();
+    os.close();
+  }
+
+  public static HttpURLConnection createHttpUrlConnection(String hostName,
+      String path, String httpMethod, boolean ssl, int timeoutSeconds)
+      throws IOException {
+    String fullPath;
+    if (path.startsWith("http")) {
+      fullPath = path;
+    } else {
+      fullPath = (ssl ? "https://" : "http://") + hostName + "/" + path;
+    }
+    return createHttpUrlConnection(fullPath, httpMethod, ssl, timeoutSeconds);
+  }
+
+  static HttpURLConnection createHttpUrlConnection(
+      String fullPath, String httpMethod, boolean ssl, int timeoutSeconds)
+      throws IOException {
+    URL url = new URL(fullPath);
+    HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
+    if (ssl) {
+      SSLSocketFactory socketFactory = (SSLSocketFactory) SSLSocketFactory.getDefault();
+      ((HttpsURLConnection) urlConnection).setSSLSocketFactory(socketFactory);
+    }
+    urlConnection.setReadTimeout(timeoutSeconds * 1000);
+    urlConnection.setConnectTimeout(timeoutSeconds * 1000);
+    urlConnection.setRequestMethod(httpMethod);
+    urlConnection.setDoOutput(!"GET".equals(httpMethod));
+    urlConnection.setDoInput(true);
+    urlConnection.setUseCaches(false);
+    urlConnection.setInstanceFollowRedirects(true);
+    Context context = Leanplum.getContext();
+    urlConnection.setRequestProperty("User-Agent",
+        getApplicationName(context) + "/" + getVersionName() + "/" + Request.appId() + "/" +
+            Constants.CLIENT + "/" + Constants.LEANPLUM_VERSION + "/" + getSystemName() + "/" +
+            getSystemVersion() + "/" + Constants.LEANPLUM_PACKAGE_IDENTIFIER);
+    return urlConnection;
+  }
+
+  /**
+   * Writes the filesToUpload to a new HttpURLConnection using the multipart form data format.
+   *
+   * @return the connection that the files were uploaded using
+   */
+  public static HttpURLConnection uploadFilesOperation(
+      String key,
+      List<File> filesToUpload,
+      List<InputStream> streams,
+      String hostName,
+      String path,
+      Map<String, Object> params,
+      String httpMethod,
+      boolean ssl,
+      int timeoutSeconds) throws IOException {
+
+    HttpURLConnection urlConnection = createHttpUrlConnection(hostName, path,
+        httpMethod, ssl, timeoutSeconds);
+
+    final String BOUNDARY = "==================================leanplum";
+    final String LINE_END = "\r\n";
+    final String TWO_HYPHENS = "--";
+    final String CONTENT_TYPE = "Content-Type: application/octet-stream";
+
+    // Make a connection to the server
+    urlConnection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + BOUNDARY);
+    urlConnection.setRequestProperty("Connection", "Keep-Alive");
+
+    DataOutputStream outputStream = new DataOutputStream(urlConnection.getOutputStream());
+
+    // Create the header for the request with the parameters
+    for (Map.Entry<String, Object> entry : params.entrySet()) {
+      String paramData = TWO_HYPHENS + BOUNDARY + LINE_END
+          + "Content-Disposition: form-data; name=\"" + entry.getKey() + "\"" + LINE_END
+          + LINE_END
+          + entry.getValue() + LINE_END;
+      outputStream.writeBytes(paramData);
+    }
+
+    // Main file writing loop
+    for (int i = 0; i < filesToUpload.size(); i++) {
+      File fileToUpload = filesToUpload.get(i);
+      String contentDisposition = String.format(Locale.getDefault(), "Content-Disposition: " +
+              "form-data; name=\"%s%d\";filename=\"%s\"",
+          key, i, fileToUpload.getName());
+
+      // Create the header for the file
+      String fileHeader = TWO_HYPHENS + BOUNDARY + LINE_END
+          + contentDisposition + LINE_END
+          + CONTENT_TYPE + LINE_END
+          + LINE_END;
+      outputStream.writeBytes(fileHeader);
+
+      // Read in the actual file
+      InputStream is = (i < streams.size()) ? streams.get(i) : new FileInputStream(fileToUpload);
+      byte[] buffer = new byte[4096];
+      int bytesRead;
+      try {
+        while ((bytesRead = is.read(buffer)) != -1) {
+          outputStream.write(buffer, 0, bytesRead);
+        }
+      } catch (NullPointerException e) {
+        Log.e("Unable to read file while uploading " + filesToUpload.get(i));
+        return null;
+      } finally {
+        if (is != null) {
+          try {
+            is.close();
+          } catch (IOException e) {
+            Log.w("Failed to close InputStream: " + e);
+          }
+        }
+      }
+
+      // End the output for this file
+      outputStream.writeBytes(LINE_END);
+    }
+
+    // End the output for the request
+    String endOfRequest = TWO_HYPHENS + BOUNDARY + TWO_HYPHENS + LINE_END;
+    outputStream.writeBytes(endOfRequest);
+
+    outputStream.flush();
+    outputStream.close();
+    return urlConnection;
+  }
+
+  public static void saveResponse(URLConnection op, OutputStream outputStream) throws IOException {
+    InputStream is = op.getInputStream();
+    byte[] buffer = new byte[4096];
+    int bytesRead;
+    while ((bytesRead = is.read(buffer)) != -1) {
+      outputStream.write(buffer, 0, bytesRead);
+    }
+    outputStream.close();
+  }
+
+  private static String getResponse(HttpURLConnection op) throws IOException {
+    InputStream inputStream;
+    if (op.getResponseCode() < 400) {
+      inputStream = op.getInputStream();
+    } else {
+      inputStream = op.getErrorStream();
+    }
+    BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8"));
+    StringBuilder builder = new StringBuilder();
+    for (String line; (line = reader.readLine()) != null; ) {
+      builder.append(line).append("\n");
+    }
+
+    try {
+      inputStream.close();
+      reader.close();
+    } catch (Exception ignored) {
+    }
+
+    return builder.toString();
+  }
+
+  public static JSONObject getJsonResponse(HttpURLConnection op)
+      throws JSONException, IOException {
+    String response = getResponse(op);
+    if (Constants.enableVerboseLoggingInDevelopmentMode
+        && Constants.isDevelopmentModeEnabled) {
+      Log.d("Received response " + response);
+    }
+    JSONTokener tokener = new JSONTokener(response);
+    return new JSONObject(tokener);
+  }
+
+  /**
+   * Check whether the device has a network connection. WARNING: Does not check for available
+   * internet connection! use isOnline()
+   *
+   * @return Whether a network connection is available or not.
+   */
+  public static boolean isConnected() {
+    try {
+      Context context = Leanplum.getContext();
+      ConnectivityManager manager = (ConnectivityManager) context.getSystemService(
+          Context.CONNECTIVITY_SERVICE);
+      if (manager == null) {
+        return false;
+      }
+      NetworkInfo netInfo = manager.getActiveNetworkInfo();
+      return !(netInfo == null || !netInfo.isConnectedOrConnecting());
+    } catch (Exception e) {
+      Log.e("Error getting connectivity info", e);
+      return false;
+    }
+  }
+
+  public static <T> T multiIndex(Map<?, ?> map, Object... indices) {
+    if (map == null) {
+      return null;
+    }
+    Object current = map;
+    for (Object index : indices) {
+      if (!((Map<?, ?>) current).containsKey(index)) {
+        return null;
+      }
+      current = ((Map<?, ?>) current).get(index);
+    }
+    return CollectionUtil.uncheckedCast(current);
+  }
+
+  public static <T> void executeAsyncTask(AsyncTask<T, ?, ?> task, T... params) {
+    if (Build.VERSION.SDK_INT >= 11) {
+      task.executeOnExecutor(asyncExecutor, params);
+    } else {
+      task.execute(params);
+    }
+  }
+
+  /**
+   * Check the device to make sure it has the Google Play Services APK. If it doesn't, display a
+   * dialog that allows users to download the APK from the Google Play Store or enable it in the
+   * device's system settings.
+   */
+  public static boolean hasPlayServices() {
+    if (hasPlayServicesCalled) {
+      return hasPlayServices;
+    }
+    Context context = Leanplum.getContext();
+    PackageManager packageManager = context.getPackageManager();
+    PackageInfo packageInfo;
+    try {
+      packageInfo = packageManager.getPackageInfo("com.google.android.gms",
+          PackageManager.GET_SIGNATURES);
+    } catch (PackageManager.NameNotFoundException e) {
+      hasPlayServicesCalled = true;
+      hasPlayServices = false;
+      return false;
+    }
+    if (packageInfo.versionCode < 4242000) {
+      Log.i("Google Play services version is too old: " + packageInfo.versionCode);
+      hasPlayServicesCalled = true;
+      hasPlayServices = false;
+      return false;
+    }
+    ApplicationInfo info;
+    try {
+      info = packageManager.getApplicationInfo("com.google.android.gms", 0);
+    } catch (PackageManager.NameNotFoundException e) {
+      hasPlayServicesCalled = true;
+      hasPlayServices = false;
+      return false;
+    }
+    hasPlayServicesCalled = true;
+    hasPlayServices = info.enabled;
+    return info.enabled;
+  }
+
+  public static boolean isInBackground() {
+    return (LeanplumActivityHelper.getCurrentActivity() == null ||
+        LeanplumActivityHelper.isActivityPaused());
+  }
+
+  /**
+   * Include install time and last app update time in start API params the first time that the app
+   * runs with Leanplum.
+   */
+  public static void initializePreLeanplumInstall(Map<String, Object> params) {
+    Context context = Leanplum.getContext();
+    SharedPreferences preferences = context.getSharedPreferences("__leanplum__",
+        Context.MODE_PRIVATE);
+    if (preferences.getBoolean(Constants.Keys.INSTALL_TIME_INITIALIZED, false)) {
+      return;
+    }
+
+    PackageManager packageManager = context.getPackageManager();
+    String packageName = context.getPackageName();
+    setInstallTime(params, packageManager, packageName);
+    setUpdateTime(params, packageManager, packageName);
+
+    SharedPreferences.Editor editor = preferences.edit();
+    editor.putBoolean(Constants.Keys.INSTALL_TIME_INITIALIZED, true);
+    try {
+      editor.apply();
+    } catch (NoSuchMethodError e) {
+      editor.commit();
+    }
+  }
+
+  /**
+   * Set install time from package manager and update time from apk file modification time.
+   */
+  private static void setInstallTime(Map<String, Object> params, PackageManager packageManager,
+      String packageName) {
+    try {
+      PackageInfo info = packageManager.getPackageInfo(packageName, 0);
+      params.put(Params.INSTALL_DATE, "" + (info.firstInstallTime / 1000.0));
+    } catch (NameNotFoundException e) {
+      Log.w("Failed to find package info: " + e);
+    }
+  }
+
+  /**
+   * Set update time from apk file modification time.
+   */
+  private static void setUpdateTime(Map<String, Object> params, PackageManager packageManager,
+      String packageName) {
+    try {
+      ApplicationInfo info = packageManager.getApplicationInfo(packageName, 0);
+      File apkFile = new File(info.sourceDir);
+      if (apkFile.exists()) {
+        params.put(Constants.Params.UPDATE_DATE, "" + (apkFile.lastModified() / 1000.0));
+      }
+    } catch (Throwable t) {
+      Log.w("Failed to find package info: " + t);
+    }
+  }
+
+  /**
+   * Handles uncaught exceptions in the SDK.
+   */
+  public static void handleException(Throwable t) {
+    if (t instanceof OutOfMemoryError) {
+      if (Constants.isDevelopmentModeEnabled) {
+        throw (OutOfMemoryError) t;
+      }
+      return;
+    }
+
+    // Propagate Leanplum generated exceptions.
+    if (t instanceof LeanplumException) {
+      if (Constants.isDevelopmentModeEnabled) {
+        throw (LeanplumException) t;
+      }
+      return;
+    }
+
+    Log.e("INTERNAL ERROR", t);
+
+    String versionName;
+    try {
+      versionName = getVersionName();
+    } catch (Throwable t2) {
+      versionName = "(Unknown)";
+    }
+
+    try {
+      Map<String, Object> params = new HashMap<>();
+      params.put(Params.TYPE, Constants.Values.SDK_ERROR);
+
+      String message = t.getMessage();
+      if (message != null) {
+        message = t.toString() + " (" + message + ')';
+      } else {
+        message = t.toString();
+      }
+      params.put(Params.MESSAGE, message);
+
+      StringWriter stringWriter = new StringWriter();
+      PrintWriter writer = new PrintWriter(stringWriter);
+      t.printStackTrace(writer);
+      params.put("stackTrace", stringWriter.toString());
+
+      params.put(Params.VERSION_NAME, versionName);
+      Request.post(Methods.LOG, params).send();
+    } catch (Throwable t2) {
+      Log.e("Unable to send error report.", t2);
+    }
+  }
+
+  /**
+   * Constructs a {@link HashMap} with the given keys and values.
+   */
+  public static <K, V> Map<K, V> newMap(K firstKey, V firstValue, Object... otherValues) {
+    if (otherValues.length % 2 == 1) {
+      throw new IllegalArgumentException("Must supply an even number of values.");
+    }
+
+    Map<K, V> map = new HashMap<>();
+    map.put(firstKey, firstValue);
+    for (int i = 0; i < otherValues.length; i += 2) {
+      K otherKey = CollectionUtil.uncheckedCast(otherValues[i]);
+      V otherValue = CollectionUtil.uncheckedCast(otherValues[i + 1]);
+      map.put(otherKey, otherValue);
+    }
+    return map;
+  }
+
+  /**
+   * Generates a Resource name from resourceId located in res/ folder.
+   *
+   * @param resourceId id of the resource, must be greater then 0.
+   * @return resourceName in format folder/file.extension.
+   */
+  public static String generateResourceNameFromId(int resourceId) {
+    try {
+      if (resourceId <= 0) {
+        Log.w("Provided resource id is invalid.");
+        return null;
+      }
+      Resources resources = Leanplum.getContext().getResources();
+      // Get entryName from resourceId, which represents a file name in res/ directory.
+      String entryName = resources.getResourceEntryName(resourceId);
+      // Get typeName from resourceId, which represents a folder where file is located in
+      // res/ directory.
+      String typeName = resources.getResourceTypeName(resourceId);
+
+      // By using TypedValue we can get full path of a file with extension.
+      TypedValue value = new TypedValue();
+      resources.getValue(resourceId, value, true);
+
+      // Regex matching to find real file extension, "image.img.png" will produce "png".
+      String[] fullFileName = value.string.toString().split("\\.(?=[^\\.]+$)");
+      String extension = "";
+      // If extension is found, we will append dot before it.
+      if (fullFileName.length == 2) {
+        extension = "." + fullFileName[1];
+      }
+
+      // Return full resource name in format: drawable/image.png
+      return typeName + "/" + entryName + extension;
+    } catch (Exception e) {
+      Log.w("Failed to generate resource name from provided resource id: ", e);
+      Util.handleException(e);
+    }
+    return null;
+  }
+
+  /**
+   * Generates resource Id based on Resource name.
+   *
+   * @param resourceName name of the resource including folder and file extension.
+   * @return id of the resource if found, 0 otherwise.
+   */
+  public static int generateIdFromResourceName(String resourceName) {
+    // Split resource name to extract folder and file name.
+    String[] parts = resourceName.split("/");
+    if (parts.length == 2) {
+      Resources resources = Leanplum.getContext().getResources();
+      // Type name represents folder where file is contained.
+      String typeName = parts[0];
+      String fileName = parts[1];
+      String entryName = fileName;
+      // Since fileName contains extension we have to remove it,
+      // to be able to get resource id.
+      String[] fileParts = fileName.split("\\.(?=[^\\.]+$)");
+      if (fileParts.length == 2) {
+        entryName = fileParts[0];
+      }
+      // Get identifier for a file in specified directory
+      if (!TextUtils.isEmpty(typeName) && !TextUtils.isEmpty(entryName)) {
+        return resources.getIdentifier(entryName, typeName, Leanplum.getContext().getPackageName());
+      }
+    }
+    Log.w("Could not extract resource id from provided resource name: ", resourceName);
+    return 0;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/VarCache.java
@@ -0,0 +1,899 @@
+/*
+ * Copyright 2013, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.internal;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import com.leanplum.ActionContext;
+import com.leanplum.CacheUpdateBlock;
+import com.leanplum.Leanplum;
+import com.leanplum.LocationManager;
+import com.leanplum.Var;
+import com.leanplum.internal.FileManager.HashResults;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.InputStream;
+import java.lang.reflect.Array;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Variable cache.
+ *
+ * @author Andrew First.
+ */
+public class VarCache {
+  private static final Map<String, Var<?>> vars = new ConcurrentHashMap<>();
+  private static final Map<String, Object> fileAttributes = new HashMap<>();
+  private static final Map<String, InputStream> fileStreams = new HashMap<>();
+
+  /**
+   * The default values set by the client. This is not thread-safe so traversals should be
+   * synchronized.
+   */
+  public static final Map<String, Object> valuesFromClient = new HashMap<>();
+
+  private static final Map<String, String> defaultKinds = new HashMap<>();
+  private static final Map<String, Object> actionDefinitions = new HashMap<>();
+  private static final String LEANPLUM = "__leanplum__";
+  private static Map<String, Object> diffs = new HashMap<>();
+  private static Map<String, Object> regions = new HashMap<>();
+  private static Map<String, Object> messageDiffs = new HashMap<>();
+  private static List<Map<String, Object>> updateRuleDiffs;
+  private static List<Map<String, Object>> eventRuleDiffs;
+  private static Map<String, Object> devModeValuesFromServer;
+  private static Map<String, Object> devModeFileAttributesFromServer;
+  private static Map<String, Object> devModeActionDefinitionsFromServer;
+  private static List<Map<String, Object>> variants = new ArrayList<>();
+  private static CacheUpdateBlock updateBlock;
+  private static CacheUpdateBlock interfaceUpdateBlock;
+  private static CacheUpdateBlock eventsUpdateBlock;
+  private static boolean hasReceivedDiffs = false;
+  private static Map<String, Object> messages = new HashMap<>();
+  private static Object merged;
+  private static boolean silent;
+  private static int contentVersion;
+  private static Map<String, Object> userAttributes;
+
+  private static final String NAME_COMPONENT_REGEX = "(?:[^\\.\\[.(\\\\]+|\\\\.)+";
+  private static final Pattern NAME_COMPONENT_PATTERN = Pattern.compile(NAME_COMPONENT_REGEX);
+
+  public static String[] getNameComponents(String name) {
+    Matcher matcher = NAME_COMPONENT_PATTERN.matcher(name);
+    List<String> components = new ArrayList<>();
+    while (matcher.find()) {
+      components.add(name.substring(matcher.start(), matcher.end()));
+    }
+    return components.toArray(new String[0]);
+  }
+
+  private static Object traverse(Object collection, Object key, boolean autoInsert) {
+    if (collection == null) {
+      return null;
+    }
+    if (collection instanceof Map) {
+      Map<Object, Object> castedCollection = CollectionUtil.uncheckedCast(collection);
+      Object result = castedCollection.get(key);
+      if (autoInsert && result == null && key instanceof String) {
+        result = new HashMap<String, Object>();
+        castedCollection.put(key, result);
+      }
+      return result;
+    } else if (collection instanceof List) {
+      List<Object> castedList = CollectionUtil.uncheckedCast(collection);
+      Object result = castedList.get((Integer) key);
+      if (autoInsert && result == null) {
+        result = new HashMap<String, Object>();
+        castedList.set((Integer) key, result);
+      }
+      return result;
+    }
+    return null;
+  }
+
+  public static boolean registerFile(
+      String stringValue, String defaultValue,
+      InputStream defaultStream, boolean isResource, String resourceHash, int resourceSize) {
+    if (Constants.isDevelopmentModeEnabled) {
+      if (!Constants.isNoop()) {
+        if (defaultStream == null) {
+          return false;
+        }
+        Map<String, Object> variationAttributes = new HashMap<>();
+        Map<String, Object> attributes = new HashMap<>();
+        if (isResource) {
+          attributes.put(Constants.Keys.HASH, resourceHash);
+          attributes.put(Constants.Keys.SIZE, resourceSize);
+        } else {
+          if (Constants.hashFilesToDetermineModifications && Util.isSimulator()) {
+            HashResults result = FileManager.fileMD5HashCreateWithPath(defaultStream);
+            if (result != null) {
+              attributes.put(Constants.Keys.HASH, result.hash);
+              attributes.put(Constants.Keys.SIZE, result.size);
+            }
+          } else {
+            int size = FileManager.getFileSize(
+                FileManager.fileValue(stringValue, defaultValue, null));
+            attributes.put(Constants.Keys.SIZE, size);
+          }
+        }
+        variationAttributes.put("", attributes);
+        fileAttributes.put(stringValue, variationAttributes);
+        fileStreams.put(stringValue, defaultStream);
+        maybeUploadNewFiles();
+      }
+      return true;
+    }
+    return false;
+  }
+
+  private static void updateValues(String name, String[] nameComponents, Object value, String kind,
+      Map<String, Object> values, Map<String, String> kinds) {
+    Object valuesPtr = values;
+    if (nameComponents != null && nameComponents.length > 0) {
+      for (int i = 0; i < nameComponents.length - 1; i++) {
+        valuesPtr = traverse(valuesPtr, nameComponents[i], true);
+      }
+      if (valuesPtr instanceof Map) {
+        Map<String, Object> map = CollectionUtil.uncheckedCast(valuesPtr);
+        map.put(nameComponents[nameComponents.length - 1], value);
+      }
+    }
+    if (kinds != null) {
+      kinds.put(name, kind);
+    }
+  }
+
+  public static void registerVariable(Var<?> var) {
+    vars.put(var.name(), var);
+    synchronized (valuesFromClient) {
+      updateValues(
+          var.name(), var.nameComponents(), var.defaultValue(),
+          var.kind(), valuesFromClient, defaultKinds);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <T> Var<T> getVariable(String name) {
+    return (Var<T>) vars.get(name);
+  }
+
+  private static void computeMergedDictionary() {
+    synchronized (valuesFromClient) {
+      merged = mergeHelper(valuesFromClient, diffs);
+    }
+  }
+
+  public static Object mergeHelper(Object vars, Object diff) {
+    if (diff == null) {
+      return vars;
+    }
+    if (diff instanceof Number
+        || diff instanceof Boolean
+        || diff instanceof String
+        || diff instanceof Character
+        || vars instanceof Number
+        || vars instanceof Boolean
+        || vars instanceof String
+        || vars instanceof Character) {
+      return diff;
+    }
+
+    Iterable<?> diffKeys = (diff instanceof Map) ? ((Map<?, ?>) diff).keySet() : (Iterable<?>) diff;
+    Iterable<?> varsKeys = (vars instanceof Map) ? ((Map<?, ?>) vars).keySet() : (Iterable<?>) vars;
+    Map<?, ?> diffMap = (diff instanceof Map) ? ((Map<?, ?>) diff) : null;
+    Map<?, ?> varsMap = (vars instanceof Map) ? ((Map<?, ?>) vars) : null;
+
+    // Infer that the diffs is an array if the vars value doesn't exist to tell us the type.
+    boolean isArray = false;
+    if (vars == null) {
+      if (diff instanceof Map && ((Map<?, ?>) diff).size() > 0) {
+        isArray = true;
+        for (Object var : diffKeys) {
+          if (!(var instanceof String)) {
+            isArray = false;
+            break;
+          }
+          String str = ((String) var);
+          if (str.length() < 3 || str.charAt(0) != '[' || str.charAt(str.length() - 1) != ']') {
+            isArray = false;
+            break;
+          }
+          String varSubscript = str.substring(1, str.length() - 1);
+          if (!("" + Integer.getInteger(varSubscript)).equals(varSubscript)) {
+            isArray = false;
+            break;
+          }
+        }
+      }
+    }
+
+    // Merge arrays.
+    if (vars instanceof List || isArray) {
+      ArrayList<Object> merged = new ArrayList<>();
+      for (Object var : varsKeys) {
+        merged.add(var);
+      }
+      for (Object varSubscript : diffKeys) {
+        String strSubscript = (String) varSubscript;
+        int subscript = Integer.parseInt(strSubscript.substring(1, strSubscript.length() - 1));
+        Object var = diffMap != null ? diffMap.get(strSubscript) : null;
+        while (subscript >= merged.size()) {
+          merged.add(null);
+        }
+        merged.set(subscript, mergeHelper(merged.get(subscript), var));
+      }
+      return merged;
+    }
+
+    // Merge dictionaries.
+    if (vars instanceof Map || diff instanceof Map) {
+      HashMap<Object, Object> merged = new HashMap<>();
+      if (varsKeys != null) {
+        for (Object var : varsKeys) {
+          if (diffMap != null && varsMap != null) {
+            Object diffVar = diffMap.get(var);
+            Object value = varsMap.get(var);
+            if (diffVar == null && value != null) {
+              merged.put(var, value);
+            }
+          }
+        }
+      }
+      for (Object var : diffKeys) {
+        Object diffsValue = diffMap != null ? diffMap.get(var) : null;
+        Object varsValue = varsMap != null ? varsMap.get(var) : null;
+        Object mergedValues = mergeHelper(varsValue, diffsValue);
+        merged.put(var, mergedValues);
+      }
+      return merged;
+    }
+    return null;
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <T> T getMergedValueFromComponentArray(Object[] components, Object values) {
+    Object mergedPtr = values;
+    for (Object component : components) {
+      mergedPtr = traverse(mergedPtr, component, false);
+    }
+    return (T) mergedPtr;
+  }
+
+  public static <T> T getMergedValueFromComponentArray(Object[] components) {
+    return getMergedValueFromComponentArray(components,
+        merged != null ? merged : valuesFromClient);
+  }
+
+  public static Map<String, Object> getDiffs() {
+    return diffs;
+  }
+
+  public static Map<String, Object> getMessageDiffs() {
+    return messageDiffs;
+  }
+
+  public static List<Map<String, Object>> getUpdateRuleDiffs() {
+    return updateRuleDiffs;
+  }
+
+  public static List<Map<String, Object>> getEventRuleDiffs() {
+    return eventRuleDiffs;
+  }
+
+  public static Map<String, Object> regions() {
+    return regions;
+  }
+
+  public static boolean hasReceivedDiffs() {
+    return hasReceivedDiffs;
+  }
+
+  public static void loadDiffs() {
+    if (Constants.isNoop()) {
+      return;
+    }
+    Context context = Leanplum.getContext();
+    SharedPreferences defaults = context.getSharedPreferences(LEANPLUM, Context.MODE_PRIVATE);
+    if (Request.token() == null) {
+      applyVariableDiffs(
+          new HashMap<String, Object>(),
+          new HashMap<String, Object>(),
+          new ArrayList<Map<String, Object>>(),
+          new ArrayList<Map<String, Object>>(),
+          new HashMap<String, Object>(),
+          new ArrayList<Map<String, Object>>());
+      return;
+    }
+    try {
+      // Crypt functions return input text if there was a problem.
+      AESCrypt aesContext = new AESCrypt(Request.appId(), Request.token());
+      String variables = aesContext.decodePreference(
+          defaults, Constants.Defaults.VARIABLES_KEY, "{}");
+      String messages = aesContext.decodePreference(
+          defaults, Constants.Defaults.MESSAGES_KEY, "{}");
+      String updateRules = aesContext.decodePreference(
+          defaults, Constants.Defaults.UPDATE_RULES_KEY, "[]");
+      String eventRules = aesContext.decodePreference(
+          defaults, Constants.Defaults.EVENT_RULES_KEY, "[]");
+      String regions = aesContext.decodePreference(defaults, Constants.Defaults.REGIONS_KEY, "{}");
+      String variants = aesContext.decodePreference(defaults, Constants.Keys.VARIANTS, "[]");
+      applyVariableDiffs(
+          JsonConverter.fromJson(variables),
+          JsonConverter.fromJson(messages),
+          JsonConverter.<Map<String, Object>>listFromJson(new JSONArray(updateRules)),
+          JsonConverter.<Map<String, Object>>listFromJson(new JSONArray(eventRules)),
+          JsonConverter.fromJson(regions),
+          JsonConverter.<Map<String, Object>>listFromJson(new JSONArray(variants)));
+      String deviceId = aesContext.decodePreference(defaults, Constants.Params.DEVICE_ID, null);
+      if (deviceId != null) {
+        if (Util.isValidDeviceId(deviceId)) {
+          Request.setDeviceId(deviceId);
+        } else {
+          Log.w("Invalid stored device id found: \"" + deviceId + "\"; discarding.");
+        }
+      }
+      String userId = aesContext.decodePreference(defaults, Constants.Params.USER_ID, null);
+      if (userId != null) {
+        if (Util.isValidUserId(userId)) {
+          Request.setUserId(userId);
+        } else {
+          Log.w("Invalid stored user id found: \"" + userId + "\"; discarding.");
+        }
+      }
+      String loggingEnabled = aesContext.decodePreference(defaults, Constants.Keys.LOGGING_ENABLED,
+          "false");
+      if (Boolean.parseBoolean(loggingEnabled)) {
+        Constants.loggingEnabled = true;
+      }
+    } catch (Exception e) {
+      Log.e("Could not load variable diffs.\n" + Log.getStackTraceString(e));
+    }
+    userAttributes();
+  }
+
+  public static void saveDiffs() {
+    if (Constants.isNoop()) {
+      return;
+    }
+    if (Request.token() == null) {
+      return;
+    }
+    Context context = Leanplum.getContext();
+    SharedPreferences defaults = context.getSharedPreferences(LEANPLUM, Context.MODE_PRIVATE);
+    SharedPreferences.Editor editor = defaults.edit();
+
+    // Crypt functions return input text if there was a problem.
+    AESCrypt aesContext = new AESCrypt(Request.appId(), Request.token());
+    String variablesCipher = aesContext.encrypt(JsonConverter.toJson(diffs));
+    editor.putString(Constants.Defaults.VARIABLES_KEY, variablesCipher);
+
+    String messagesCipher = aesContext.encrypt(JsonConverter.toJson(messages));
+    editor.putString(Constants.Defaults.MESSAGES_KEY, messagesCipher);
+
+    try {
+      String updateRulesCipher = aesContext.encrypt(
+          JsonConverter.listToJsonArray(updateRuleDiffs).toString());
+      editor.putString(Constants.Defaults.UPDATE_RULES_KEY, updateRulesCipher);
+    } catch (JSONException e) {
+      Log.e("Error converting updateRuleDiffs to JSON", e);
+    }
+
+    try {
+      String eventRulesCipher = aesContext.encrypt(
+          JsonConverter.listToJsonArray(eventRuleDiffs).toString());
+      editor.putString(Constants.Defaults.EVENT_RULES_KEY, eventRulesCipher);
+    } catch (JSONException e) {
+      Log.e("Error converting eventRuleDiffs to JSON", e);
+    }
+
+    String regionsCipher = aesContext.encrypt(JsonConverter.toJson(regions));
+    editor.putString(Constants.Defaults.REGIONS_KEY, regionsCipher);
+
+    try {
+      String variantsJson = JsonConverter.listToJsonArray(variants).toString();
+      editor.putString(Constants.Keys.VARIANTS, aesContext.encrypt(variantsJson));
+    } catch (JSONException e1) {
+      Log.e("Error converting " + variants + " to JSON.\n" + Log.getStackTraceString(e1));
+    }
+    editor.putString(Constants.Params.DEVICE_ID, aesContext.encrypt(Request.deviceId()));
+    editor.putString(Constants.Params.USER_ID, aesContext.encrypt(Request.userId()));
+    editor.putString(Constants.Keys.LOGGING_ENABLED,
+        aesContext.encrypt(String.valueOf(Constants.loggingEnabled)));
+    try {
+      editor.apply();
+    } catch (NoSuchMethodError e) {
+      editor.commit();
+    }
+  }
+
+  /**
+   * Convert a resId to a resPath.
+   */
+  static int getResIdFromPath(String resPath) {
+    int resId = 0;
+    try {
+      String path = resPath.replace("res/", "");
+      path = path.substring(0, path.lastIndexOf('.'));  // remove file extension
+      String name = path.substring(path.lastIndexOf('/') + 1);
+      String type = path.substring(0, path.lastIndexOf('/'));
+      type = type.replace('/', '.');
+      Context context = Leanplum.getContext();
+      resId = context.getResources().getIdentifier(name, type, context.getPackageName());
+    } catch (Exception e) {
+      // Fall back to 0 on any exception
+    }
+    return resId;
+  }
+
+  /**
+   * Update file variables stream info with override info, so that override files don't require
+   * downloads if they're already available.
+   */
+  private static void fileVariableFinish() {
+    for (String name : new HashMap<>(vars).keySet()) {
+      Var<?> var = vars.get(name);
+      String overrideFile = var.stringValue;
+      if (var.isResource && (var.kind().equals(Constants.Kinds.FILE)) && overrideFile != null &&
+          !var.defaultValue().equals(overrideFile)) {
+        Map<String, Object> variationAttributes = CollectionUtil.uncheckedCast(fileAttributes.get
+            (overrideFile));
+        InputStream stream = fileStreams.get(overrideFile);
+        if (variationAttributes != null && stream != null) {
+          var.setOverrideResId(getResIdFromPath(var.stringValue()));
+        }
+      }
+    }
+  }
+
+  public static void applyVariableDiffs(
+      Map<String, Object> diffs,
+      Map<String, Object> messages,
+      List<Map<String, Object>> updateRules,
+      List<Map<String, Object>> eventRules,
+      Map<String, Object> regions,
+      List<Map<String, Object>> variants) {
+    if (diffs != null) {
+      VarCache.diffs = diffs;
+      computeMergedDictionary();
+
+      // Update variables with new values.
+      // Have to copy the dictionary because a dictionary variable may add a new sub-variable,
+      // modifying the variable dictionary.
+      for (String name : new HashMap<>(vars).keySet()) {
+        vars.get(name).update();
+      }
+      fileVariableFinish();
+    }
+
+    if (messages != null) {
+      // Store messages.
+      messageDiffs = messages;
+      Map<String, Object> newMessages = new HashMap<>();
+      for (Map.Entry<String, Object> entry : messages.entrySet()) {
+        Map<String, Object> messageConfig = CollectionUtil.uncheckedCast(entry.getValue());
+        Map<String, Object> newConfig = new HashMap<>(messageConfig);
+        Map<String, Object> actionArgs = CollectionUtil.uncheckedCast(messageConfig.get(Constants
+            .Keys.VARS));
+        Map<String, Object> defaultArgs = Util.multiIndex(actionDefinitions,
+            newConfig.get(Constants.Params.ACTION), "values");
+        Map<String, Object> vars = CollectionUtil.uncheckedCast(mergeHelper(defaultArgs,
+            actionArgs));
+        newMessages.put(entry.getKey(), newConfig);
+        newConfig.put(Constants.Keys.VARS, vars);
+      }
+
+      VarCache.messages = newMessages;
+      for (Map.Entry<String, Object> entry : VarCache.messages.entrySet()) {
+        String name = entry.getKey();
+        Map<String, Object> messageConfig = CollectionUtil.uncheckedCast(VarCache.messages.get
+            (name));
+        if (messageConfig.get("action") != null) {
+          Map<String, Object> actionArgs =
+              CollectionUtil.uncheckedCast(messageConfig.get(Constants.Keys.VARS));
+          new ActionContext(
+              messageConfig.get("action").toString(), actionArgs, name).update();
+        }
+      }
+    }
+
+    if (regions != null) {
+      VarCache.regions = regions;
+    }
+
+    if (messages != null || regions != null) {
+      Set<String> foregroundRegionNames = new HashSet<>();
+      Set<String> backgroundRegionNames = new HashSet<>();
+      ActionManager.getForegroundandBackgroundRegionNames(foregroundRegionNames,
+          backgroundRegionNames);
+      LocationManager locationManager = ActionManager.getLocationManager();
+      if (locationManager != null) {
+        locationManager.setRegionsData(regions, foregroundRegionNames, backgroundRegionNames);
+      }
+    }
+
+    boolean interfaceUpdated = false;
+    if (updateRules != null) {
+      interfaceUpdated = !(updateRules.equals(updateRuleDiffs));
+      updateRuleDiffs = new ArrayList<>(updateRules);
+      VarCache.downloadUpdateRulesImages();
+    }
+
+    boolean eventsUpdated = false;
+    if (eventRules != null) {
+      eventsUpdated = !(eventRules.equals(eventRuleDiffs));
+      eventRuleDiffs = new ArrayList<>(eventRules);
+    }
+
+    if (variants != null) {
+      VarCache.variants = variants;
+    }
+
+    contentVersion++;
+
+    if (!silent) {
+      saveDiffs();
+      triggerHasReceivedDiffs();
+
+      if (interfaceUpdated && interfaceUpdateBlock != null) {
+        interfaceUpdateBlock.updateCache();
+      }
+
+      if (eventsUpdated && eventsUpdateBlock != null) {
+        eventsUpdateBlock.updateCache();
+      }
+    }
+  }
+
+  static void applyUpdateRuleDiffs(List<Map<String, Object>> updateRuleDiffs) {
+    VarCache.updateRuleDiffs = updateRuleDiffs;
+    VarCache.downloadUpdateRulesImages();
+    if (interfaceUpdateBlock != null) {
+      interfaceUpdateBlock.updateCache();
+    }
+    VarCache.saveDiffs();
+  }
+
+  private static void downloadUpdateRulesImages() {
+    for (Map value : VarCache.updateRuleDiffs) {
+      List changes = (List) value.get("changes");
+      for (Object change : changes) {
+        Map<String, String> castedChange = CollectionUtil.uncheckedCast(change);
+        String key = castedChange.get("key");
+        if (key != null && key.contains("image")) {
+          String name = castedChange.get("value");
+          FileManager.maybeDownloadFile(true, name, null, null, null);
+        }
+      }
+    }
+  }
+
+  public static int contentVersion() {
+    return contentVersion;
+  }
+
+  @SuppressWarnings("SameParameterValue")
+  private static boolean areActionDefinitionsEqual(
+      Map<String, Object> a, Map<String, Object> b) {
+    if ((a == null || b == null) || (a.size() != b.size())) {
+      return false;
+    }
+    for (Map.Entry<String, Object> entry : a.entrySet()) {
+      Map<String, Object> aItem = CollectionUtil.uncheckedCast(entry.getValue());
+      Map<String, Object> bItem = CollectionUtil.uncheckedCast(b.get(entry.getKey()));
+      if (bItem == null || aItem == null) {
+        return false;
+      }
+
+      Object aKind = aItem.get("kind");
+      Object aValues = aItem.get("values");
+      Object aKinds = aItem.get("kinds");
+      Object aOptions = aItem.get("options");
+      if (aKind != null && !aKind.equals(bItem.get("kind")) ||
+          aValues != null && !aValues.equals(bItem.get("values")) ||
+          aKinds != null && !aKinds.equals(bItem.get("kinds")) ||
+          (aOptions == null) != (bItem.get("options") == null) ||
+          aOptions != null && aOptions.equals(bItem.get("options"))) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  private static void triggerHasReceivedDiffs() {
+    hasReceivedDiffs = true;
+    if (updateBlock != null) {
+      updateBlock.updateCache();
+    }
+  }
+
+  static boolean sendVariablesIfChanged() {
+    return sendContentIfChanged(true, false);
+  }
+
+  static boolean sendActionsIfChanged() {
+    return sendContentIfChanged(false, true);
+  }
+
+  private static boolean sendContentIfChanged(boolean variables, boolean actions) {
+    boolean changed = false;
+    if (variables && devModeValuesFromServer != null
+        && !valuesFromClient.equals(devModeValuesFromServer)) {
+      changed = true;
+    }
+    if (actions && !areActionDefinitionsEqual(
+        actionDefinitions, devModeActionDefinitionsFromServer)) {
+      changed = true;
+    }
+
+    if (changed) {
+      HashMap<String, Object> params = new HashMap<>();
+      if (variables) {
+        params.put(Constants.Params.VARS, JsonConverter.toJson(valuesFromClient));
+        params.put(Constants.Params.KINDS, JsonConverter.toJson(defaultKinds));
+      }
+      if (actions) {
+        params.put(Constants.Params.ACTION_DEFINITIONS, JsonConverter.toJson(actionDefinitions));
+      }
+      params.put(Constants.Params.FILE_ATTRIBUTES, JsonConverter.toJson(fileAttributes));
+      Request.post(Constants.Methods.SET_VARS, params).sendIfConnected();
+    }
+
+    return changed;
+  }
+
+  static void maybeUploadNewFiles() {
+    // First check to make sure we have all the data we need
+    if (Constants.isNoop()
+        || devModeFileAttributesFromServer == null
+        || !Leanplum.hasStartedAndRegisteredAsDeveloper()
+        || !Constants.enableFileUploadingInDevelopmentMode) {
+      return;
+    }
+
+    List<String> filenames = new ArrayList<>();
+    List<JSONObject> fileData = new ArrayList<>();
+    List<InputStream> streams = new ArrayList<>();
+    int totalSize = 0;
+    for (Map.Entry<String, Object> entry : fileAttributes.entrySet()) {
+      String name = entry.getKey();
+      Map<String, Object> variationAttributes = CollectionUtil.uncheckedCast(entry.getValue());
+      Map<String, Object> serverVariationAttributes =
+          CollectionUtil.uncheckedCast(devModeFileAttributesFromServer.get(name));
+      Map<String, Object> localAttributes = CollectionUtil.uncheckedCast(variationAttributes.get
+          (""));
+      Map<String, Object> serverAttributes = CollectionUtil.uncheckedCast(
+          (serverVariationAttributes != null ? serverVariationAttributes.get("") : null));
+      if (FileManager.isNewerLocally(localAttributes, serverAttributes)) {
+        Log.v("Will upload file " + name + ". Local attributes: " +
+            localAttributes + "; server attributes: " + serverAttributes);
+
+        String hash = (String) localAttributes.get(Constants.Keys.HASH);
+        if (hash == null) {
+          hash = "";
+        }
+
+        String variationPath = FileManager.fileRelativeToAppBundle(name);
+
+        // Upload in batch if we can't put any more files in
+        if ((totalSize > Constants.Files.MAX_UPLOAD_BATCH_SIZES && filenames.size() > 0)
+            || filenames.size() >= Constants.Files.MAX_UPLOAD_BATCH_FILES) {
+          Map<String, Object> params = new HashMap<>();
+          params.put(Constants.Params.DATA, fileData.toString());
+
+          Request.post(Constants.Methods.UPLOAD_FILE, params).sendFilesNow(filenames,
+              streams);
+
+          filenames = new ArrayList<>();
+          fileData = new ArrayList<>();
+          streams = new ArrayList<>();
+          totalSize = 0;
+        }
+
+        // Add the current file to the lists and update size
+        Object size = localAttributes.get(Constants.Keys.SIZE);
+        totalSize += (Integer) size;
+        filenames.add(variationPath);
+        JSONObject fileDatum = new JSONObject();
+        try {
+          fileDatum.put(Constants.Keys.HASH, hash);
+          fileDatum.put(Constants.Keys.SIZE, localAttributes.get(Constants.Keys.SIZE) + "");
+          fileDatum.put(Constants.Keys.FILENAME, name);
+          fileData.add(fileDatum);
+        } catch (JSONException e) {
+          // HASH, SIZE, or FILENAME are null, which they never should be (they're constants).
+          Log.e("Unable to upload files.\n" + Log.getStackTraceString(e));
+          fileData.add(new JSONObject());
+        }
+        streams.add(fileStreams.get(name));
+      }
+    }
+
+    if (filenames.size() > 0) {
+      Map<String, Object> params = new HashMap<>();
+      params.put(Constants.Params.DATA, fileData.toString());
+      Request.post(Constants.Methods.UPLOAD_FILE, params).sendFilesNow(filenames, streams);
+    }
+  }
+
+  /**
+   * Sets whether values should be saved and callbacks triggered when the variable values get
+   * updated.
+   */
+  public static void setSilent(boolean silent) {
+    VarCache.silent = silent;
+  }
+
+  public static boolean silent() {
+    return silent;
+  }
+
+  public static void setDevModeValuesFromServer(Map<String, Object> values,
+      Map<String, Object> fileAttributes, Map<String, Object> actionDefinitions) {
+    devModeValuesFromServer = values;
+    devModeActionDefinitionsFromServer = actionDefinitions;
+    devModeFileAttributesFromServer = fileAttributes;
+  }
+
+  public static void onUpdate(CacheUpdateBlock block) {
+    updateBlock = block;
+  }
+
+  public static void onInterfaceUpdate(CacheUpdateBlock block) {
+    interfaceUpdateBlock = block;
+  }
+
+  public static void onEventsUpdate(CacheUpdateBlock block) {
+    eventsUpdateBlock = block;
+  }
+
+  public static List<Map<String, Object>> variants() {
+    return variants;
+  }
+
+  public static Map<String, Object> actionDefinitions() {
+    return actionDefinitions;
+  }
+
+  public static Map<String, Object> messages() {
+    return messages;
+  }
+
+  public static void registerActionDefinition(
+      String name, int kind, List<ActionArg<?>> args,
+      Map<String, Object> options) {
+    Map<String, Object> values = new HashMap<>();
+    Map<String, String> kinds = new HashMap<>();
+    List<String> order = new ArrayList<>();
+    for (ActionArg<?> arg : args) {
+      updateValues(arg.name(), getNameComponents(arg.name()),
+          arg.defaultValue(), arg.kind(), values, kinds);
+      order.add(arg.name());
+    }
+    Map<String, Object> definition = new HashMap<>();
+    definition.put("kind", kind);
+    definition.put("values", values);
+    definition.put("kinds", kinds);
+    definition.put("order", order);
+    definition.put("options", options);
+    actionDefinitions.put(name, definition);
+  }
+
+  public static <T> String kindFromValue(T defaultValue) {
+    String kind = null;
+    if (defaultValue instanceof Integer
+        || defaultValue instanceof Long
+        || defaultValue instanceof Short
+        || defaultValue instanceof Character
+        || defaultValue instanceof Byte
+        || defaultValue instanceof BigInteger) {
+      kind = Constants.Kinds.INT;
+    } else if (defaultValue instanceof Float
+        || defaultValue instanceof Double
+        || defaultValue instanceof BigDecimal) {
+      kind = Constants.Kinds.FLOAT;
+    } else if (defaultValue instanceof String) {
+      kind = Constants.Kinds.STRING;
+    } else if (defaultValue instanceof List
+        || defaultValue instanceof Array) {
+      kind = Constants.Kinds.ARRAY;
+    } else if (defaultValue instanceof Map) {
+      kind = Constants.Kinds.DICTIONARY;
+    } else if (defaultValue instanceof Boolean) {
+      kind = Constants.Kinds.BOOLEAN;
+    }
+    return kind;
+  }
+
+  static Map<String, Object> userAttributes() {
+    if (userAttributes == null) {
+      Context context = Leanplum.getContext();
+      SharedPreferences defaults = context.getSharedPreferences(LEANPLUM, Context.MODE_PRIVATE);
+      AESCrypt aesContext = new AESCrypt(Request.appId(), Request.token());
+      try {
+        userAttributes = JsonConverter.fromJson(
+            aesContext.decodePreference(defaults, Constants.Defaults.ATTRIBUTES_KEY, "{}"));
+      } catch (Exception e) {
+        Log.e("Could not load user attributes.\n" + Log.getStackTraceString(e));
+        userAttributes = new HashMap<>();
+      }
+    }
+    return userAttributes;
+  }
+
+  public static void saveUserAttributes() {
+    if (Constants.isNoop() || Request.appId() == null || userAttributes == null) {
+      return;
+    }
+    Context context = Leanplum.getContext();
+    SharedPreferences defaults = context.getSharedPreferences(LEANPLUM, Context.MODE_PRIVATE);
+    SharedPreferences.Editor editor = defaults.edit();
+    // Crypt functions return input text if there was a problem.
+    String plaintext = JsonConverter.toJson(userAttributes);
+    AESCrypt aesContext = new AESCrypt(Request.appId(), Request.token());
+    editor.putString(Constants.Defaults.ATTRIBUTES_KEY, aesContext.encrypt(plaintext));
+    try {
+      editor.apply();
+    } catch (NoSuchMethodError e) {
+      editor.commit();
+    }
+  }
+
+  /**
+   * Resets the VarCache to stock state.
+   */
+  public static void reset() {
+    vars.clear();
+    fileAttributes.clear();
+    fileStreams.clear();
+    valuesFromClient.clear();
+    defaultKinds.clear();
+    actionDefinitions.clear();
+    diffs.clear();
+    messageDiffs.clear();
+    regions.clear();
+    devModeValuesFromServer = null;
+    devModeFileAttributesFromServer = null;
+    devModeActionDefinitionsFromServer = null;
+    variants.clear();
+    updateBlock = null;
+    hasReceivedDiffs = false;
+    messages = null;
+    merged = null;
+    silent = false;
+    contentVersion = 0;
+    userAttributes = null;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/internal/WebSocketClient.java
@@ -0,0 +1,290 @@
+// Copyright (c) 2009-2012 James Coglan
+// Copyright (c) 2012 Eric Butler 
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of
+// this software and associated documentation files (the 'Software'), to deal in
+// the Software without restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+// Software, and to permit persons to whom the Software is furnished to do so,
+// subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+//
+// Source: https://github.com/codebutler/android-websockets/blob/master/src/main/java/com/codebutler/android_websockets/WebSocketClient.java
+
+package com.leanplum.internal;
+
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.text.TextUtils;
+import android.util.Base64;
+
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.GeneralSecurityException;
+import java.security.KeyManagementException;
+import java.security.NoSuchAlgorithmException;
+import java.util.List;
+
+import javax.net.SocketFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLException;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.TrustManager;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.HttpException;
+import ch.boye.httpclientandroidlib.HttpStatus;
+import ch.boye.httpclientandroidlib.NameValuePair;
+import ch.boye.httpclientandroidlib.StatusLine;
+import ch.boye.httpclientandroidlib.client.HttpResponseException;
+import ch.boye.httpclientandroidlib.message.BasicLineParser;
+import ch.boye.httpclientandroidlib.message.BasicNameValuePair;
+
+class WebSocketClient {
+  private static final String TAG = "WebSocketClient";
+
+  private URI mURI;
+  private Listener mListener;
+  private java.net.Socket mSocket;
+  private Thread mThread;
+  private HandlerThread mHandlerThread;
+  private Handler mHandler;
+  private List<BasicNameValuePair> mExtraHeaders;
+  private HybiParser mParser;
+
+  private final Object mSendLock = new Object();
+
+  private static TrustManager[] sTrustManagers;
+
+  public static void setTrustManagers(TrustManager[] tm) {
+    sTrustManagers = tm;
+  }
+
+  public WebSocketClient(URI uri, Listener listener, List<BasicNameValuePair> extraHeaders) {
+    mURI = uri;
+    mListener = listener;
+    mExtraHeaders = extraHeaders;
+    mParser = new HybiParser(this);
+
+    mHandlerThread = new HandlerThread("websocket-thread");
+    mHandlerThread.start();
+    mHandler = new Handler(mHandlerThread.getLooper());
+  }
+
+  public Listener getListener() {
+    return mListener;
+  }
+
+  public void connect() {
+    if (mThread != null && mThread.isAlive()) {
+      return;
+    }
+
+    mThread = new Thread(new Runnable() {
+      @Override
+      public void run() {
+        try {
+          int port = (mURI.getPort() != -1) ? mURI.getPort() : (mURI.getScheme().equals("wss") ? 443 : 80);
+
+          String path = TextUtils.isEmpty(mURI.getPath()) ? "/" : mURI.getPath();
+          if (!TextUtils.isEmpty(mURI.getQuery())) {
+            path += "?" + mURI.getQuery();
+          }
+
+          String originScheme = mURI.getScheme().equals("wss") ? "https" : "http";
+          URI origin = null;
+          try {
+            origin = new URI(originScheme, "//" + mURI.getHost(), null);
+          } catch (URISyntaxException e) {
+            Util.handleException(e);
+          }
+
+          SocketFactory factory;
+          try {
+            factory = mURI.getScheme().equals("wss") ? getSSLSocketFactory() : SocketFactory.getDefault();
+          } catch (GeneralSecurityException e) {
+            Util.handleException(e);
+            return;
+          }
+          try {
+            mSocket = factory.createSocket(mURI.getHost(), port);
+          } catch (IOException e) {
+            Util.handleException(e);
+          }
+
+          PrintWriter out = new PrintWriter(mSocket.getOutputStream());
+          out.print("GET " + path + " HTTP/1.1\r\n");
+          out.print("Upgrade: websocket\r\n");
+          out.print("Connection: Upgrade\r\n");
+          out.print("Host: " + mURI.getHost() + "\r\n");
+          out.print("Origin: " + (origin != null ? origin.toString() : "unknown") + "\r\n");
+          out.print("Sec-WebSocket-Key: " + createSecret() + "\r\n");
+          out.print("Sec-WebSocket-Version: 13\r\n");
+          if (mExtraHeaders != null) {
+            for (NameValuePair pair : mExtraHeaders) {
+              out.print(String.format("%s: %s\r\n", pair.getName(), pair.getValue()));
+            }
+          }
+          out.print("\r\n");
+          out.flush();
+
+          HybiParser.HappyDataInputStream stream = new HybiParser.HappyDataInputStream(mSocket.getInputStream());
+
+          // Read HTTP response status line.
+
+          StatusLine statusLine = parseStatusLine(readLine(stream));
+
+          if (statusLine == null) {
+            throw new HttpException("Received no reply from server.");
+          } else if (statusLine.getStatusCode() != HttpStatus.SC_SWITCHING_PROTOCOLS) {
+            throw new HttpResponseException(statusLine.getStatusCode(), statusLine.getReasonPhrase());
+          }
+
+          // Read HTTP response headers.
+          String line;
+          while (!TextUtils.isEmpty(line = readLine(stream))) {
+            Header header = parseHeader(line);
+            if (header.getName().equals("Sec-WebSocket-Accept")) {
+              // FIXME: Verify the response...
+            }
+          }
+
+          mListener.onConnect();
+
+          // Now decode websocket frames.
+          mParser.start(stream);
+
+        } catch (EOFException ex) {
+          Log.d("WebSocket EOF!", ex);
+          mListener.onDisconnect(0, "EOF");
+
+        } catch (SSLException ex) {
+          // Connection reset by peer
+          Log.d("Websocket SSL error!", ex);
+          mListener.onDisconnect(0, "SSL");
+
+        } catch (Exception e) {
+          mListener.onError(e);
+        }
+      }
+    });
+    mThread.start();
+  }
+
+  public void disconnect() {
+    if (mSocket != null) {
+      mHandler.post(new Runnable() {
+        @Override
+        public void run() {
+          try {
+            if (mSocket != null) {
+              mSocket.close();
+              mSocket = null;
+            }
+          } catch (IOException e) {
+            Log.d("Error while disconnecting", e);
+            mListener.onError(e);
+          }
+        }
+      });
+    }
+  }
+
+  public void send(String data) {
+    sendFrame(mParser.frame(data));
+  }
+
+  public void send(byte[] data) {
+    sendFrame(mParser.frame(data));
+  }
+
+  private StatusLine parseStatusLine(String line) {
+    if (TextUtils.isEmpty(line)) {
+      return null;
+    }
+    return BasicLineParser.parseStatusLine(line, new BasicLineParser());
+  }
+
+  private Header parseHeader(String line) {
+    return BasicLineParser.parseHeader(line, new BasicLineParser());
+  }
+
+  // Can't use BufferedReader because it buffers past the HTTP data.
+  private String readLine(HybiParser.HappyDataInputStream reader) throws IOException {
+    int readChar = reader.read();
+    if (readChar == -1) {
+      return null;
+    }
+    StringBuilder string = new StringBuilder("");
+    while (readChar != '\n') {
+      if (readChar != '\r') {
+        string.append((char) readChar);
+      }
+
+      readChar = reader.read();
+      if (readChar == -1) {
+        return null;
+      }
+    }
+    return string.toString();
+  }
+
+  private String createSecret() {
+    byte[] nonce = new byte[16];
+    for (int i = 0; i < 16; i++) {
+      nonce[i] = (byte) (Math.random() * 256);
+    }
+    return Base64.encodeToString(nonce, Base64.DEFAULT).trim();
+  }
+
+  void sendFrame(final byte[] frame) {
+    mHandler.post(new Runnable() {
+      @Override
+      public void run() {
+        try {
+          synchronized (mSendLock) {
+            if (mSocket != null) {
+              OutputStream outputStream = mSocket.getOutputStream();
+              outputStream.write(frame);
+              outputStream.flush();
+            }
+          }
+        } catch (IOException e) {
+          mListener.onError(e);
+        }
+      }
+    });
+  }
+
+  interface Listener {
+    void onConnect();
+
+    void onMessage(String message);
+
+    void onMessage(byte[] data);
+
+    void onDisconnect(int code, String reason);
+
+    void onError(Exception error);
+  }
+
+  private SSLSocketFactory getSSLSocketFactory() throws NoSuchAlgorithmException, KeyManagementException {
+    SSLContext context = SSLContext.getInstance("TLS");
+    context.init(null, sTrustManagers, null);
+    return context.getSocketFactory();
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/Alert.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+
+import com.leanplum.ActionArgs;
+import com.leanplum.ActionContext;
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+import com.leanplum.callbacks.ActionCallback;
+import com.leanplum.callbacks.PostponableAction;
+import com.leanplum.messagetemplates.MessageTemplates.Args;
+import com.leanplum.messagetemplates.MessageTemplates.Values;
+
+import static com.leanplum.messagetemplates.MessageTemplates.getApplicationName;
+
+/**
+ * Registers a Leanplum action that displays a system alert dialog.
+ *
+ * @author Andrew First
+ */
+public class Alert {
+  private static final String NAME = "Alert";
+
+  public static void register(Context currentContext) {
+    Leanplum.defineAction(
+        NAME,
+        Leanplum.ACTION_KIND_MESSAGE | Leanplum.ACTION_KIND_ACTION,
+        new ActionArgs().with(Args.TITLE, getApplicationName(currentContext))
+            .with(Args.MESSAGE, Values.ALERT_MESSAGE)
+            .with(Args.DISMISS_TEXT, Values.OK_TEXT)
+            .withAction(Args.DISMISS_ACTION, null), new ActionCallback() {
+
+          @Override
+          public boolean onResponse(final ActionContext context) {
+            LeanplumActivityHelper.queueActionUponActive(new PostponableAction() {
+              @Override
+              public void run() {
+                Activity activity = LeanplumActivityHelper.getCurrentActivity();
+                if (activity == null) {
+                  return;
+                }
+                AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(
+                    activity);
+                alertDialogBuilder
+                    .setTitle(context.stringNamed(Args.TITLE))
+                    .setMessage(context.stringNamed(Args.MESSAGE))
+                    .setCancelable(false)
+                    .setPositiveButton(context.stringNamed(Args.DISMISS_TEXT),
+                        new DialogInterface.OnClickListener() {
+                          public void onClick(DialogInterface dialog, int id) {
+                            context.runActionNamed(Args.DISMISS_ACTION);
+                          }
+                        });
+                AlertDialog alertDialog = alertDialogBuilder.create();
+                if (!activity.isFinishing()) {
+                  alertDialog.show();
+                }
+              }
+            });
+            return true;
+          }
+        });
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/BaseMessageDialog.java
@@ -0,0 +1,615 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.Point;
+import android.graphics.Typeface;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.RoundRectShape;
+import android.graphics.drawable.shapes.Shape;
+import android.os.Build;
+import android.os.Handler;
+import android.text.TextUtils;
+import android.util.TypedValue;
+import android.view.Display;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+import android.view.Window;
+import android.view.WindowManager;
+import android.view.animation.AccelerateInterpolator;
+import android.view.animation.AlphaAnimation;
+import android.view.animation.Animation;
+import android.view.animation.DecelerateInterpolator;
+import android.webkit.WebChromeClient;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.leanplum.ActionContext;
+import com.leanplum.Leanplum;
+import com.leanplum.utils.BitmapUtil;
+import com.leanplum.utils.SizeUtil;
+import com.leanplum.views.BackgroundImageView;
+import com.leanplum.views.CloseButton;
+
+import org.json.JSONObject;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.util.Map;
+
+/**
+ * Base dialog used to display the Center Popup, Interstitial, Web Interstitial, HTML template.
+ *
+ * @author Martin Yanakiev, Anna Orlova
+ */
+public class BaseMessageDialog extends Dialog {
+  protected RelativeLayout dialogView;
+  protected BaseMessageOptions options;
+  protected WebInterstitialOptions webOptions;
+  protected HTMLOptions htmlOptions;
+  protected Activity activity;
+  protected WebView webView;
+
+  private boolean isWeb = false;
+  private boolean isHtml = false;
+  private boolean isClosing = false;
+
+  protected BaseMessageDialog(Activity activity, boolean fullscreen, BaseMessageOptions options,
+      WebInterstitialOptions webOptions, HTMLOptions htmlOptions) {
+    super(activity, getTheme(activity));
+
+    SizeUtil.init(activity);
+    this.activity = activity;
+    this.options = options;
+    this.webOptions = webOptions;
+    this.htmlOptions = htmlOptions;
+    if (webOptions != null) {
+      isWeb = true;
+    }
+    if (htmlOptions != null) {
+      isHtml = true;
+    }
+    dialogView = new RelativeLayout(activity);
+    RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(
+        LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+    dialogView.setBackgroundColor(Color.TRANSPARENT);
+    dialogView.setLayoutParams(layoutParams);
+
+    RelativeLayout view = createContainerView(activity, fullscreen);
+    view.setId(108);
+    dialogView.addView(view, view.getLayoutParams());
+
+    if ((!isWeb || (webOptions != null && webOptions.hasDismissButton())) && !isHtml) {
+      CloseButton closeButton = createCloseButton(activity, fullscreen, view);
+      dialogView.addView(closeButton, closeButton.getLayoutParams());
+    }
+
+    setContentView(dialogView, dialogView.getLayoutParams());
+
+    dialogView.setAnimation(createFadeInAnimation());
+
+    if (!fullscreen) {
+      Window window = getWindow();
+      if (window == null) {
+        return;
+      }
+      if (!isHtml) {
+        window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
+        if (Build.VERSION.SDK_INT >= 14) {
+          window.setDimAmount(0.7f);
+        }
+      } else {
+        window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
+        window.setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
+            WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL);
+        if (htmlOptions != null &&
+            MessageTemplates.Args.HTML_ALIGN_BOTTOM.equals(htmlOptions.getHtmlAlign())) {
+          dialogView.setGravity(Gravity.BOTTOM);
+        }
+      }
+    }
+  }
+
+  @Override
+  public void onWindowFocusChanged(boolean hasFocus) {
+    try {
+      if (webView != null && Build.VERSION.SDK_INT >= 11) {
+        if (hasFocus) {
+          webView.onResume();
+        } else {
+          webView.onPause();
+        }
+      }
+    } catch (Throwable ignore) {
+    }
+    super.onWindowFocusChanged(hasFocus);
+  }
+
+  private Animation createFadeInAnimation() {
+    Animation fadeIn = new AlphaAnimation(0, 1);
+    fadeIn.setInterpolator(new DecelerateInterpolator());
+    fadeIn.setDuration(350);
+    return fadeIn;
+  }
+
+  private Animation createFadeOutAnimation() {
+    Animation fadeOut = new AlphaAnimation(1, 0);
+    fadeOut.setInterpolator(new AccelerateInterpolator());
+    fadeOut.setDuration(350);
+    return fadeOut;
+  }
+
+  @Override
+  public void cancel() {
+    if (isClosing) {
+      return;
+    }
+    isClosing = true;
+    Animation animation = createFadeOutAnimation();
+    animation.setAnimationListener(new Animation.AnimationListener() {
+      @Override
+      public void onAnimationStart(Animation animation) {
+      }
+
+      @Override
+      public void onAnimationRepeat(Animation animation) {
+      }
+
+      @Override
+      public void onAnimationEnd(Animation animation) {
+        BaseMessageDialog.super.cancel();
+        Handler handler = new Handler();
+        handler.postDelayed(new Runnable() {
+          @Override
+          public void run() {
+            if (isHtml && webView != null) {
+              webView.stopLoading();
+              webView.loadUrl("");
+              if (dialogView != null) {
+                dialogView.removeAllViews();
+              }
+              webView.removeAllViews();
+              webView.destroy();
+            }
+          }
+        }, 10);
+      }
+    });
+    dialogView.startAnimation(animation);
+  }
+
+  private CloseButton createCloseButton(Activity context, boolean fullscreen, View parent) {
+    CloseButton closeButton = new CloseButton(context);
+    closeButton.setId(103);
+    RelativeLayout.LayoutParams closeLayout = new RelativeLayout.LayoutParams(
+        LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
+    if (fullscreen) {
+      closeLayout.addRule(RelativeLayout.ALIGN_PARENT_TOP, dialogView.getId());
+      closeLayout.addRule(RelativeLayout.ALIGN_PARENT_RIGHT, dialogView.getId());
+      closeLayout.setMargins(0, SizeUtil.dp5, SizeUtil.dp5, 0);
+    } else {
+      closeLayout.addRule(RelativeLayout.ALIGN_TOP, parent.getId());
+      closeLayout.addRule(RelativeLayout.ALIGN_RIGHT, parent.getId());
+      closeLayout.setMargins(0, -SizeUtil.dp7, -SizeUtil.dp7, 0);
+    }
+    closeButton.setLayoutParams(closeLayout);
+    closeButton.setOnClickListener(new View.OnClickListener() {
+      @Override
+      public void onClick(View arg0) {
+        cancel();
+      }
+    });
+    return closeButton;
+  }
+
+  @SuppressWarnings("deprecation")
+  private RelativeLayout createContainerView(Activity context, boolean fullscreen) {
+    RelativeLayout view = new RelativeLayout(context);
+
+    // Positions the dialog.
+    RelativeLayout.LayoutParams layoutParams;
+    if (fullscreen) {
+      layoutParams = new RelativeLayout.LayoutParams(
+          LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+    } else if (isHtml) {
+      int height = SizeUtil.dpToPx(context, htmlOptions.getHtmlHeight());
+      layoutParams = new RelativeLayout.LayoutParams(
+          LayoutParams.MATCH_PARENT, height);
+    } else {
+
+      // Make sure the dialog fits on screen.
+      Display display = context.getWindowManager().getDefaultDisplay();
+      Point size = new Point();
+      if (Build.VERSION.SDK_INT >= 13) {
+        display.getSize(size);
+      } else {
+        size = new Point(display.getHeight(), display.getHeight());
+      }
+
+      int width = SizeUtil.dpToPx(context, ((CenterPopupOptions) options).getWidth());
+      int height = SizeUtil.dpToPx(context, ((CenterPopupOptions) options).getHeight());
+
+      int maxWidth = size.x - SizeUtil.dp20;
+      int maxHeight = size.y - SizeUtil.dp20;
+      double aspectRatio = width / (double) height;
+      if (width > maxWidth && (int) (width / aspectRatio) < maxHeight) {
+        width = maxWidth;
+        height = (int) (width / aspectRatio);
+      }
+      if (height > maxHeight && (int) (height * aspectRatio) < maxWidth) {
+        height = maxHeight;
+        width = (int) (height * aspectRatio);
+      }
+
+      layoutParams = new RelativeLayout.LayoutParams(width, height);
+      layoutParams.addRule(RelativeLayout.CENTER_IN_PARENT, RelativeLayout.TRUE);
+    }
+
+    view.setLayoutParams(layoutParams);
+
+    ShapeDrawable footerBackground = new ShapeDrawable();
+    footerBackground.setShape(createRoundRect(fullscreen ? 0 : SizeUtil.dp20));
+    footerBackground.getPaint().setColor(0x00000000);
+    if (Build.VERSION.SDK_INT >= 16) {
+      view.setBackground(footerBackground);
+    } else {
+      view.setBackgroundDrawable(footerBackground);
+    }
+
+    if (!isWeb && !isHtml) {
+      ImageView image = createBackgroundImageView(context, fullscreen);
+      view.addView(image, image.getLayoutParams());
+
+      View title = createTitleView(context);
+      title.setId(104);
+      view.addView(title, title.getLayoutParams());
+
+      View button = createAcceptButton(context);
+      button.setId(105);
+      view.addView(button, button.getLayoutParams());
+
+      View message = createMessageView(context);
+      ((RelativeLayout.LayoutParams) message.getLayoutParams())
+          .addRule(RelativeLayout.BELOW, title.getId());
+      ((RelativeLayout.LayoutParams) message.getLayoutParams())
+          .addRule(RelativeLayout.ABOVE, button.getId());
+      view.addView(message, message.getLayoutParams());
+    } else if (isWeb) {
+      WebView webView = createWebView(context);
+      view.addView(webView, webView.getLayoutParams());
+    } else {
+      webView = createHtml(context);
+      view.addView(webView, webView.getLayoutParams());
+    }
+
+    return view;
+  }
+
+  private Shape createRoundRect(int cornerRadius) {
+    int c = cornerRadius;
+    float[] outerRadii = new float[] {c, c, c, c, c, c, c, c};
+    return new RoundRectShape(outerRadii, null, null);
+  }
+
+  @SuppressWarnings("deprecation")
+  private ImageView createBackgroundImageView(Context context, boolean fullscreen) {
+    BackgroundImageView view = new BackgroundImageView(context, fullscreen);
+    view.setScaleType(ImageView.ScaleType.CENTER_CROP);
+    int cornerRadius;
+    if (!fullscreen) {
+      cornerRadius = SizeUtil.dp20;
+    } else {
+      cornerRadius = 0;
+    }
+    view.setImageBitmap(options.getBackgroundImage());
+    ShapeDrawable footerBackground = new ShapeDrawable();
+    footerBackground.setShape(createRoundRect(cornerRadius));
+    footerBackground.getPaint().setColor(options.getBackgroundColor());
+    if (Build.VERSION.SDK_INT >= 16) {
+      view.setBackground(footerBackground);
+    } else {
+      view.setBackgroundDrawable(footerBackground);
+    }
+    RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(
+        LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+    view.setLayoutParams(layoutParams);
+    return view;
+  }
+
+  private RelativeLayout createTitleView(Context context) {
+    RelativeLayout view = new RelativeLayout(context);
+    view.setLayoutParams(new LayoutParams(
+        LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
+
+    TextView title = new TextView(context);
+    title.setPadding(0, SizeUtil.dp5, 0, SizeUtil.dp5);
+    title.setGravity(Gravity.CENTER);
+    title.setText(options.getTitle());
+    title.setTextColor(options.getTitleColor());
+    title.setTextSize(TypedValue.COMPLEX_UNIT_SP, SizeUtil.textSize0);
+    title.setTypeface(null, Typeface.BOLD);
+    RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(
+        LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
+    layoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL, RelativeLayout.TRUE);
+    layoutParams.addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE);
+    title.setLayoutParams(layoutParams);
+
+    view.addView(title, title.getLayoutParams());
+    return view;
+  }
+
+  private TextView createMessageView(Context context) {
+    TextView view = new TextView(context);
+    RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(
+        LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+    view.setLayoutParams(layoutParams);
+    view.setGravity(Gravity.CENTER);
+    view.setText(options.getMessageText());
+    view.setTextColor(options.getMessageColor());
+    view.setTextSize(TypedValue.COMPLEX_UNIT_SP, SizeUtil.textSize0_1);
+    return view;
+  }
+
+  private WebView createWebView(Context context) {
+    WebView view = new WebView(context);
+    RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(
+        LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+    view.setLayoutParams(layoutParams);
+    view.setWebViewClient(new WebViewClient() {
+      @SuppressWarnings("deprecation")
+      @Override
+      public boolean shouldOverrideUrlLoading(WebView wView, String url) {
+        if (url.contains(webOptions.getCloseUrl())) {
+          cancel();
+          String[] urlComponents = url.split("\\?");
+          if (urlComponents.length > 1) {
+            String queryString = urlComponents[1];
+            String[] parameters = queryString.split("&");
+            for (String parameter : parameters) {
+              String[] parameterComponents = parameter.split("=");
+              if (parameterComponents.length > 1 && parameterComponents[0].equals("result")) {
+                Leanplum.track(parameterComponents[1]);
+              }
+            }
+          }
+          return true;
+        }
+        return false;
+      }
+    });
+    view.loadUrl(webOptions.getUrl());
+    return view;
+  }
+
+  /**
+   * Create WebView with HTML template.
+   *
+   * @param context Current context.
+   * @return WebVew WebVew with HTML template.
+   */
+  @SuppressLint("SetJavaScriptEnabled")
+  private WebView createHtml(Context context) {
+    dialogView.setVisibility(View.GONE);
+    final WebView webView = new WebView(context);
+    webView.setBackgroundColor(Color.TRANSPARENT);
+    webView.setVerticalScrollBarEnabled(false);
+    webView.setHorizontalScrollBarEnabled(false);
+    webView.setOnTouchListener(new View.OnTouchListener() {
+      public boolean onTouch(View v, MotionEvent event) {
+        return (event.getAction() == MotionEvent.ACTION_MOVE);
+      }
+    });
+    webView.canGoBack();
+    // Disable long click.
+    webView.setLongClickable(false);
+    webView.setHapticFeedbackEnabled(false);
+    webView.setOnLongClickListener(new View.OnLongClickListener() {
+      @Override
+      public boolean onLongClick(View v) {
+        return true;
+      }
+    });
+
+    WebSettings webViewSettings = webView.getSettings();
+    if (Build.VERSION.SDK_INT >= 17) {
+      webViewSettings.setMediaPlaybackRequiresUserGesture(false);
+    }
+    webViewSettings.setAppCacheEnabled(true);
+    webViewSettings.getSaveFormData();
+    webViewSettings.setAllowFileAccess(true);
+    webViewSettings.setJavaScriptEnabled(true);
+    webViewSettings.setDomStorageEnabled(true);
+    webViewSettings.setJavaScriptCanOpenWindowsAutomatically(true);
+    webViewSettings.setLoadWithOverviewMode(true);
+    webViewSettings.setLoadsImagesAutomatically(true);
+
+    if (Build.VERSION.SDK_INT >= 16) {
+      webViewSettings.setAllowFileAccessFromFileURLs(true);
+      webViewSettings.setAllowUniversalAccessFromFileURLs(true);
+    }
+    if (Build.VERSION.SDK_INT >= 11) {
+      webViewSettings.setBuiltInZoomControls(false);
+      webViewSettings.setDisplayZoomControls(false);
+    }
+    webViewSettings.setSupportZoom(false);
+
+    RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(
+        LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+    webView.setLayoutParams(layoutParams);
+    final Dialog currentDialog = this;
+    webView.setWebChromeClient(new WebChromeClient());
+    webView.setWebViewClient(new WebViewClient() {
+      @SuppressWarnings("deprecation")
+      @Override
+      public boolean shouldOverrideUrlLoading(WebView wView, String url) {
+        // Open URL event.
+        if (url.contains(htmlOptions.getOpenUrl())) {
+          dialogView.setVisibility(View.VISIBLE);
+          if (activity != null && !activity.isFinishing()) {
+            currentDialog.show();
+          }
+          return true;
+        }
+
+        // Close URL event.
+        if (url.contains(htmlOptions.getCloseUrl())) {
+          cancel();
+          String queryComponentsFromUrl = queryComponentsFromUrl(url, "result");
+          if (!TextUtils.isEmpty(queryComponentsFromUrl)) {
+            Leanplum.track(queryComponentsFromUrl);
+          }
+          return true;
+        }
+
+        // Track URL event.
+        if (url.contains(htmlOptions.getTrackUrl())) {
+          String eventName = queryComponentsFromUrl(url, "event");
+          if (!TextUtils.isEmpty(eventName)) {
+            Double value = Double.parseDouble(queryComponentsFromUrl(url, "value"));
+            String info = queryComponentsFromUrl(url, "info");
+            Map<String, Object> paramsMap = null;
+
+            try {
+              paramsMap = ActionContext.mapFromJson(new JSONObject(queryComponentsFromUrl(url,
+                  "parameters")));
+            } catch (Exception ignored) {
+            }
+
+            if (queryComponentsFromUrl(url, "isMessageEvent").equals("true")) {
+              ActionContext actionContext = htmlOptions.getActionContext();
+              actionContext.trackMessageEvent(eventName, value, info, paramsMap);
+            } else {
+              Leanplum.track(eventName, value, info, paramsMap);
+            }
+          }
+          return true;
+        }
+
+        // Action URL or track action URL event.
+        if (url.contains(htmlOptions.getActionUrl()) ||
+            url.contains(htmlOptions.getTrackActionUrl())) {
+          cancel();
+          String queryComponentsFromUrl = queryComponentsFromUrl(url, "action");
+          try {
+            queryComponentsFromUrl = URLDecoder.decode(queryComponentsFromUrl, "UTF-8");
+          } catch (UnsupportedEncodingException ignored) {
+          }
+
+          ActionContext actionContext = htmlOptions.getActionContext();
+          if (!TextUtils.isEmpty(queryComponentsFromUrl) && actionContext != null) {
+            if (url.contains(htmlOptions.getActionUrl())) {
+              actionContext.runActionNamed(queryComponentsFromUrl);
+            } else {
+              actionContext.runTrackedActionNamed(queryComponentsFromUrl);
+            }
+          }
+          return true;
+        }
+
+        return false;
+      }
+    });
+    String html = htmlOptions.getHtmlTemplate();
+
+    webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null);
+
+    return webView;
+  }
+
+  /**
+   * Get query components from URL.
+   *
+   * @param url URL string.
+   * @param components Name of components.
+   * @return String String with query components.
+   */
+  private String queryComponentsFromUrl(String url, String components) {
+    String componentsFromUrl = "";
+    String[] urlComponents = url.split("\\?");
+    if (urlComponents.length > 1) {
+      String queryString = urlComponents[1];
+      String[] parameters = queryString.split("&");
+      for (String parameter : parameters) {
+        String[] parameterComponents = parameter.split("=");
+        if (parameterComponents.length > 1 && parameterComponents[0].equals(components)) {
+          componentsFromUrl = parameterComponents[1];
+        }
+      }
+    }
+    try {
+      componentsFromUrl = URLDecoder.decode(componentsFromUrl, "UTF-8");
+    } catch (Exception ignored) {
+    }
+    return componentsFromUrl;
+  }
+
+  private TextView createAcceptButton(Context context) {
+    TextView view = new TextView(context);
+    RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(
+        LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
+    layoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM, RelativeLayout.TRUE);
+    layoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL, RelativeLayout.TRUE);
+    layoutParams.setMargins(0, 0, 0, SizeUtil.dp5);
+
+    view.setPadding(SizeUtil.dp20, SizeUtil.dp5, SizeUtil.dp20, SizeUtil.dp5);
+    view.setLayoutParams(layoutParams);
+    view.setText(options.getAcceptButtonText());
+    view.setTextColor(options.getAcceptButtonTextColor());
+    view.setTypeface(null, Typeface.BOLD);
+
+    BitmapUtil.stateBackgroundDarkerByPercentage(view,
+        options.getAcceptButtonBackgroundColor(), 30);
+
+    view.setTextSize(TypedValue.COMPLEX_UNIT_SP, SizeUtil.textSize0_1);
+    view.setOnClickListener(new View.OnClickListener() {
+      @Override
+      public void onClick(View arg0) {
+        if (!isClosing) {
+          options.accept();
+          cancel();
+        }
+      }
+    });
+    return view;
+  }
+
+  private static int getTheme(Activity activity) {
+    boolean full = (activity.getWindow().getAttributes().flags &
+        WindowManager.LayoutParams.FLAG_FULLSCREEN) == WindowManager.LayoutParams.FLAG_FULLSCREEN;
+    if (full) {
+      return android.R.style.Theme_Translucent_NoTitleBar_Fullscreen;
+    } else {
+      return android.R.style.Theme_Translucent_NoTitleBar;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/BaseMessageOptions.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Color;
+import android.util.Log;
+
+import com.leanplum.ActionArgs;
+import com.leanplum.ActionContext;
+import com.leanplum.messagetemplates.MessageTemplates.Args;
+import com.leanplum.utils.BitmapUtil;
+
+import java.io.InputStream;
+
+/**
+ * Options used by Center Popup and Interstitial.
+ *
+ * @author Martin Yanakiev
+ */
+abstract class BaseMessageOptions {
+  private ActionContext context;
+  private String title;
+  private int titleColor;
+  private String messageText;
+  private int messageColor;
+  private Bitmap backgroundImage;
+  private int backgroundColor;
+  private String acceptButtonText;
+  private int acceptButtonBackgroundColor;
+  private int acceptButtonTextColor;
+
+  protected BaseMessageOptions(ActionContext context) {
+    this.context = context;
+    setTitle(context.stringNamed(Args.TITLE_TEXT));
+    setTitleColor(context.numberNamed(Args.TITLE_COLOR).intValue());
+    setMessageText(context.stringNamed(Args.MESSAGE_TEXT));
+    setMessageColor(context.numberNamed(Args.MESSAGE_COLOR).intValue());
+    InputStream imageStream = context.streamNamed(Args.BACKGROUND_IMAGE);
+    if (imageStream != null) {
+      try {
+        setBackgroundImage(BitmapFactory.decodeStream(imageStream));
+      } catch (Exception e) {
+        Log.e("Leanplum", "Error loading background image", e);
+      }
+    }
+    setBackgroundColor(context.numberNamed(Args.BACKGROUND_COLOR).intValue());
+    setAcceptButtonText(context.stringNamed(Args.ACCEPT_BUTTON_TEXT));
+    setAcceptButtonBackgroundColor(context.numberNamed(
+        Args.ACCEPT_BUTTON_BACKGROUND_COLOR).intValue());
+    setAcceptButtonTextColor(context.numberNamed(
+        Args.ACCEPT_BUTTON_TEXT_COLOR).intValue());
+  }
+
+  public int getBackgroundColor() {
+    return backgroundColor;
+  }
+
+  private void setBackgroundColor(int backgroundColor) {
+    this.backgroundColor = backgroundColor;
+  }
+
+  public String getAcceptButtonText() {
+    return acceptButtonText;
+  }
+
+  private void setAcceptButtonText(String acceptButtonText) {
+    this.acceptButtonText = acceptButtonText;
+  }
+
+  public String getTitle() {
+    return title;
+  }
+
+  private void setTitle(String title) {
+    this.title = title;
+  }
+
+  public int getTitleColor() {
+    return titleColor;
+  }
+
+  private void setTitleColor(int titleColor) {
+    this.titleColor = titleColor;
+  }
+
+  public String getMessageText() {
+    return messageText;
+  }
+
+  private void setMessageText(String messageText) {
+    this.messageText = messageText;
+  }
+
+  public int getMessageColor() {
+    return messageColor;
+  }
+
+  private void setMessageColor(int messageColor) {
+    this.messageColor = messageColor;
+  }
+
+  public Bitmap getBackgroundImage() {
+    return backgroundImage;
+  }
+
+  public Bitmap getBackgroundImageRounded(int pixels) {
+    return BitmapUtil.getRoundedCornerBitmap(backgroundImage, pixels);
+  }
+
+  private void setBackgroundImage(Bitmap backgroundImage) {
+    this.backgroundImage = backgroundImage;
+  }
+
+  public int getAcceptButtonBackgroundColor() {
+    return acceptButtonBackgroundColor;
+  }
+
+  private void setAcceptButtonBackgroundColor(int color) {
+    this.acceptButtonBackgroundColor = color;
+  }
+
+  public int getAcceptButtonTextColor() {
+    return acceptButtonTextColor;
+  }
+
+  private void setAcceptButtonTextColor(int color) {
+    this.acceptButtonTextColor = color;
+  }
+
+  public void accept() {
+    context.runTrackedActionNamed(Args.ACCEPT_ACTION);
+  }
+
+  public static ActionArgs toArgs(Context currentContext) {
+    return new ActionArgs()
+        .with(Args.TITLE_TEXT,
+            MessageTemplates.getApplicationName(currentContext))
+        .withColor(Args.TITLE_COLOR, Color.BLACK)
+        .with(Args.MESSAGE_TEXT, MessageTemplates.Values.POPUP_MESSAGE)
+        .withColor(Args.MESSAGE_COLOR, Color.BLACK)
+        .withFile(Args.BACKGROUND_IMAGE, null)
+        .withColor(Args.BACKGROUND_COLOR, Color.WHITE)
+        .with(Args.ACCEPT_BUTTON_TEXT, MessageTemplates.Values.OK_TEXT)
+        .withColor(Args.ACCEPT_BUTTON_BACKGROUND_COLOR, Color.WHITE)
+        .withColor(Args.ACCEPT_BUTTON_TEXT_COLOR, Color.argb(255, 0, 122, 255))
+        .withAction(Args.ACCEPT_ACTION, null);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/CenterPopup.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.app.Activity;
+import android.content.Context;
+
+import com.leanplum.ActionContext;
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+import com.leanplum.callbacks.ActionCallback;
+import com.leanplum.callbacks.PostponableAction;
+import com.leanplum.callbacks.VariablesChangedCallback;
+
+/**
+ * Registers a Leanplum action that displays a custom center popup dialog.
+ *
+ * @author Andrew First
+ */
+public class CenterPopup extends BaseMessageDialog {
+  private static final String NAME = "Center Popup";
+
+  public CenterPopup(Activity activity, CenterPopupOptions options) {
+    super(activity, false, options, null, null);
+    this.options = options;
+  }
+
+  public static void register(Context currentContext) {
+    Leanplum.defineAction(NAME, Leanplum.ACTION_KIND_MESSAGE | Leanplum.ACTION_KIND_ACTION,
+        CenterPopupOptions.toArgs(currentContext), new ActionCallback() {
+          @Override
+          public boolean onResponse(final ActionContext context) {
+            Leanplum.addOnceVariablesChangedAndNoDownloadsPendingHandler(
+                new VariablesChangedCallback() {
+                  @Override
+                  public void variablesChanged() {
+                    LeanplumActivityHelper.queueActionUponActive(new PostponableAction() {
+                      @Override
+                      public void run() {
+                        Activity activity = LeanplumActivityHelper.getCurrentActivity();
+                        if (activity == null) {
+                          return;
+                        }
+                        CenterPopup popup = new CenterPopup(activity,
+                            new CenterPopupOptions(context));
+                        if (!activity.isFinishing()) {
+                          popup.show();
+                        }
+                      }
+                    });
+                  }
+                });
+            return true;
+          }
+        });
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/CenterPopupOptions.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.content.Context;
+
+import com.leanplum.ActionArgs;
+import com.leanplum.ActionContext;
+import com.leanplum.messagetemplates.MessageTemplates.Args;
+
+/**
+ * Options used by {@link CenterPopup}.
+ *
+ * @author Martin Yanakiev
+ */
+public class CenterPopupOptions extends BaseMessageOptions {
+  private int width;
+  private int height;
+
+  public CenterPopupOptions(ActionContext context) {
+    super(context);
+    setWidth(context.numberNamed(Args.LAYOUT_WIDTH).intValue());
+    setHeight(context.numberNamed(Args.LAYOUT_HEIGHT).intValue());
+  }
+
+  public int getWidth() {
+    return width;
+  }
+
+  private void setWidth(int width) {
+    this.width = width;
+  }
+
+  public int getHeight() {
+    return height;
+  }
+
+  private void setHeight(int height) {
+    this.height = height;
+  }
+
+  public static ActionArgs toArgs(Context currentContext) {
+    return BaseMessageOptions.toArgs(currentContext)
+        .with(Args.LAYOUT_WIDTH, MessageTemplates.Values.CENTER_POPUP_WIDTH)
+        .with(Args.LAYOUT_HEIGHT, MessageTemplates.Values.CENTER_POPUP_HEIGHT);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/Confirm.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+
+import com.leanplum.ActionArgs;
+import com.leanplum.ActionContext;
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+import com.leanplum.callbacks.ActionCallback;
+import com.leanplum.callbacks.PostponableAction;
+import com.leanplum.messagetemplates.MessageTemplates.Args;
+import com.leanplum.messagetemplates.MessageTemplates.Values;
+
+import static com.leanplum.messagetemplates.MessageTemplates.getApplicationName;
+
+/**
+ * Registers a Leanplum action that displays a system confirm dialog.
+ *
+ * @author Andrew First
+ */
+class Confirm {
+  private static final String NAME = "Confirm";
+
+  public static void register(Context currentContext) {
+    Leanplum.defineAction(
+        NAME,
+        Leanplum.ACTION_KIND_MESSAGE | Leanplum.ACTION_KIND_ACTION,
+        new ActionArgs().with(Args.TITLE, getApplicationName(currentContext))
+            .with(Args.MESSAGE, Values.CONFIRM_MESSAGE)
+            .with(Args.ACCEPT_TEXT, Values.YES_TEXT)
+            .with(Args.CANCEL_TEXT, Values.NO_TEXT)
+            .withAction(Args.ACCEPT_ACTION, null)
+            .withAction(Args.CANCEL_ACTION, null), new ActionCallback() {
+
+          @Override
+          public boolean onResponse(final ActionContext context) {
+            LeanplumActivityHelper.queueActionUponActive(new PostponableAction() {
+              @Override
+              public void run() {
+                Activity activity = LeanplumActivityHelper.getCurrentActivity();
+                AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(
+                    activity);
+                alertDialogBuilder
+                    .setTitle(context.stringNamed(Args.TITLE))
+                    .setMessage(context.stringNamed(Args.MESSAGE))
+                    .setCancelable(false)
+                    .setPositiveButton(context.stringNamed(Args.ACCEPT_TEXT),
+                        new DialogInterface.OnClickListener() {
+                          public void onClick(DialogInterface dialog, int id) {
+                            context.runTrackedActionNamed(Args.ACCEPT_ACTION);
+                          }
+                        })
+                    .setNegativeButton(context.stringNamed(Args.CANCEL_TEXT),
+                        new DialogInterface.OnClickListener() {
+                          public void onClick(DialogInterface dialog, int id) {
+                            context.runActionNamed(Args.CANCEL_ACTION);
+                          }
+                        });
+                AlertDialog alertDialog = alertDialogBuilder.create();
+                if (activity != null && !activity.isFinishing()) {
+                  alertDialog.show();
+                }
+              }
+            });
+            return true;
+          }
+        });
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/HTMLOptions.java
@@ -0,0 +1,270 @@
+/*
+ * Copyright 2017, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.leanplum.ActionArgs;
+import com.leanplum.ActionContext;
+import com.leanplum.Leanplum;
+
+import org.json.JSONException;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.Map;
+
+/**
+ * Options used by {@link HTMLTemplate}.
+ *
+ * @author Anna Orlova
+ */
+class HTMLOptions {
+  private String closeUrl;
+  private String openUrl;
+  private String trackUrl;
+  private String actionUrl;
+  private String trackActionUrl;
+  private String htmlTemplate;
+  private ActionContext actionContext;
+  private String htmlAlign;
+  private int htmlHeight;
+
+  HTMLOptions(ActionContext context) {
+    this.setActionContext(context);
+    this.setHtmlTemplate(getTemplate(context));
+    this.setCloseUrl(context.stringNamed(MessageTemplates.Args.CLOSE_URL));
+    this.setOpenUrl(context.stringNamed(MessageTemplates.Args.OPEN_URL));
+    this.setTrackUrl(context.stringNamed(MessageTemplates.Args.TRACK_URL));
+    this.setActionUrl(context.stringNamed(MessageTemplates.Args.ACTION_URL));
+    this.setTrackActionUrl(context.stringNamed(MessageTemplates.Args.TRACK_ACTION_URL));
+    this.setHtmlAlign(context.stringNamed(MessageTemplates.Args.HTML_ALIGN));
+    this.setHtmlHeight(context.numberNamed(MessageTemplates.Args.HTML_HEIGHT).intValue());
+  }
+
+  /**
+   * Read data from file as String.
+   *
+   * @param context ActionContext.
+   * @param name Name of file.
+   * @return String String with data of file.
+   */
+  @SuppressWarnings("SameParameterValue")
+  private static String readFileAsString(ActionContext context, String name) {
+    if (context == null || TextUtils.isEmpty(name)) {
+      return null;
+    }
+
+    String str;
+    InputStream inputStream = context.streamNamed(name);
+    StringBuilder buf = new StringBuilder();
+    BufferedReader reader = null;
+
+    try {
+      reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8"));
+      while ((str = reader.readLine()) != null) {
+        buf.append(str).append("\n");
+      }
+      reader.close();
+    } catch (IOException e) {
+      Log.e("Leanplum", "Fail to get HTML template.");
+    } finally {
+      try {
+        if (inputStream != null) {
+          inputStream.close();
+        }
+        if (reader != null) {
+          reader.close();
+        }
+      } catch (Exception e) {
+        Log.w("Leanplum", "Failed to close InputStream or BufferedReader: " + e);
+      }
+    }
+    return buf.toString();
+  }
+
+  /**
+   * Replace all keys with __file__ prefix to keys without __file__ prefix and replace value of
+   * those keys with local path to file.
+   *
+   * @param map Map with arguments from ActionContext.
+   * @param htmlTemplateName Name of file with HTML template.
+   * @return Map Map with updated arguments.
+   */
+  private static Map<String, Object> replaceFileToLocalPath(Map<String, Object> map,
+      String htmlTemplateName) {
+    if (map == null) {
+      return null;
+    }
+    String[] keyArray = map.keySet().toArray(new String[map.keySet().size()]);
+    for (String key : keyArray) {
+      if (map.get(key) instanceof Map) {
+        @SuppressWarnings("unchecked")
+        Map<String, Object> mapValue = (Map<String, Object>) map.get(key);
+        replaceFileToLocalPath(mapValue, htmlTemplateName);
+      } else if (key.contains(MessageTemplates.Values.FILE_PREFIX) &&
+          !key.equals(htmlTemplateName)) {
+        String filePath = ActionContext.filePath((String) map.get(key));
+        if (filePath == null) {
+          continue;
+        }
+        File f = new File(filePath);
+        String localPath = "file://" + f.getAbsolutePath();
+        if (localPath.contains(Leanplum.getContext().getPackageName())) {
+          map.put(key.replace(MessageTemplates.Values.FILE_PREFIX, ""),
+              localPath.replace(" ", "%20"));
+        }
+        map.remove(key);
+      }
+    }
+    return map;
+  }
+
+  /**
+   * Get HTML template file.
+   *
+   * @param context ActionContext.
+   * @return String String with data of HTML template file.
+   */
+  private static String getTemplate(ActionContext context) {
+    if (context == null) {
+      return null;
+    }
+
+    String htmlTemplate = readFileAsString(context, MessageTemplates.Values.HTML_TEMPLATE_PREFIX);
+    Map<String, Object> htmlArgs = replaceFileToLocalPath(context.getArgs(),
+        MessageTemplates.Values.HTML_TEMPLATE_PREFIX);
+    if (htmlArgs == null || TextUtils.isEmpty(htmlTemplate)) {
+      return null;
+    }
+
+    htmlArgs.put("messageId", context.getMessageId());
+    if (context.getContextualValues() != null && context.getContextualValues().arguments != null) {
+      htmlArgs.put("displayEvent", context.getContextualValues().arguments);
+    }
+
+    String htmlString = "";
+    try {
+      htmlString = (htmlTemplate.replace("##Vars##",
+          ActionContext.mapToJsonObject(htmlArgs).toString()));
+    } catch (JSONException e) {
+      Log.e("Leanplum", "Cannot convert map of arguments to JSON object.");
+    }
+    return htmlString.replace("\\/", "/");
+  }
+
+  /**
+   * @return boolean True if it's full screen template.
+   */
+  boolean isFullScreen() {
+    return htmlHeight == 0;
+  }
+
+  int getHtmlHeight() {
+    return htmlHeight;
+  }
+
+  private void setHtmlHeight(int htmlHeight) {
+    this.htmlHeight = htmlHeight;
+  }
+
+  String getHtmlAlign() {
+    return htmlAlign;
+  }
+
+  private void setHtmlAlign(String htmlAlign) {
+    this.htmlAlign = htmlAlign;
+  }
+
+  ActionContext getActionContext() {
+    return actionContext;
+  }
+
+  private void setActionContext(ActionContext actionContext) {
+    //noinspection AccessStaticViaInstance
+    this.actionContext = actionContext;
+  }
+
+  String getHtmlTemplate() {
+    return htmlTemplate;
+  }
+
+  private void setHtmlTemplate(String htmlTemplate) {
+    this.htmlTemplate = htmlTemplate;
+  }
+
+  String getTrackActionUrl() {
+    return trackActionUrl;
+  }
+
+  private void setTrackActionUrl(String trackActionUrl) {
+    this.trackActionUrl = trackActionUrl;
+  }
+
+  String getTrackUrl() {
+    return trackUrl;
+  }
+
+  private void setTrackUrl(String trackUrl) {
+    this.trackUrl = trackUrl;
+  }
+
+  String getOpenUrl() {
+    return openUrl;
+  }
+
+  private void setOpenUrl(String openUrl) {
+    this.openUrl = openUrl;
+  }
+
+  String getActionUrl() {
+    return actionUrl;
+  }
+
+  private void setActionUrl(String actionUrl) {
+    this.actionUrl = actionUrl;
+  }
+
+  String getCloseUrl() {
+    return closeUrl;
+  }
+
+  private void setCloseUrl(String closeUrl) {
+    this.closeUrl = closeUrl;
+  }
+
+  public static ActionArgs toArgs() {
+    return new ActionArgs()
+        .with(MessageTemplates.Args.CLOSE_URL, MessageTemplates.Values.DEFAULT_CLOSE_URL)
+        .with(MessageTemplates.Args.OPEN_URL, MessageTemplates.Values.DEFAULT_OPEN_URL)
+        .with(MessageTemplates.Args.ACTION_URL, MessageTemplates.Values.DEFAULT_ACTION_URL)
+        .with(MessageTemplates.Args.TRACK_ACTION_URL,
+            MessageTemplates.Values.DEFAULT_TRACK_ACTION_URL)
+        .with(MessageTemplates.Args.TRACK_URL, MessageTemplates.Values.DEFAULT_TRACK_URL)
+        .with(MessageTemplates.Args.HTML_ALIGN, MessageTemplates.Values.DEFAULT_HTML_ALING)
+        .with(MessageTemplates.Args.HTML_HEIGHT, MessageTemplates.Values.DEFAULT_HTML_HEIGHT);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/HTMLTemplate.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2017, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.app.Activity;
+import android.support.annotation.NonNull;
+import android.util.Log;
+import android.view.MotionEvent;
+
+import com.leanplum.ActionContext;
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+import com.leanplum.callbacks.ActionCallback;
+import com.leanplum.callbacks.PostponableAction;
+import com.leanplum.callbacks.VariablesChangedCallback;
+
+/**
+ * Registers a Leanplum action that displays a HTML message.
+ *
+ * @author Anna Orlova
+ */
+@SuppressWarnings("WeakerAccess")
+public class HTMLTemplate extends BaseMessageDialog {
+  private static final String NAME = "HTML";
+
+  public HTMLTemplate(Activity activity, HTMLOptions htmlOptions) {
+    super(activity, htmlOptions.isFullScreen(), null, null, htmlOptions);
+    this.htmlOptions = htmlOptions;
+  }
+
+  @Override
+  public boolean dispatchTouchEvent(@NonNull MotionEvent ev) {
+    if (!htmlOptions.isFullScreen()) {
+      if (htmlOptions.getHtmlAlign().equals(MessageTemplates.Args.HTML_ALIGN_TOP) && ev.getY()
+          > htmlOptions.getHtmlHeight() ||
+          htmlOptions.getHtmlAlign().equals(MessageTemplates.Args.HTML_ALIGN_BOTTOM) && ev.getY()
+              < dialogView.getHeight() - htmlOptions.getHtmlHeight()) {
+        activity.dispatchTouchEvent(ev);
+      }
+    }
+    return super.dispatchTouchEvent(ev);
+  }
+
+  public static void register() {
+    Leanplum.defineAction(NAME, Leanplum.ACTION_KIND_MESSAGE | Leanplum.ACTION_KIND_ACTION,
+        HTMLOptions.toArgs(), new ActionCallback() {
+          @Override
+          public boolean onResponse(final ActionContext context) {
+            Leanplum.addOnceVariablesChangedAndNoDownloadsPendingHandler(
+                new VariablesChangedCallback() {
+                  @Override
+                  public void variablesChanged() {
+                    LeanplumActivityHelper.queueActionUponActive(
+                        new PostponableAction() {
+                          @Override
+                          public void run() {
+                            try {
+                              HTMLOptions htmlOptions = new HTMLOptions(context);
+                              if (htmlOptions.getHtmlTemplate() == null) {
+                                return;
+                              }
+                              final Activity activity = LeanplumActivityHelper.getCurrentActivity();
+                              if (activity != null && !activity.isFinishing()) {
+                                new HTMLTemplate(activity, htmlOptions);
+                              }
+                            } catch (Throwable t) {
+                              Log.e("Leanplum", "Fail on show HTML In-App message.", t);
+                            }
+                          }
+                        });
+                  }
+                });
+            return true;
+          }
+        });
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/Interstitial.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.app.Activity;
+import android.content.Context;
+
+import com.leanplum.ActionContext;
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+import com.leanplum.callbacks.ActionCallback;
+import com.leanplum.callbacks.PostponableAction;
+import com.leanplum.callbacks.VariablesChangedCallback;
+
+/**
+ * Registers a Leanplum action that displays a fullscreen interstitial.
+ *
+ * @author Andrew First
+ */
+public class Interstitial extends BaseMessageDialog {
+  private static final String NAME = "Interstitial";
+
+  public Interstitial(Activity activity, InterstitialOptions options) {
+    super(activity, true, options, null, null);
+    this.options = options;
+  }
+
+  public static void register(Context currentContext) {
+    Leanplum.defineAction(NAME, Leanplum.ACTION_KIND_MESSAGE | Leanplum.ACTION_KIND_ACTION,
+        InterstitialOptions.toArgs(currentContext),
+        new ActionCallback() {
+          @Override
+          public boolean onResponse(final ActionContext context) {
+            Leanplum.addOnceVariablesChangedAndNoDownloadsPendingHandler(
+                new VariablesChangedCallback() {
+                  @Override
+                  public void variablesChanged() {
+                    LeanplumActivityHelper.queueActionUponActive(
+                        new PostponableAction() {
+                          @Override
+                          public void run() {
+                            Activity activity = LeanplumActivityHelper.getCurrentActivity();
+                            if (activity == null) {
+                              return;
+                            }
+                            Interstitial interstitial = new Interstitial(activity,
+                                new InterstitialOptions(context));
+                            if (!activity.isFinishing()) {
+                              interstitial.show();
+                            }
+                          }
+                        });
+                  }
+                });
+            return true;
+          }
+        });
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/InterstitialOptions.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.content.Context;
+
+import com.leanplum.ActionArgs;
+import com.leanplum.ActionContext;
+
+/**
+ * Options used by {@link Interstitial}.
+ *
+ * @author Martin Yanakiev
+ */
+public class InterstitialOptions extends BaseMessageOptions {
+  public InterstitialOptions(ActionContext context) {
+    super(context);
+    // Set specific properties for interstitial popup.
+  }
+
+  public static ActionArgs toArgs(Context currentContext) {
+    return BaseMessageOptions.toArgs(currentContext)
+        .with(MessageTemplates.Args.MESSAGE_TEXT, MessageTemplates.Values.INTERSTITIAL_MESSAGE);
+    // Add specific args for interstitial popup.
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/MessageTemplates.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.content.Context;
+
+/**
+ * Registers all of the built-in message templates.
+ *
+ * @author Andrew First
+ */
+public class MessageTemplates {
+  static class Args {
+    // Open URL
+    static final String URL = "URL";
+
+    // Alert/confirm arguments.
+    static final String TITLE = "Title";
+    static final String MESSAGE = "Message";
+    static final String ACCEPT_TEXT = "Accept text";
+    static final String CANCEL_TEXT = "Cancel text";
+    static final String DISMISS_TEXT = "Dismiss text";
+    static final String ACCEPT_ACTION = "Accept action";
+    static final String CANCEL_ACTION = "Cancel action";
+    static final String DISMISS_ACTION = "Dismiss action";
+
+    // Center popup/interstitial arguments.
+    static final String TITLE_TEXT = "Title.Text";
+    static final String TITLE_COLOR = "Title.Color";
+    static final String MESSAGE_TEXT = "Message.Text";
+    static final String MESSAGE_COLOR = "Message.Color";
+    static final String ACCEPT_BUTTON_TEXT = "Accept button.Text";
+    static final String ACCEPT_BUTTON_BACKGROUND_COLOR = "Accept button.Background color";
+    static final String ACCEPT_BUTTON_TEXT_COLOR = "Accept button.Text color";
+    static final String BACKGROUND_IMAGE = "Background image";
+    static final String BACKGROUND_COLOR = "Background color";
+    static final String LAYOUT_WIDTH = "Layout.Width";
+    static final String LAYOUT_HEIGHT = "Layout.Height";
+    static final String HTML_HEIGHT = "HTML Height";
+    static final String HTML_ALIGN = "HTML Align";
+    static final String HTML_ALIGN_TOP = "Top";
+    static final String HTML_ALIGN_BOTTOM = "Bottom";
+
+    // Web interstitial arguments.
+    static final String CLOSE_URL = "Close URL";
+    static final String HAS_DISMISS_BUTTON = "Has dismiss button";
+
+    // HTML Template arguments.
+    static final String OPEN_URL = "Open URL";
+    static final String TRACK_URL = "Track URL";
+    static final String ACTION_URL = "Action URL";
+    static final String TRACK_ACTION_URL = "Track Action URL";
+  }
+
+  static class Values {
+    static final String ALERT_MESSAGE = "Alert message goes here.";
+    static final String CONFIRM_MESSAGE = "Confirmation message goes here.";
+    static final String POPUP_MESSAGE = "Popup message goes here.";
+    static final String INTERSTITIAL_MESSAGE = "Interstitial message goes here.";
+    static final String OK_TEXT = "OK";
+    static final String YES_TEXT = "Yes";
+    static final String NO_TEXT = "No";
+    static final int CENTER_POPUP_WIDTH = 300;
+    static final int CENTER_POPUP_HEIGHT = 250;
+    static final int DEFAULT_HTML_HEIGHT = 0;
+    static final String DEFAULT_HTML_ALING = Args.HTML_ALIGN_TOP;
+
+    // Open URL.
+    static final String DEFAULT_URL = "http://www.example.com";
+
+    // Web interstitial values.
+    static final String DEFAULT_CLOSE_URL = "http://leanplum:close";
+    static final boolean DEFAULT_HAS_DISMISS_BUTTON = true;
+
+    // HTML Template values.
+    public static final String FILE_PREFIX = "__file__";
+    public static final String HTML_TEMPLATE_PREFIX = "__file__Template";
+    static final String DEFAULT_OPEN_URL = "http://leanplum:loadFinished";
+    static final String DEFAULT_TRACK_URL = "http://leanplum:track";
+    static final String DEFAULT_ACTION_URL = "http://leanplum:runAction";
+    static final String DEFAULT_TRACK_ACTION_URL = "http://leanplum:runTrackedAction";
+
+  }
+
+  private static boolean registered = false;
+
+  static String getApplicationName(Context context) {
+    int stringId = context.getApplicationInfo().labelRes;
+    if (stringId == 0) {
+      return context.getApplicationInfo().loadLabel(context.getPackageManager()).toString();
+    }
+    return context.getString(stringId);
+  }
+
+  public synchronized static void register(Context currentContext) {
+    if (registered) {
+      return;
+    }
+    registered = true;
+    OpenURL.register();
+    Alert.register(currentContext);
+    Confirm.register(currentContext);
+    CenterPopup.register(currentContext);
+    Interstitial.register(currentContext);
+    WebInterstitial.register();
+    HTMLTemplate.register();
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/OpenURL.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.util.Log;
+
+import com.leanplum.ActionArgs;
+import com.leanplum.ActionContext;
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+import com.leanplum.callbacks.ActionCallback;
+import com.leanplum.callbacks.PostponableAction;
+import com.leanplum.messagetemplates.MessageTemplates.Args;
+import com.leanplum.messagetemplates.MessageTemplates.Values;
+
+import java.util.List;
+
+/**
+ * Registers a Leanplum action that opens a particular URL. If the URL cannot be handled by the
+ * system URL handler, you can add your own action responder using {@link Leanplum#onAction} that
+ * handles the URL how you want.
+ *
+ * @author Andrew First
+ */
+class OpenURL {
+  private static final String NAME = "Open URL";
+
+  public static void register() {
+    Leanplum.defineAction(NAME, Leanplum.ACTION_KIND_ACTION,
+        new ActionArgs().with(Args.URL, Values.DEFAULT_URL), new ActionCallback() {
+          @Override
+          public boolean onResponse(ActionContext context) {
+            String url = context.stringNamed(Args.URL);
+            final Intent uriIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+            // Calling startActivity() from outside of an Activity context requires the
+            // FLAG_ACTIVITY_NEW_TASK flag.
+            if (!(Leanplum.getContext() instanceof Activity)) {
+              uriIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+            }
+            try {
+              if (Leanplum.getContext() != null) {
+                LeanplumActivityHelper.queueActionUponActive(
+                    new PostponableAction() {
+                      @Override
+                      public void run() {
+                        Context context = Leanplum.getContext();
+                        if (context == null) {
+                          return;
+                        }
+                        List<ResolveInfo> resolveInfoList = context.getPackageManager().
+                            queryIntentActivities(uriIntent, 0);
+                        // If url can be handled by current app - set package name to intent, so url
+                        // will be open by current app. Skip chooser dialog.
+                        if (resolveInfoList != null && resolveInfoList.size() != 0) {
+                          for (ResolveInfo resolveInfo : resolveInfoList) {
+                            if (resolveInfo != null && resolveInfo.activityInfo != null &&
+                                resolveInfo.activityInfo.name != null) {
+                              if (resolveInfo.activityInfo.name.contains(
+                                  context.getPackageName())) {
+                                uriIntent.setPackage(resolveInfo.activityInfo.packageName);
+                              }
+                            }
+                          }
+                          try {
+                            // Even if we have valid destination, startActivity can crash if
+                            // activity we are trying to open is not exported in manifest.
+                            context.startActivity(uriIntent);
+                          } catch (ActivityNotFoundException e) {
+                            Log.e("Leanplum", "Activity you are trying to start doesn't exist or " +
+                                "isn't exported in manifest: " + e);
+                          }
+                        }
+                      }
+                    });
+                return true;
+              } else {
+                return false;
+              }
+            } catch (ActivityNotFoundException e) {
+              Log.e("Leanplum", "Unable to handle URL " + url);
+              return false;
+            }
+          }
+        });
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/WebInterstitial.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.app.Activity;
+import android.content.Context;
+
+import com.leanplum.ActionContext;
+import com.leanplum.Leanplum;
+import com.leanplum.LeanplumActivityHelper;
+import com.leanplum.callbacks.ActionCallback;
+import com.leanplum.callbacks.PostponableAction;
+
+/**
+ * Registers a Leanplum action that displays a fullscreen Web Interstitial.
+ *
+ * @author Atanas Dobrev
+ */
+public class WebInterstitial extends BaseMessageDialog {
+  private static final String NAME = "Web Interstitial";
+
+  public WebInterstitial(Activity activity, WebInterstitialOptions options) {
+    super(activity, true, null, options, null);
+    this.webOptions = options;
+  }
+
+  /**
+   * Deprecated: Use {@link WebInterstitial#register()}.
+   */
+  @Deprecated
+  @SuppressWarnings("unused")
+  public static void register(Context currentContext) {
+    register();
+  }
+
+  public static void register() {
+    Leanplum.defineAction(NAME, Leanplum.ACTION_KIND_MESSAGE | Leanplum.ACTION_KIND_ACTION,
+        WebInterstitialOptions.toArgs(), new ActionCallback() {
+          @Override
+          public boolean onResponse(final ActionContext context) {
+            LeanplumActivityHelper.queueActionUponActive(new PostponableAction() {
+              @Override
+              public void run() {
+                Activity activity = LeanplumActivityHelper.getCurrentActivity();
+                if (activity == null) {
+                  return;
+                }
+                WebInterstitial webInterstitial = new WebInterstitial(activity,
+                    new WebInterstitialOptions(context));
+                if (!activity.isFinishing()) {
+                  webInterstitial.show();
+                }
+              }
+            });
+            return true;
+          }
+        });
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/messagetemplates/WebInterstitialOptions.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.messagetemplates;
+
+import android.content.Context;
+
+import com.leanplum.ActionArgs;
+import com.leanplum.ActionContext;
+import com.leanplum.messagetemplates.MessageTemplates.Args;
+import com.leanplum.messagetemplates.MessageTemplates.Values;
+
+/**
+ * Options used by {@link WebInterstitial}.
+ *
+ * @author Atanas Dobrev
+ */
+@SuppressWarnings("WeakerAccess")
+public class WebInterstitialOptions {
+  private String url;
+  private String closeUrl;
+  private boolean hasDismissButton;
+
+  protected WebInterstitialOptions(ActionContext context) {
+    this.setUrl(context.stringNamed(Args.URL));
+    this.setHasDismissButton(context.booleanNamed(Args.HAS_DISMISS_BUTTON));
+    this.setCloseUrl(context.stringNamed(Args.CLOSE_URL));
+  }
+
+  public String getUrl() {
+    return url;
+  }
+
+  private void setUrl(String url) {
+    this.url = url;
+  }
+
+  public boolean hasDismissButton() {
+    return hasDismissButton;
+  }
+
+  private void setHasDismissButton(boolean hasDismissButton) {
+    this.hasDismissButton = hasDismissButton;
+  }
+
+  public String getCloseUrl() {
+    return closeUrl;
+  }
+
+  private void setCloseUrl(String closeUrl) {
+    this.closeUrl = closeUrl;
+  }
+
+  /**
+   * Deprecated: Use {@link WebInterstitialOptions#toArgs()}.
+   */
+  @Deprecated
+  @SuppressWarnings("unused")
+  public static ActionArgs toArgs(Context currentContext) {
+    return toArgs();
+  }
+
+  public static ActionArgs toArgs() {
+    return new ActionArgs()
+        .with(Args.URL, Values.DEFAULT_URL)
+        .with(Args.CLOSE_URL, Values.DEFAULT_CLOSE_URL)
+        .with(Args.HAS_DISMISS_BUTTON, Values.DEFAULT_HAS_DISMISS_BUTTON);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/utils/BitmapUtil.java
@@ -0,0 +1,241 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.utils;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.BitmapFactory;
+import android.graphics.PorterDuff.Mode;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.StateListDrawable;
+import android.graphics.drawable.shapes.RoundRectShape;
+import android.os.Build;
+import android.util.DisplayMetrics;
+import android.view.View;
+import android.view.WindowManager;
+
+import com.leanplum.internal.Log;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+
+/**
+ * Bitmap manipulation utilities.
+ *
+ * @author Martin Yanakiev, Anna Orlova
+ */
+public class BitmapUtil {
+  // Layout_height for imageView is 192dp
+  // https://android.googlesource.com/platform/frameworks/base
+  // /+/6387d2f6dae27ba6e8481883325adad96d3010f4/core/res/res/layout
+  // /notification_template_big_picture.xml
+  private static final int BIG_PICTURE_MAX_HEIGHT_DP = 192;
+
+  public static Bitmap getRoundedCornerBitmap(Bitmap bitmap, int pixels) {
+    if (bitmap == null) {
+      return null;
+    }
+
+    Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(),
+        Config.ARGB_8888);
+    android.graphics.Canvas canvas = new android.graphics.Canvas(output);
+
+    final int color = 0xff000000;
+    final android.graphics.Paint paint = new android.graphics.Paint();
+    final android.graphics.Rect rect = new android.graphics.Rect(0, 0,
+        bitmap.getWidth(), bitmap.getHeight());
+    final android.graphics.RectF rectF = new android.graphics.RectF(rect);
+
+    paint.setAntiAlias(true);
+    canvas.drawARGB(0, 0, 0, 0);
+    paint.setColor(color);
+    canvas.drawRoundRect(rectF, pixels, pixels, paint);
+
+    paint.setXfermode(new android.graphics.PorterDuffXfermode(Mode.SRC_IN));
+    canvas.drawBitmap(bitmap, rect, rect, paint);
+
+    return output;
+  }
+
+  public static void stateBackgroundDarkerByPercentage(View v,
+      int normalStateColor, int percentage) {
+    int darker = getDarker(normalStateColor, percentage);
+    stateBackground(v, normalStateColor, darker);
+  }
+
+  public static int getDarker(int color, int percentage) {
+    if (percentage < 0 || percentage > 100)
+      percentage = 0;
+    double d = ((100 - percentage) / (double) 100);
+    int a = (color >> 24) & 0xFF;
+    int r = (int) (((color >> 16) & 0xFF) * d) & 0xFF;
+    int g = (int) (((color >> 8) & 0xFF) * d) & 0xFF;
+    int b = (int) ((color & 0xFF) * d) & 0xFF;
+    a = a << 24;
+    r = r << 16;
+    g = g << 8;
+    return a | r | g | b;
+  }
+
+  public static void stateBackground(View v, int normalStateColor,
+      int pressedStateColor) {
+    if (Build.VERSION.SDK_INT >= 16) {
+      v.setBackground(getBackground(normalStateColor, pressedStateColor));
+    } else {
+      v.setBackgroundColor(normalStateColor);
+    }
+  }
+
+  private static Drawable getBackground(int normalStateColor,
+      int pressedStateColor) {
+    StateListDrawable background = new StateListDrawable();
+    int c = SizeUtil.dp10;
+    float[] r = new float[] {c, c, c, c, c, c, c, c};
+    RoundRectShape rr = new RoundRectShape(r, null, null);
+    ShapeDrawable cd = new ShapeDrawable();
+    cd.setShape(rr);
+    cd.getPaint().setColor(pressedStateColor);
+    background.addState(new int[] {android.R.attr.state_pressed,
+        android.R.attr.state_focused}, cd);
+    background.addState(new int[] {-android.R.attr.state_pressed,
+        android.R.attr.state_focused}, cd);
+    background.addState(new int[] {android.R.attr.state_pressed,
+        -android.R.attr.state_focused}, cd);
+    ShapeDrawable cd1 = new ShapeDrawable();
+    cd1.setShape(rr);
+    cd1.getPaint().setColor(normalStateColor);
+    background.addState(new int[] {-android.R.attr.state_pressed,
+        -android.R.attr.state_focused}, cd1);
+    return background;
+  }
+
+  /**
+   * Method to calculate a sample size value that is a power of two based on a target width and
+   * height. From official Android documentation:
+   * https://developer.android.com/training/displaying-bitmaps/load-bitmap.html
+   *
+   * @param reqWidth The requested width of the image.
+   * @param reqHeight The requested height of the image.
+   * @return The calculated inSampleSize - power of two.
+   */
+  private static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth,
+      int reqHeight) {
+    // Raw height and width of image.
+    final int height = options.outHeight;
+    final int width = options.outWidth;
+    int inSampleSize = 1;
+
+    if (height > reqHeight || width > reqWidth) {
+      final int halfHeight = height / 2;
+      final int halfWidth = width / 2;
+
+      // Calculate the largest inSampleSize value that is a power of 2 and keeps both
+      // height and width larger than the requested height and width.
+      while ((halfHeight / inSampleSize) >= reqHeight
+          && (halfWidth / inSampleSize) >= reqWidth) {
+        inSampleSize *= 2;
+      }
+    }
+
+    return inSampleSize;
+  }
+
+  /**
+   * Download a scaled bitmap.
+   *
+   * @param imageUrl The string of URL image.
+   * @param width The requested width of the image.
+   * @param height The requested height of the image.
+   * @return The scaled bitmap downloaded form URL.
+   */
+  private static Bitmap getBitmapFromUrl(String imageUrl, int width, int height) {
+    InputStream input = null;
+    try {
+      input = new URL(imageUrl).openStream();
+
+      // First decode with inJustDecodeBounds=true to check dimensions.
+      final BitmapFactory.Options options = new BitmapFactory.Options();
+      options.inJustDecodeBounds = true;
+      BitmapFactory.decodeStream(input, null, options);
+
+      closeStream(input);
+
+      input = new URL(imageUrl).openStream();
+      options.inSampleSize = calculateInSampleSize(options, width, height);
+      // Decode bitmap with inSampleSize set.
+      options.inJustDecodeBounds = false;
+      return BitmapFactory.decodeStream(input, null, options);
+    } catch (IOException e) {
+      Log.e(String.format("IOException in image download for URL: %s.", imageUrl), e);
+      return null;
+    } finally {
+      closeStream(input);
+    }
+  }
+
+  /**
+   * Create a scaled bitmap.
+   *
+   * @param context The application context.
+   * @param imageUrl The string of URL image.
+   * @return The scaled bitmap.
+   */
+  public static Bitmap getScaledBitmap(Context context, String imageUrl) {
+    // Processing an image depending on the current screen size to avoid central crop if the image
+    // ratio is more than 2:1. Google aspect ~2:1 - page 78
+    // http://commondatastorage.googleapis.com/io2012/presentations/live%20to%20website/105.pdf
+    WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
+    DisplayMetrics displayMetrics = new DisplayMetrics();
+    windowManager.getDefaultDisplay().getMetrics(displayMetrics);
+
+    int pixelsHeight = Math.round(displayMetrics.density * BIG_PICTURE_MAX_HEIGHT_DP + 0.5f);
+    int pixelsWidth = Math.min(2 * pixelsHeight, displayMetrics.widthPixels);
+
+    Bitmap bitmap = getBitmapFromUrl(imageUrl, pixelsWidth, pixelsHeight);
+    try {
+      bitmap = Bitmap.createScaledBitmap(bitmap, pixelsWidth, pixelsHeight, true);
+    } catch (Exception e) {
+      Log.e("Failed on scale image " + imageUrl + " to (" + pixelsWidth + ", " + pixelsHeight + ")",
+          e);
+    }
+
+    return bitmap;
+  }
+
+  /**
+   * Method to close InputStream.
+   *
+   * @param inputStream The InputStream which must be closed.
+   */
+  private static void closeStream(InputStream inputStream) {
+    try {
+      if (inputStream != null) {
+        inputStream.close();
+      }
+    } catch (IOException e) {
+      Log.e("IOException during closing of image download stream.", e);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/utils/SharedPreferencesUtil.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2017, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.utils;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+/**
+ * Shared preferences manipulation utilities.
+ *
+ * @author Anna Orlova
+ */
+public class SharedPreferencesUtil {
+  public static final String DEFAULT_STRING_VALUE = "";
+
+  /**
+   * Gets string value for key from shared preferences.
+   *
+   * @param context Application context.
+   * @param sharedPreferenceName Shared preference name.
+   * @param key Key of preference.
+   * @return String Value for key, if here no value - return DEFAULT_STRING_VALUE.
+   */
+  public static String getString(Context context, String sharedPreferenceName, String key) {
+    final SharedPreferences sharedPreferences = getPreferences(context, sharedPreferenceName);
+    return sharedPreferences.getString(key, DEFAULT_STRING_VALUE);
+  }
+
+  /**
+   * Get application shared preferences with sharedPreferenceName name.
+   *
+   * @param context Application context.
+   * @param sharedPreferenceName Shared preference name.
+   * @return Application's {@code SharedPreferences}.
+   */
+  private static SharedPreferences getPreferences(Context context, String sharedPreferenceName) {
+    return context.getSharedPreferences(sharedPreferenceName, Context.MODE_PRIVATE);
+  }
+
+  /**
+   * Sets string value for provided key to shared preference with sharedPreferenceName name.
+   *
+   * @param context application context.
+   * @param sharedPreferenceName shared preference name.
+   * @param key key of preference.
+   * @param value value of preference.
+   */
+  public static void setString(Context context, String sharedPreferenceName, String key,
+      String value) {
+    final SharedPreferences sharedPreferences = getPreferences(context, sharedPreferenceName);
+    SharedPreferences.Editor editor = sharedPreferences.edit();
+    editor.putString(key, value);
+    try {
+      editor.apply();
+    } catch (NoSuchMethodError e) {
+      editor.commit();
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/utils/SizeUtil.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.utils;
+
+import android.app.Activity;
+import android.content.Context;
+import android.util.DisplayMetrics;
+import android.view.WindowManager;
+
+/**
+ * Utilities for converting between different size units.
+ *
+ * @author Martin Yanakiev
+ */
+public class SizeUtil {
+  public static int dp2;
+  public static int dp30;
+  public static int dp5;
+  public static int dp20;
+  public static int dp18;
+  public static int dp16;
+  public static int dp14;
+  public static int dp10;
+  public static int dp7;
+  public static int dp100;
+  public static int dp200;
+  public static int dp250;
+  public static int dp50;
+
+  public static final int textSize0_2 = 16;
+  public static final int textSize0_1 = 18;
+  public static final int textSize0 = 20;
+  public static final int textSize1 = 22;
+  public static final int textSize2 = 24;
+
+  private static boolean hasInited = false;
+
+  public static void init(Context context) {
+    if (hasInited) {
+      return;
+    }
+    hasInited = true;
+    dp30 = dpToPx(context, 30);
+    dp5 = dpToPx(context, 5);
+    dp20 = dpToPx(context, 20);
+    dp10 = dpToPx(context, 10);
+    dp7 = dpToPx(context, 7);
+    dp18 = dpToPx(context, 18);
+    dp16 = dpToPx(context, 16);
+    dp14 = dpToPx(context, 14);
+    dp100 = dpToPx(context, 100);
+    dp200 = dpToPx(context, 200);
+    dp250 = dpToPx(context, 250);
+    dp2 = dpToPx(context, 2);
+    dp50 = dpToPx(context, 50);
+  }
+
+  public static int dpToPx(Context context, int dp) {
+    init(context);
+    DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
+    return Math.round(dp
+        * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT));
+  }
+
+  public static int pxToDp(Context context, int px) {
+    init(context);
+    DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
+    return Math.round(px
+        / (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT));
+  }
+
+  public static int spToPx(Context context, int sp) {
+    init(context);
+    DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
+    return (int) (sp * displayMetrics.scaledDensity);
+  }
+
+  public static int pxToSp(Context context, int px) {
+    init(context);
+    DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
+    return (int) (px / displayMetrics.scaledDensity);
+  }
+
+  public static int getStatusBarHeight(Activity activity) {
+    init(activity);
+    boolean full = (activity.getWindow().getAttributes().flags & WindowManager.LayoutParams.FLAG_FULLSCREEN) == WindowManager.LayoutParams.FLAG_FULLSCREEN;
+    if (full) {
+      return 0;
+    }
+    int result = 0;
+    int resourceId = activity.getResources().getIdentifier("status_bar_height",
+        "dimen", "android");
+    if (resourceId > 0) {
+      result = activity.getResources().getDimensionPixelSize(resourceId);
+    }
+    return result;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/views/BackgroundImageView.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.views;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Paint.Style;
+import android.graphics.PorterDuff.Mode;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.RelativeLayout.LayoutParams;
+
+import com.leanplum.utils.BitmapUtil;
+import com.leanplum.utils.SizeUtil;
+
+/**
+ * The image background on a Center Popup or Interstitial dialog.
+ *
+ * @author Martin Yanakiev
+ */
+public class BackgroundImageView extends ImageView {
+  private Paint paint = new Paint();
+  private boolean fullscreen;
+  private Matrix emptyMatrix = new Matrix();
+  private boolean loadedBitmap;
+
+  public BackgroundImageView(Context context, AttributeSet attrs, int defStyle) {
+    super(context, attrs, defStyle);
+    init();
+  }
+
+  public BackgroundImageView(Context context, AttributeSet attrs) {
+    super(context, attrs);
+    init();
+  }
+
+  public BackgroundImageView(Context context, boolean fullscreen) {
+    super(context);
+    init();
+    this.fullscreen = fullscreen;
+  }
+
+  private void init() {
+    paint.setColor(0xFF00FF00);
+    paint.setStrokeWidth(2);
+    paint.setStyle(Style.FILL_AND_STROKE);
+  }
+
+  @Override
+  protected void onDraw(Canvas canvas) {
+    super.onDraw(canvas);
+    if (fullscreen) {
+      return;
+    }
+    if (loadedBitmap) {
+      loadedBitmap = false;
+      return;
+    }
+    Bitmap bitmap = loadBitmapFromView(this);
+    canvas.drawColor(Color.TRANSPARENT, Mode.CLEAR);
+    bitmap = BitmapUtil.getRoundedCornerBitmap(bitmap, SizeUtil.dp20);
+    canvas.drawBitmap(bitmap, emptyMatrix, paint);
+  }
+
+  public Bitmap loadBitmapFromView(View view) {
+    if (view.getMeasuredHeight() <= 0) {
+      view.measure(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
+    }
+    Bitmap bitmap = Bitmap.createBitmap(view.getMeasuredWidth(), view.getMeasuredHeight(),
+        Bitmap.Config.ARGB_8888);
+    Canvas canvas = new Canvas(bitmap);
+    view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight());
+    loadedBitmap = true;
+    view.draw(canvas);
+    return bitmap;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/leanplum/views/CloseButton.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2014, Leanplum, Inc. All rights reserved.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.leanplum.views;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Paint.Style;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+
+import com.leanplum.utils.SizeUtil;
+
+/**
+ * The close button on a Center Popup or Interstitial dialog.
+ *
+ * @author Martin Yanakiev
+ */
+public class CloseButton extends View {
+  private Paint circlePaint = new Paint();
+  private Paint circlePressedPaint = new Paint();
+  private Paint linePaint = new Paint();
+  private float size;
+  private float x1;
+  private float y1;
+  private float x2;
+  private float y2;
+  private boolean isPressed = false;
+
+  public CloseButton(Context context) {
+    super(context);
+    initLabelView();
+  }
+
+  public CloseButton(Context context, AttributeSet attrs) {
+    super(context, attrs);
+    initLabelView();
+  }
+
+  private void initLabelView() {
+    circlePaint.setAntiAlias(true);
+    circlePaint.setColor(0xFFDDDDDD);
+    circlePaint.setStrokeWidth(2);
+    circlePaint.setStyle(Style.FILL_AND_STROKE);
+    circlePressedPaint.setAntiAlias(true);
+    circlePressedPaint.setColor(0xFF999999);
+    circlePressedPaint.setStrokeWidth(2);
+    circlePressedPaint.setStyle(Style.FILL_AND_STROKE);
+    linePaint.setAntiAlias(true);
+    linePaint.setColor(0xFF000000);
+    linePaint.setStrokeWidth(3);
+    linePaint.setStyle(Style.FILL_AND_STROKE);
+    size = SizeUtil.dp30;
+    x1 = size * (2 / (float) 6);
+    x2 = size * (4 / (float) 6);
+    y1 = size * (2 / (float) 6);
+    y2 = size * (4 / (float) 6);
+  }
+
+  @Override
+  public boolean performClick() {
+    return super.performClick();
+  }
+
+  @Override
+  public boolean onTouchEvent(MotionEvent event) {
+    if (event.getAction() == MotionEvent.ACTION_DOWN) {
+      isPressed = true;
+      invalidate();
+      return true;
+    } else if (event.getAction() == MotionEvent.ACTION_UP) {
+      isPressed = false;
+      invalidate();
+      performClick();
+      return true;
+    }
+    return super.onTouchEvent(event);
+  }
+
+  @Override
+  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+    super.onMeasure(widthMeasureSpec, widthMeasureSpec);
+    setMeasuredDimension((int) size, (int) size);
+  }
+
+  @Override
+  protected void onDraw(Canvas canvas) {
+    super.onDraw(canvas);
+    Paint backgroundPaint = isPressed ? circlePressedPaint : circlePaint;
+    canvas.drawCircle(getWidth() / 2f, getHeight() / 2f, (getWidth() / 2f) - 1, backgroundPaint);
+    canvas.drawLine(x1, y1, x2, y2, linePaint);
+    canvas.drawLine(x2, y1, x1, y2, linePaint);
+  }
+}