All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.leanplum.internal.VarCache Maven / Gradle / Ivy

The newest version!
/*
 * Copyright 2022, 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.text.TextUtils;
import androidx.annotation.Nullable;
import com.leanplum.ActionContext;
import com.leanplum.CacheUpdateBlock;
import com.leanplum.SecuredVars;
import com.leanplum.Leanplum;
import com.leanplum.LocationManager;
import com.leanplum.Var;
import com.leanplum.actions.internal.ActionManagerDefinitionKt;
import com.leanplum.internal.FileManager.HashResults;
import com.leanplum.internal.Request.RequestType;
import com.leanplum.utils.SharedPreferencesUtil;

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> vars = new ConcurrentHashMap<>();
  private static final Map fileAttributes = new HashMap<>();
  private static final Map fileStreams = new HashMap<>();
  private static volatile String varsJson;
  private static volatile String varsSignature;

  /**
   * The default values set by the client. This is not thread-safe so traversals should be
   * synchronized.
   */
  public static final Map valuesFromClient = new HashMap<>();

  private static final Map defaultKinds = new HashMap<>();
  private static final String LEANPLUM = "__leanplum__";
  private static Map diffs = new HashMap<>();
  private static Map regions = new HashMap<>();
  private static Map messageDiffs = new HashMap<>();
  private static Map devModeValuesFromServer;
  private static Map devModeFileAttributesFromServer;
  private static volatile List> variants = new ArrayList<>();
  private static volatile List> localCaps = new ArrayList<>();
  private static CacheUpdateBlock updateBlock;
  private static boolean hasReceivedDiffs = false;
  private static Map messages = new HashMap<>();
  private static Object merged;
  private static boolean silent;
  private static int contentVersion;
  private static Map userAttributes;
  private static Map variantDebugInfo = new HashMap<>();

  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 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 castedCollection = CollectionUtil.uncheckedCast(collection);
      Object result = castedCollection.get(key);
      if (autoInsert && result == null && key instanceof String) {
        result = new HashMap();
        castedCollection.put(key, result);
      }
      return result;
    } else if (collection instanceof List) {
      List castedList = CollectionUtil.uncheckedCast(collection);
      Object result = castedList.get((Integer) key);
      if (autoInsert && result == null) {
        result = new HashMap();
        castedList.set((Integer) key, result);
      }
      return result;
    }
    return null;
  }

  @FunctionalInterface
  public interface StreamProvider {
    InputStream openStream();
  }

  private static boolean isStreamAvailable(StreamProvider stream) {
    if (stream == null)
      return false;

    try {
      InputStream is = stream.openStream();
      if (is != null) {
        is.close();
        return true;
      }
    } catch (Throwable ignore) {
    }
    return false;
  }

  public static void registerFile(
      String stringValue, StreamProvider defaultStream, String hash, int size) {

    if (!isStreamAvailable(defaultStream)
        || !Constants.isDevelopmentModeEnabled
        || Constants.isNoop()) {
      return;
    }

    Map attributes = new HashMap<>();
    attributes.put(Constants.Keys.HASH, hash);
    attributes.put(Constants.Keys.SIZE, size);

    Map variationAttributes = new HashMap<>();
    variationAttributes.put("", attributes);

    fileStreams.put(stringValue, defaultStream);
    fileAttributes.put(stringValue, variationAttributes);
    maybeUploadNewFiles();
  }

  public static void registerFile(
      String stringValue, String defaultValue, StreamProvider defaultStream) {

    if (!isStreamAvailable(defaultStream)
        || !Constants.isDevelopmentModeEnabled
        || Constants.isNoop()) {
      return;
    }

    Map variationAttributes = new HashMap<>();
    Map attributes = new HashMap<>();

    if (Util.isSimulator()) {
      HashResults result = FileManager.fileMD5HashCreateWithPath(defaultStream.openStream());
      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);
    fileStreams.put(stringValue, defaultStream);
    fileAttributes.put(stringValue, variationAttributes);
    maybeUploadNewFiles();
  }

  public static void updateValues(String name, String[] nameComponents, Object value, String kind,
      Map values, Map 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 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  Var getVariable(String name) {
    return (Var) 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 merged = new ArrayList<>();
      for (Object var : varsKeys) {
        merged.add(var);
      }
      
      // Merge values from server
      // Array values from server come as Dictionary
      // Example:
      // string[] items = new string[] { "Item 1", "Item 2"};
      // args.With("Items", items); // Action Context arg value
      // "vars": {
      //      "Items": {
      //                  "[1]": "Item 222", // Modified value from server
      //                  "[0]": "Item 111"  // Modified value from server
      //              }
      //  }
      // Prevent error when loading variable diffs where the diff is an Array and not Dictionary
      if (diffMap != null) {
        for (Object varSubscript : diffKeys) {
          if (varSubscript instanceof String) {
            String strSubscript = (String) varSubscript;
            if (strSubscript.length() > 2 && strSubscript.startsWith("[") && strSubscript.endsWith("]")) {
              // Get the index from the string key: "[0]" -> 0
              int subscript = Integer.parseInt(strSubscript.substring(1, strSubscript.length() - 1));
              Object var = diffMap.get(strSubscript);
              while (subscript >= merged.size() && merged.size() < Short.MAX_VALUE) {
                merged.add(null);
              }
              merged.set(subscript, mergeHelper(merged.get(subscript), var));
            }
          }
        }
      }
      return merged;
    }

    // Merge dictionaries.
    if (vars instanceof Map || diff instanceof Map) {
      HashMap 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 getMergedValueFromComponentArray(Object[] components, Object values) {
    Object mergedPtr = values;
    for (Object component : components) {
      mergedPtr = traverse(mergedPtr, component, false);
    }
    return (T) mergedPtr;
  }

  public static  T getMergedValueFromComponentArray(Object[] components) {
    return getMergedValueFromComponentArray(components,
        merged != null ? merged : valuesFromClient);
  }

  public static Map getDiffs() {
    return diffs;
  }

  public static Map getMessageDiffs() {
    return messageDiffs;
  }

  public static Map regions() {
    return regions;
  }

  public static boolean hasReceivedDiffs() {
    return hasReceivedDiffs;
  }

  public static Map getVariantDebugInfo() {
    return variantDebugInfo;
  }

  public static void setVariantDebugInfo(Map variantDebugInfo) {
    if (variantDebugInfo != null) {
      VarCache.variantDebugInfo = variantDebugInfo;
    } else {
      VarCache.variantDebugInfo = new HashMap<>();
    }
  }

  public static void loadDiffs() {
    if (Constants.isNoop()) {
      return;
    }
    Context context = Leanplum.getContext();
    SharedPreferences defaults = context.getSharedPreferences(LEANPLUM, Context.MODE_PRIVATE);
    if (APIConfig.getInstance().token() == null) {
      applyVariableDiffs(
          new HashMap<>(),
          new HashMap<>(),
          new HashMap<>(),
          new ArrayList<>(),
          new ArrayList<>(),
          new HashMap<>(),
          "",
          "");
      return;
    }
    try {
      // Crypt functions return input text if there was a problem.
      AESCrypt aesContext = new AESCrypt(APIConfig.getInstance().appId(), APIConfig.getInstance().token());
      String variables = aesContext.decodePreference(
          defaults, Constants.Defaults.VARIABLES_KEY, "{}");
      String messages = aesContext.decodePreference(
          defaults, Constants.Defaults.MESSAGES_KEY, "{}");
      String regions = aesContext.decodePreference(defaults, Constants.Defaults.REGIONS_KEY, "{}");
      String variants = aesContext.decodePreference(defaults, Constants.Keys.VARIANTS, "[]");
      String localCaps = aesContext.decodePreference(defaults, Constants.Keys.LOCAL_CAPS, "[]");
      String variantDebugInfo = aesContext.decodePreference(defaults, Constants.Keys.VARIANT_DEBUG_INFO, "{}");
      String varsJson = aesContext.decodePreference(defaults, Constants.Defaults.VARIABLES_JSON_KEY, "{}");
      String varsSignature = aesContext.decodePreference(defaults, Constants.Defaults.VARIABLES_SIGN_KEY, null);
      applyVariableDiffs(
          JsonConverter.fromJson(variables),
          JsonConverter.fromJson(messages),
          JsonConverter.fromJson(regions),
          JsonConverter.listFromJson(new JSONArray(variants)),
          JsonConverter.listFromJson(new JSONArray(localCaps)),
          JsonConverter.fromJson(variantDebugInfo),
          varsJson,
          varsSignature);
      String deviceId = aesContext.decodePreference(defaults, Constants.Params.DEVICE_ID, null);
      if (deviceId != null) {
        APIConfig.getInstance().setDeviceId(deviceId);
      }
      String userId = aesContext.decodePreference(defaults, Constants.Params.USER_ID, null);
      if (userId != null) {
        APIConfig.getInstance().setUserId(userId);
      }
      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 (APIConfig.getInstance().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(APIConfig.getInstance().appId(), APIConfig.getInstance().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);

    String regionsCipher = aesContext.encrypt(JsonConverter.toJson(regions));
    editor.putString(Constants.Defaults.REGIONS_KEY, regionsCipher);

    try {
      if (variants != null && !variants.isEmpty()) {
        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));
    }

    try {
      if (localCaps != null) {
        String json = JsonConverter.listToJsonArray(localCaps).toString();
        editor.putString(Constants.Keys.LOCAL_CAPS, aesContext.encrypt(json));
      }
    } catch (JSONException e) {
      Log.e("Error converting " + localCaps + " to JSON.\n" + Log.getStackTraceString(e));
    }

    if (variantDebugInfo != null) {
      editor.putString(
          Constants.Keys.VARIANT_DEBUG_INFO,
          aesContext.encrypt(JsonConverter.toJson(variantDebugInfo)));
    }

    editor.putString(Constants.Defaults.VARIABLES_JSON_KEY, aesContext.encrypt(varsJson));
    editor.putString(Constants.Defaults.VARIABLES_SIGN_KEY, aesContext.encrypt(varsSignature));

    editor.putString(Constants.Params.DEVICE_ID, aesContext.encrypt(APIConfig.getInstance().deviceId()));
    editor.putString(Constants.Params.USER_ID, aesContext.encrypt(APIConfig.getInstance().userId()));
    editor.putString(Constants.Keys.LOGGING_ENABLED,
        aesContext.encrypt(String.valueOf(Constants.loggingEnabled)));
    SharedPreferencesUtil.commitChanges(editor);
  }

  /**
   * 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);
      if (var == null) {
        continue;
      }
      String overrideFile = var.stringValue;
      if (var.isResource && Constants.Kinds.FILE.equals(var.kind()) && overrideFile != null &&
          !overrideFile.equals(var.defaultValue())) {
        Map variationAttributes = CollectionUtil.uncheckedCast(fileAttributes.get
            (overrideFile));
        StreamProvider streamProvider = fileStreams.get(overrideFile);
        if (variationAttributes != null && streamProvider != null) {
          var.setOverrideResId(getResIdFromPath(var.stringValue()));
        }
      }
    }
  }

  public static void applyVariableDiffs(
      Map diffs,
      Map messages,
      Map regions,
      List> variants,
      List> localCaps,
      Map variantDebugInfo,
      String varsJson,
      String varsSignature) {
    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()) {
        Var var = vars.get(name);
        if (var != null) {
          var.update();
        }
      }
      fileVariableFinish();
    }

    if (messages != null) {
      // Store messages.
      messageDiffs = messages;
      Map newMessages = new HashMap<>();
      for (Map.Entry entry : messages.entrySet()) {
        Map messageConfig = CollectionUtil.uncheckedCast(entry.getValue());
        Map newConfig = new HashMap<>(messageConfig);
        Map actionArgs = CollectionUtil.uncheckedCast(messageConfig.get(Constants
            .Keys.VARS));
        Map actionDefinitions =
            ActionManager.getInstance().getDefinitions().getActionDefinitionMaps();
        Map defaultArgs = Util.multiIndex(actionDefinitions,
            newConfig.get(Constants.Params.ACTION), "values");
        Map vars = CollectionUtil.uncheckedCast(mergeHelper(defaultArgs,
            actionArgs));
        newMessages.put(entry.getKey(), newConfig);
        newConfig.put(Constants.Keys.VARS, vars);
      }

      VarCache.messages = newMessages;
      for (Map.Entry entry : VarCache.messages.entrySet()) {
        String name = entry.getKey();
        Map messageConfig = CollectionUtil.uncheckedCast(VarCache.messages.get
            (name));
        if (messageConfig != null && messageConfig.get("action") != null) {
          Map 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 foregroundRegionNames = new HashSet<>();
      Set backgroundRegionNames = new HashSet<>();
      ActionManager.getForegroundandBackgroundRegionNames(foregroundRegionNames,
          backgroundRegionNames);
      LocationManager locationManager = ActionManager.getLocationManager();
      if (locationManager != null) {
        locationManager.setRegionsData(regions, foregroundRegionNames, backgroundRegionNames);
      }
    }

    if (variants != null) {
      VarCache.variants = variants;
    }

    if (localCaps != null) {
      VarCache.localCaps = localCaps;
    }

    if (variantDebugInfo != null) {
      VarCache.setVariantDebugInfo(variantDebugInfo);
    }

    if (varsJson != null) {
      VarCache.varsJson = varsJson;
      VarCache.varsSignature = varsSignature;
    }

    contentVersion++;

    if (!silent) {
      saveDiffs();
      triggerHasReceivedDiffs();
    }
  }

  public static int contentVersion() {
    return contentVersion;
  }

  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;
    }
    Map actionDefinitions =
        ActionManager.getInstance().getDefinitions().getActionDefinitionMaps();

    boolean areLocalAndServerDefinitionsEqual =
        ActionManagerDefinitionKt.areLocalAndServerDefinitionsEqual(ActionManager.getInstance());

    if (actions && !areLocalAndServerDefinitionsEqual) {
      changed = true;
    }

    if (changed) {
      HashMap 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 request = RequestBuilder
          .withSetVarsAction()
          .andParams(params)
          .andType(RequestType.IMMEDIATE)
          .create();
      RequestSender.getInstance().send(request);
    }

    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 filenames = new ArrayList<>();
    List fileData = new ArrayList<>();
    List streams = new ArrayList<>();
    int totalSize = 0;
    for (Map.Entry entry : fileAttributes.entrySet()) {
      String name = entry.getKey();
      Map variationAttributes = CollectionUtil.uncheckedCast(entry.getValue());
      Map serverVariationAttributes =
          CollectionUtil.uncheckedCast(devModeFileAttributesFromServer.get(name));
      Map localAttributes = CollectionUtil.uncheckedCast(variationAttributes.get
          (""));
      Map serverAttributes = CollectionUtil.uncheckedCast(
          (serverVariationAttributes != null ? serverVariationAttributes.get("") : null));
      if (FileManager.isNewerLocally(localAttributes, serverAttributes)) {
        Log.d("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) {

          FileTransferManager.getInstance().sendFilesNow(fileData, 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());
        }
        InputStream is = null;
        StreamProvider streamProvider = fileStreams.get(name);
        if (streamProvider != null) {
          is = streamProvider.openStream();
        }
        streams.add(is);
      }
    }

    if (filenames.size() > 0) {
      FileTransferManager.getInstance().sendFilesNow(fileData, 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 values,
      Map fileAttributes, Map actionDefinitions) {
    devModeValuesFromServer = values;
    ActionManagerDefinitionKt.setDevModeActionDefinitionsFromServer(
        ActionManager.getInstance(),
        actionDefinitions);
    devModeFileAttributesFromServer = fileAttributes;
  }

  public static void onUpdate(CacheUpdateBlock block) {
    updateBlock = block;
  }

  public static List> variants() {
    return variants;
  }

  public static List> localCaps() {
    return localCaps;
  }

  public static Map messages() {
    return messages;
  }

  public static  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 userAttributes() {
    if (userAttributes == null) {
      Context context = Leanplum.getContext();
      SharedPreferences defaults = context.getSharedPreferences(LEANPLUM, Context.MODE_PRIVATE);
      AESCrypt aesContext = new AESCrypt(APIConfig.getInstance().appId(), APIConfig.getInstance().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() || APIConfig.getInstance().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(APIConfig.getInstance().appId(), APIConfig.getInstance().token());
    editor.putString(Constants.Defaults.ATTRIBUTES_KEY, aesContext.encrypt(plaintext));
    SharedPreferencesUtil.commitChanges(editor);
  }

  @Nullable
  public static SecuredVars getSecuredVars() {
    if (TextUtils.isEmpty(varsJson) || TextUtils.isEmpty(varsSignature)) {
      return null;
    }
    return new SecuredVars(varsJson, varsSignature);
  }

  public static void clearUserContent() {
    vars.clear();
    variants = new ArrayList<>();
    localCaps = new ArrayList<>();
    variantDebugInfo.clear();
    varsJson = null;
    varsSignature = null;

    diffs.clear();
    messageDiffs.clear();
    messages = null;
    userAttributes = null;
    merged = null;

    devModeValuesFromServer = null;
    devModeFileAttributesFromServer = null;
    ActionManagerDefinitionKt.setDevModeActionDefinitionsFromServer(
        ActionManager.getInstance(), null);
  }

  /**
   * Resets the VarCache to stock state.
   */
  public static void reset() {
    vars.clear();
    variantDebugInfo.clear();
    fileAttributes.clear();
    fileStreams.clear();
    valuesFromClient.clear();
    defaultKinds.clear();
    ActionManager.getInstance().getDefinitions().clear();
    diffs.clear();
    messageDiffs.clear();
    regions.clear();
    devModeValuesFromServer = null;
    devModeFileAttributesFromServer = null;
    ActionManagerDefinitionKt.setDevModeActionDefinitionsFromServer(
        ActionManager.getInstance(), null);
    variants = new ArrayList<>();
    localCaps = new ArrayList<>();
    updateBlock = null;
    hasReceivedDiffs = false;
    messages = null;
    merged = null;
    silent = false;
    contentVersion = 0;
    userAttributes = null;
    varsJson = null;
    varsSignature = null;
  }
}