/*
 * 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.Build;
import android.provider.Settings.Secure;
import android.text.TextUtils;
import android.util.TypedValue;

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 com.leanplum.monitoring.ExceptionHandler;
import com.leanplum.utils.SharedPreferencesUtil;

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.lang.reflect.Method;
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.TimeUnit;
import java.util.zip.GZIPInputStream;

import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLSocketFactory;

import androidx.annotation.RequiresPermission;

/**
 * Leanplum utilities.
 *
 * @author Andrew First
 */
public class Util {

  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 or androidX. Note: This method must
   * not run on the main thread.
   */
  private static DeviceIdInfo getAdvertisingId(Context caller) {
    try {
      final String[] classNames = {
          "androidx.ads.identifier.AdvertisingIdClient",
          "com.google.android.gms.ads.identifier.AdvertisingIdClient"
      };

      for (String name : classNames) {
        try {
          Object adInfo = Class.forName(name)
              .getMethod("getAdvertisingIdInfo", Context.class)
              .invoke(null, caller);

          if (name.equals(classNames[0])) {
            Method get = adInfo.getClass().getMethod("get", long.class, TimeUnit.class);
            adInfo = get.invoke(adInfo, 5, TimeUnit.SECONDS);
          }

          String id = checkDeviceId("advertising id", (String) adInfo.getClass().getMethod("getId")
              .invoke(adInfo));

          if (id != null) {
            boolean limitTracking = (Boolean) adInfo.getClass()
                .getMethod("isLimitAdTrackingEnabled")
                .invoke(adInfo);
            Log.v("Using advertising device id: " + id);
            return new DeviceIdInfo(id, limitTracking);
          }
        } catch (Throwable t) {
          Log.i("Couldn't get AdvertisingID using class: " + name);
        }
      }
    } 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 {
      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("RequestOld 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();

    /*
      Must include `Accept-Encoding: gzip` in the header
      Must include the phrase `gzip` in the `User-Agent` header
      https://cloud.google.com/appengine/kb/
    */

    urlConnection.setRequestProperty("User-Agent",
        getApplicationName(context) + "/" + getVersionName() + "/" + RequestOld.appId() + "/" +
            Constants.CLIENT + "/" + Constants.LEANPLUM_VERSION + "/" + getSystemName() + "/" +
            getSystemVersion() + "/" + Constants.LEANPLUM_SUPPORTED_ENCODING + "/" + Constants.LEANPLUM_PACKAGE_IDENTIFIER);
    urlConnection.setRequestProperty("Accept-Encoding", Constants.LEANPLUM_SUPPORTED_ENCODING);
    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;
    String contentHeader = op.getHeaderField("content-encoding");
    boolean isCompressed = contentHeader != null && contentHeader.trim().equalsIgnoreCase(Constants.LEANPLUM_SUPPORTED_ENCODING);
    if (op.getResponseCode() < 400) {
      inputStream = op.getInputStream();
    } else {
      inputStream = op.getErrorStream();
    }

    // If we have a gzipped response, de-compress it first
    if (isCompressed) inputStream = new GZIPInputStream(inputStream);

    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);
  }

  /**
   * 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);
    SharedPreferencesUtil.commitChanges(editor);
  }

  /**
   * 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);
    }
  }

  /**
   * Initialize exception handling in the SDK.
   */
  public static void initExceptionHandling(Context context) {
    ExceptionHandler.getInstance().setContext(context);
  }

  /**
   * Handles uncaught exceptions in the SDK.
   */
  public static void handleException(Throwable t) {
    ExceptionHandler.getInstance().reportException(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);
      RequestOld.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;
  }
}