<commit_before>package com.amplitude.api;

import android.app.Application;
import android.content.Context;
import android.content.SharedPreferences;
import android.location.Location;
import android.os.Build;
import android.util.Pair;

import com.amplitude.security.MD5;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;

import okhttp3.FormBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

/**
 * <h1>AmplitudeClient</h1>
 * This is the SDK instance class that contains all of the SDK functionality.<br><br>
 * <b>Note:</b> call the methods on the default shared instance in the Amplitude class,
 * for example: {@code Amplitude.getInstance().logEvent();}<br><br>
 * Many of the SDK functions return the SDK instance back, allowing you to chain multiple method
 * calls together, for example: {@code Amplitude.getInstance().initialize(this, "APIKEY").enableForegroundTracking(getApplication())}
 */
public class AmplitudeClient {

    /**
     * The class identifier tag used in logging. TAG = {@code "com.amplitude.api.AmplitudeClient";}
     */
    public static final String TAG = "com.amplitude.api.AmplitudeClient";

    /**
     * The event type for start session events.
     */
    public static final String START_SESSION_EVENT = "session_start";
    /**
     * The event type for end session events.
     */
    public static final String END_SESSION_EVENT = "session_end";

    /**
     * The pref/database key for the device ID value.
     */
    public static final String DEVICE_ID_KEY = "device_id";
    /**
     * The pref/database key for the user ID value.
     */
    public static final String USER_ID_KEY = "user_id";
    /**
     * The pref/database key for the opt out flag.
     */
    public static final String OPT_OUT_KEY = "opt_out";
    /**
     * The pref/database key for the sequence number.
     */
    public static final String SEQUENCE_NUMBER_KEY = "sequence_number";
    /**
     * The pref/database key for the last event time.
     */
    public static final String LAST_EVENT_TIME_KEY = "last_event_time";
    /**
     * The pref/database key for the last event ID value.
     */
    public static final String LAST_EVENT_ID_KEY = "last_event_id";
    /**
     * The pref/database key for the last identify ID value.
     */
    public static final String LAST_IDENTIFY_ID_KEY = "last_identify_id";
    /**
     * The pref/database key for the previous session ID value.
     */
    public static final String PREVIOUS_SESSION_ID_KEY = "previous_session_id";

    private static final AmplitudeLog logger = AmplitudeLog.getLogger();

    /**
     * The Android App Context.
     */
    protected Context context;
    /**
     * The shared OkHTTPClient instance.
     */
    protected OkHttpClient httpClient;
    /**
     * The shared Amplitude database helper instance.
     */
    protected DatabaseHelper dbHelper;
    /**
     * The Amplitude App API key.
     */
    protected String apiKey;
    /**
     * The name for this instance of AmplitudeClient.
     */
    protected String instanceName;
    /**
     * The user's ID value.
     */
    protected String userId;
    /**
     * The user's Device ID value.
     */
    protected String deviceId;
    private boolean newDeviceIdPerInstall = false;
    private boolean useAdvertisingIdForDeviceId = false;
    protected boolean initialized = false;
    private boolean optOut = false;
    private boolean offline = false;
    TrackingOptions trackingOptions = new TrackingOptions();
    JSONObject apiPropertiesTrackingOptions = new JSONObject();
    /**
     * The device's Platform value.
     */
    protected String platform;

    /**
     * Event metadata
     */
    long sessionId = -1;
    long sequenceNumber = 0;
    long lastEventId = -1;
    long lastIdentifyId = -1;
    long lastEventTime = -1;
    long previousSessionId = -1;

    private DeviceInfo deviceInfo;

    /**
     * The current session ID value.
     */
    private int eventUploadThreshold = Constants.EVENT_UPLOAD_THRESHOLD;
    private int eventUploadMaxBatchSize = Constants.EVENT_UPLOAD_MAX_BATCH_SIZE;
    private int eventMaxCount = Constants.EVENT_MAX_COUNT;
    private long eventUploadPeriodMillis = Constants.EVENT_UPLOAD_PERIOD_MILLIS;
    private long minTimeBetweenSessionsMillis = Constants.MIN_TIME_BETWEEN_SESSIONS_MILLIS;
    private long sessionTimeoutMillis = Constants.SESSION_TIMEOUT_MILLIS;
    private boolean backoffUpload = false;
    private int backoffUploadBatchSize = eventUploadMaxBatchSize;
    private boolean usingForegroundTracking = false;
    private boolean trackingSessionEvents = false;
    private boolean inForeground = false;
    private boolean flushEventsOnClose = true;

    private AtomicBoolean updateScheduled = new AtomicBoolean(false);
    /**
     * Whether or not the SDK is in the process of uploading events.
     */
    AtomicBoolean uploadingCurrently = new AtomicBoolean(false);

    /**
     * The last SDK error - used for testing.
     */
    Throwable lastError;
    /**
     * The url for Amplitude API endpoint
     */
    String url = Constants.EVENT_LOG_URL;
    /**
     * The background event logging worker thread instance.
     */
    WorkerThread logThread = new WorkerThread("logThread");
    /**
     * The background event uploading worker thread instance.
     */
    WorkerThread httpThread = new WorkerThread("httpThread");

    /**
     * Instantiates a new default instance AmplitudeClient and starts worker threads.
     */
    public AmplitudeClient() {
        this(null);
    }

    /**
     * Instantiates a new AmplitudeClient with instance name and starts worker threads.
     * @param instance
     */
    public AmplitudeClient(String instance) {
        this.instanceName = Utils.normalizeInstanceName(instance);
        logThread.start();
        httpThread.start();
    }

    /**
     * Initialize the Amplitude SDK with the Android application context and your Amplitude
     * App API key. <b>Note:</b> initialization is required before you log events and modify
     * user properties.
     *
     * @param context the Android application context
     * @param apiKey  your Amplitude App API key
     * @return the AmplitudeClient
     */
    public AmplitudeClient initialize(Context context, String apiKey) {
        return initialize(context, apiKey, null);
    }

    /**
     * Initialize the Amplitude SDK with the Android application context, your Amplitude App API
     * key, and a user ID for the current user. <b>Note:</b> initialization is required before
     * you log events and modify user properties.
     *
     * @param context the Android application context
     * @param apiKey  your Amplitude App API key
     * @param userId  the user id to set
     * @return the AmplitudeClient
     */
    public AmplitudeClient initialize(Context context, String apiKey, String userId) {
        return initialize(context, apiKey, userId, null);
    }

    /**
     * Initialize the Amplitude SDK with the Android application context, your Amplitude App API
     * key, a user ID for the current user, and a custom platform value.
     * <b>Note:</b> initialization is required before you log events and modify user properties.
     *
     * @param context the Android application context
     * @param apiKey  your Amplitude App API key
     * @param userId  the user id to set
     * @param
     * @return the AmplitudeClient
     */
    public synchronized AmplitudeClient initialize(final Context context, final String apiKey, final String userId, final String platform) {
        if (context == null) {
            logger.e(TAG, "Argument context cannot be null in initialize()");
            return this;
        }

        if (Utils.isEmptyString(apiKey)) {
            logger.e(TAG, "Argument apiKey cannot be null or blank in initialize()");
            return this;
        }

        this.context = context.getApplicationContext();
        this.apiKey = apiKey;
        this.dbHelper = DatabaseHelper.getDatabaseHelper(this.context, this.instanceName);
        this.platform = Utils.isEmptyString(platform) ? Constants.PLATFORM : platform;

        final AmplitudeClient client = this;
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (!initialized) {
                    // this try block is idempotent, so it's safe to retry initialize if failed
                    try {
                        if (instanceName.equals(Constants.DEFAULT_INSTANCE)) {
                            AmplitudeClient.upgradePrefs(context);
                            AmplitudeClient.upgradeSharedPrefsToDB(context);
                        }
                        httpClient = new OkHttpClient();
                        initializeDeviceInfo();

                        if (userId != null) {
                            client.userId = userId;
                            dbHelper.insertOrReplaceKeyValue(USER_ID_KEY, userId);
                        } else {
                            client.userId = dbHelper.getValue(USER_ID_KEY);
                        }
                        final Long optOutLong = dbHelper.getLongValue(OPT_OUT_KEY);
                        optOut = optOutLong != null && optOutLong == 1;

                        // try to restore previous session id
                        previousSessionId = getLongvalue(PREVIOUS_SESSION_ID_KEY, -1);
                        if (previousSessionId >= 0) {
                            sessionId = previousSessionId;
                        }

                        // reload event meta data
                        sequenceNumber = getLongvalue(SEQUENCE_NUMBER_KEY, 0);
                        lastEventId = getLongvalue(LAST_EVENT_ID_KEY, -1);
                        lastIdentifyId = getLongvalue(LAST_IDENTIFY_ID_KEY, -1);
                        lastEventTime = getLongvalue(LAST_EVENT_TIME_KEY, -1);

                        // install database reset listener to re-insert metadata in memory
                        dbHelper.setDatabaseResetListener(new DatabaseResetListener() {
                            @Override
                            public void onDatabaseReset() {
                                dbHelper.insertOrReplaceKeyValue(DEVICE_ID_KEY, deviceId);
                                dbHelper.insertOrReplaceKeyValue(USER_ID_KEY, client.userId);
                                dbHelper.insertOrReplaceKeyLongValue(OPT_OUT_KEY, optOut ? 1L : 0L);
                                dbHelper.insertOrReplaceKeyLongValue(PREVIOUS_SESSION_ID_KEY, sessionId);
                                dbHelper.insertOrReplaceKeyLongValue(LAST_EVENT_TIME_KEY, lastEventTime);
                            }
                        });

                        initialized = true;

                    } catch (CursorWindowAllocationException e) {  // treat as uninitialized SDK
                        logger.e(TAG, String.format(
                           "Failed to initialize Amplitude SDK due to: %s", e.getMessage()
                        ));
                        client.apiKey = null;
                    }
                }
            }
        });

        return this;
    }

    /**
     * Enable foreground tracking for the SDK. This is <b>HIGHLY RECOMMENDED</b>, and will allow
     * for accurate session tracking.
     *
     * @param app the Android application
     * @return the AmplitudeClient
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public AmplitudeClient enableForegroundTracking(Application app) {
        if (usingForegroundTracking || !contextAndApiKeySet("enableForegroundTracking()")) {
            return this;
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
            app.registerActivityLifecycleCallbacks(new AmplitudeCallbacks(this));
        }

        return this;
    }

    // this method should only be called from the background log thread
    private void initializeDeviceInfo() {
        deviceInfo = new DeviceInfo(context);
        deviceId = initializeDeviceId();
        deviceInfo.prefetch();
    }

    /**
     * Whether to set a new device ID per install. If true, then the SDK will always generate a new
     * device ID on app install (as opposed to re-using an existing value like ADID).
     *
     * @param newDeviceIdPerInstall whether to set a new device ID on app install.
     * @return the AmplitudeClient
     * @deprecated
     */
    public AmplitudeClient enableNewDeviceIdPerInstall(boolean newDeviceIdPerInstall) {
        this.newDeviceIdPerInstall = newDeviceIdPerInstall;
        return this;
    }

    /**
     * Whether to use the Android advertising ID (ADID) as the user's device ID.
     *
     * @return the AmplitudeClient
     */
    public AmplitudeClient useAdvertisingIdForDeviceId() {
        this.useAdvertisingIdForDeviceId = true;
        return this;
    }

    /**
     * Enable location listening in the SDK. This will add the user's current lat/lon coordinates
     * to every event logged.
     *
     * @return the AmplitudeClient
     */
    public AmplitudeClient enableLocationListening() {
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (deviceInfo == null) {
		    throw new IllegalStateException(
		            "Must initialize before acting on location listening.");
                }
                deviceInfo.setLocationListening(true);
            }
        });
        return this;
    }

    /**
     * Disable location listening in the SDK. This will stop the sending of the user's current
     * lat/lon coordinates.
     *
     * @return the AmplitudeClient
     */
    public AmplitudeClient disableLocationListening() {
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (deviceInfo == null) {
		    throw new IllegalStateException(
		            "Must initialize before acting on location listening.");
                }
                deviceInfo.setLocationListening(false);
            }
        });
        return this;
    }

    /**
     * Sets event upload threshold. The SDK will attempt to batch upload unsent events
     * every eventUploadPeriodMillis milliseconds, or if the unsent event count exceeds the
     * event upload threshold.
     *
     * @param eventUploadThreshold the event upload threshold
     * @return the AmplitudeClient
     */
    public AmplitudeClient setEventUploadThreshold(int eventUploadThreshold) {
        this.eventUploadThreshold = eventUploadThreshold;
        return this;
    }

    /**
     * Sets event upload max batch size. This controls the maximum number of events sent with
     * each upload request.
     *
     * @param eventUploadMaxBatchSize the event upload max batch size
     * @return the AmplitudeClient
     */
    public AmplitudeClient setEventUploadMaxBatchSize(int eventUploadMaxBatchSize) {
        this.eventUploadMaxBatchSize = eventUploadMaxBatchSize;
        this.backoffUploadBatchSize = eventUploadMaxBatchSize;
        return this;
    }

    /**
     * Sets event max count. This is the maximum number of unsent events to keep on the device
     * (for example if the device does not have internet connectivity and cannot upload events).
     * If the number of unsent events exceeds the max count, then the SDK begins dropping events,
     * starting from the earliest logged.
     *
     * @param eventMaxCount the event max count
     * @return the AmplitudeClient
     */
    public AmplitudeClient setEventMaxCount(int eventMaxCount) {
        this.eventMaxCount = eventMaxCount;
        return this;
    }

    /**
     * Sets event upload period millis. The SDK will attempt to batch upload unsent events
     * every eventUploadPeriodMillis milliseconds, or if the unsent event count exceeds the
     * event upload threshold.
     *
     * @param eventUploadPeriodMillis the event upload period millis
     * @return the AmplitudeClient
     */
    public AmplitudeClient setEventUploadPeriodMillis(int eventUploadPeriodMillis) {
        this.eventUploadPeriodMillis = eventUploadPeriodMillis;
        return this;
    }

    /**
     * Sets min time between sessions millis.
     *
     * @param minTimeBetweenSessionsMillis the min time between sessions millis
     * @return the min time between sessions millis
     */
    public AmplitudeClient setMinTimeBetweenSessionsMillis(long minTimeBetweenSessionsMillis) {
        this.minTimeBetweenSessionsMillis = minTimeBetweenSessionsMillis;
        return this;
    }

    /**
     * Sets session timeout millis. If foreground tracking has not been enabled with
     * @{code enableForegroundTracking()}, then new sessions will be started after
     * sessionTimeoutMillis milliseconds have passed since the last event logged.
     *
     * @param sessionTimeoutMillis the session timeout millis
     * @return the AmplitudeClient
     */
    public AmplitudeClient setSessionTimeoutMillis(long sessionTimeoutMillis) {
        this.sessionTimeoutMillis = sessionTimeoutMillis;
        return this;
    }

    public AmplitudeClient setTrackingOptions(TrackingOptions trackingOptions) {
        this.trackingOptions = trackingOptions;
        this.apiPropertiesTrackingOptions = trackingOptions.getApiPropertiesTrackingOptions();
        return this;
    }

    /**
     * Sets opt out. If true then the SDK does not track any events for the user.
     *
     * @param optOut whether or not to opt the user out of tracking
     * @return the AmplitudeClient
     */
    public AmplitudeClient setOptOut(final boolean optOut) {
        if (!contextAndApiKeySet("setOptOut()")) {
            return this;
        }

        final AmplitudeClient client = this;
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (Utils.isEmptyString(apiKey)) { // in case initialization failed
                    return;
                }
                client.optOut = optOut;
                dbHelper.insertOrReplaceKeyLongValue(OPT_OUT_KEY, optOut ? 1L : 0L);
            }
        });
        return this;
    }

    /**
     * Returns whether or not the user is opted out of tracking.
     *
     * @return the optOut flag value
     */
    public boolean isOptedOut() {
        return optOut;
    }

    /**
     * Enable/disable message logging by the SDK.
     *
     * @param enableLogging whether to enable message logging by the SDK.
     * @return the AmplitudeClient
     */
    public AmplitudeClient enableLogging(boolean enableLogging) {
        logger.setEnableLogging(enableLogging);
        return this;
    }

    /**
     * Sets the logging level. Logging messages will only appear if they are the same severity
     * level or higher than the set log level.
     *
     * @param logLevel the log level
     * @return the AmplitudeClient
     */
    public AmplitudeClient setLogLevel(int logLevel) {
        logger.setLogLevel(logLevel);
        return this;
    }

    /**
     * Sets offline. If offline is true, then the SDK will not upload events to Amplitude servers;
     * however, it will still log events.
     *
     * @param offline whether or not the SDK should be offline
     * @return the AmplitudeClient
     */
    public AmplitudeClient setOffline(boolean offline) {
        this.offline = offline;

        // Try to update to the server once offline mode is disabled.
        if (!offline) {
            uploadEvents();
        }

        return this;
    }

    /**
     * Enable/disable flushing of unsent events on app close (enabled by default).
     *
     * @param flushEventsOnClose whether to flush unsent events on app close
     * @return the AmplitudeClient
     */
    public AmplitudeClient setFlushEventsOnClose(boolean flushEventsOnClose) {
        this.flushEventsOnClose = flushEventsOnClose;
        return this;
    }

    /**
     * Track session events amplitude client. If enabled then the SDK will automatically send
     * start and end session events to mark the start and end of the user's sessions.
     *
     * @param trackingSessionEvents whether to enable tracking of session events
     * @return the AmplitudeClient
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public AmplitudeClient trackSessionEvents(boolean trackingSessionEvents) {
        this.trackingSessionEvents = trackingSessionEvents;
        return this;
    }

    /**
     * Set foreground tracking to true.
     */
    void useForegroundTracking() {
        usingForegroundTracking = true;
    }

    /**
     * Whether foreground tracking is enabled.
     *
     * @return whether foreground tracking is enabled
     */
    boolean isUsingForegroundTracking() { return usingForegroundTracking; }

    /**
     * Whether app is in the foreground.
     *
     * @return whether app is in the foreground
     */
    boolean isInForeground() { return inForeground; }

    /**
     * Log an event with the specified event type.
     * <b>Note:</b> this is asynchronous and happens on a background thread.
     *
     * @param eventType the event type
     */
    public void logEvent(String eventType) {
        logEvent(eventType, null);
    }

    /**
     * Log an event with the specified event type and event properties.
     * <b>Note:</b> this is asynchronous and happens on a background thread.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     */
    public void logEvent(String eventType, JSONObject eventProperties) {
        logEvent(eventType, eventProperties, false);
    }

    /**
     * Log an event with the specified event type, event properties, with optional out of session
     * flag. If out of session is true, then the sessionId will be -1 for the event, indicating
     * that it is not part of the current session. Note: this might be useful when logging events
     * for notifications received.
     * <b>Note:</b> this is asynchronous and happens on a background thread.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param outOfSession    the out of session
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public void logEvent(String eventType, JSONObject eventProperties, boolean outOfSession) {
        logEvent(eventType, eventProperties, null, outOfSession);
    }

    /**
     * Log an event with the specified event type, event properties, and groups. Use this to set
     * event-level groups, meaning the group(s) set only apply for this specific event and does
     * not persist on the user.
     * <b>Note:</b> this is asynchronous and happens on a background thread.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param groups          the groups
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-groups">
     *     Setting Groups</a>
     */
    public void logEvent(String eventType, JSONObject eventProperties, JSONObject groups) {
        logEvent(eventType, eventProperties, groups, false);
    }

    /**
     * Log event with the specified event type, event properties, groups, with optional out of
     * session flag. If out of session is true, then the sessionId will be -1 for the event,
     * indicating that it is not part of the current session. Note: this might be useful when
     * logging events for notifications received.
     * <b>Note:</b> this is asynchronous and happens on a background thread.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param groups          the groups
     * @param outOfSession    the out of session
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-groups">
     *     Setting Groups</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public void logEvent(String eventType, JSONObject eventProperties, JSONObject groups, boolean outOfSession) {
        logEvent(eventType, eventProperties, groups, getCurrentTimeMillis(), outOfSession);
    }

    /**
     * Log event with the specified event type, event properties, groups, timestamp, with optional
     * out of session flag. If out of session is true, then the sessionId will be -1 for the event,
     * indicating that it is not part of the current session. Note: this might be useful when
     * logging events for notifications received.
     * <b>Note:</b> this is asynchronous and happens on a background thread.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param groups          the groups
     * @param timestamp       the timestamp in millisecond since epoch
     * @param outOfSession    the out of session
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-groups">
     *     Setting Groups</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public void logEvent(String eventType, JSONObject eventProperties, JSONObject groups, long timestamp, boolean outOfSession) {
        if (validateLogEvent(eventType)) {
            logEventAsync(
                eventType, eventProperties, null, null, groups, timestamp, outOfSession
            );
        }
    }

    /**
     * Log an event with the specified event type.
     * <b>Note:</b> this is version is synchronous and blocks the main thread until done.
     *
     * @param eventType the event type
     */
    public void logEventSync(String eventType) {
        logEventSync(eventType, null);
    }

    /**
     * Log an event with the specified event type and event properties.
     * <b>Note:</b> this is version is synchronous and blocks the main thread until done.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     */
    public void logEventSync(String eventType, JSONObject eventProperties) {
        logEventSync(eventType, eventProperties, false);
    }

    /**
     * Log an event with the specified event type, event properties, with optional out of session
     * flag. If out of session is true, then the sessionId will be -1 for the event, indicating
     * that it is not part of the current session. Note: this might be useful when logging events
     * for notifications received.
     * <b>Note:</b> this is version is synchronous and blocks the main thread until done.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param outOfSession    the out of session
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public void logEventSync(String eventType, JSONObject eventProperties, boolean outOfSession) {
        logEventSync(eventType, eventProperties, null, outOfSession);
    }

    /**
     * Log an event with the specified event type, event properties, and groups. Use this to set
     * event-level groups, meaning the group(s) set only apply for this specific event and does
     * not persist on the user.
     * <b>Note:</b> this is version is synchronous and blocks the main thread until done.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param groups          the groups
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-groups">
     *     Setting Groups</a>
     */
    public void logEventSync(String eventType, JSONObject eventProperties, JSONObject groups) {
        logEventSync(eventType, eventProperties, groups, false);
    }

    /**
     * Log event with the specified event type, event properties, groups, with optional out of
     * session flag. If out of session is true, then the sessionId will be -1 for the event,
     * indicating that it is not part of the current session. Note: this might be useful when
     * logging events for notifications received.
     * <b>Note:</b> this is version is synchronous and blocks the main thread until done.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param groups          the groups
     * @param outOfSession    the out of session
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-groups">
     *     Setting Groups</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public void logEventSync(String eventType, JSONObject eventProperties, JSONObject groups, boolean outOfSession) {
        logEventSync(eventType, eventProperties, groups, getCurrentTimeMillis(), outOfSession);
    }

    /**
     * Log event with the specified event type, event properties, groups, timestamp,  with optional
     * sout of ession flag. If out of session is true, then the sessionId will be -1 for the event,
     * indicating that it is not part of the current session. Note: this might be useful when
     * logging events for notifications received.
     * <b>Note:</b> this is version is synchronous and blocks the main thread until done.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param groups          the groups
     * @param timestamp       the timestamp in milliseconds since epoch
     * @param outOfSession    the out of session
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-groups">
     *     Setting Groups</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public void logEventSync(String eventType, JSONObject eventProperties, JSONObject groups, long timestamp, boolean outOfSession) {
        if (validateLogEvent(eventType)) {
            logEvent(eventType, eventProperties, null, null, groups, timestamp, outOfSession);
        }
    }

    /**
     * Validate the event type being logged. Also verifies that the context and API key
     * have been set already with an initialize call.
     *
     * @param eventType the event type
     * @return true if the event type is valid
     */
    protected boolean validateLogEvent(String eventType) {
        if (Utils.isEmptyString(eventType)) {
            logger.e(TAG, "Argument eventType cannot be null or blank in logEvent()");
            return false;
        }

        return contextAndApiKeySet("logEvent()");
    }

    /**
     * Log event async. Internal method to handle the synchronous logging of events.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param apiProperties   the api properties
     * @param userProperties  the user properties
     * @param groups          the groups
     * @param timestamp       the timestamp
     * @param outOfSession    the out of session
     */
    protected void logEventAsync(final String eventType, JSONObject eventProperties,
            JSONObject apiProperties, JSONObject userProperties,
            JSONObject groups, final long timestamp, final boolean outOfSession) {
        // Clone the incoming eventProperties object before sending over
        // to the log thread. Helps avoid ConcurrentModificationException
        // if the caller starts mutating the object they passed in.
        // Only does a shallow copy, so it's still possible, though unlikely,
        // to hit concurrent access if the caller mutates deep in the object.
        if (eventProperties != null) {
            eventProperties = Utils.cloneJSONObject(eventProperties);
        }

        if (apiProperties != null) {
            apiProperties = Utils.cloneJSONObject(apiProperties);
        }

        if (userProperties != null) {
            userProperties = Utils.cloneJSONObject(userProperties);
        }

        if (groups != null) {
            groups = Utils.cloneJSONObject(groups);
        }

        final JSONObject copyEventProperties = eventProperties;
        final JSONObject copyApiProperties = apiProperties;
        final JSONObject copyUserProperties = userProperties;
        final JSONObject copyGroups = groups;
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (Utils.isEmptyString(apiKey)) {  // in case initialization failed
                    return;
                }
                logEvent(
                    eventType, copyEventProperties, copyApiProperties,
                    copyUserProperties, copyGroups, timestamp, outOfSession
                );
            }
        });
    }

    /**
     * Log event. Internal method to handle the asynchronous logging of events on background
     * thread.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param apiProperties   the api properties
     * @param userProperties  the user properties
     * @param groups          the groups
     * @param timestamp       the timestamp
     * @param outOfSession    the out of session
     * @return the event ID if succeeded, else -1.
     */
    protected long logEvent(String eventType, JSONObject eventProperties, JSONObject apiProperties,
            JSONObject userProperties, JSONObject groups, long timestamp, boolean outOfSession) {
        logger.d(TAG, "Logged event to Amplitude: " + eventType);

        if (optOut) {
            return -1;
        }

        // skip session check if logging start_session or end_session events
        boolean loggingSessionEvent = trackingSessionEvents &&
                (eventType.equals(START_SESSION_EVENT) || eventType.equals(END_SESSION_EVENT));

        if (!loggingSessionEvent && !outOfSession) {
            // default case + corner case when async logEvent between onPause and onResume
            if (!inForeground){
                startNewSessionIfNeeded(timestamp);
            } else {
                refreshSessionTime(timestamp);
            }
        }

        long result = -1;
        JSONObject event = new JSONObject();
        try {
            event.put("event_type", replaceWithJSONNull(eventType));
            event.put("timestamp", timestamp);
            event.put("user_id", replaceWithJSONNull(userId));
            event.put("device_id", replaceWithJSONNull(deviceId));
            event.put("session_id", outOfSession ? -1 : sessionId);
            event.put("uuid", UUID.randomUUID().toString());
            event.put("sequence_number", getNextSequenceNumber());

            if (trackingOptions.shouldTrackVersionName()) {
                event.put("version_name", replaceWithJSONNull(deviceInfo.getVersionName()));
            }
            if (trackingOptions.shouldTrackOsName()) {
                event.put("os_name", replaceWithJSONNull(deviceInfo.getOsName()));
            }
            if (trackingOptions.shouldTrackOsVersion()) {
                event.put("os_version", replaceWithJSONNull(deviceInfo.getOsVersion()));
            }
            if (trackingOptions.shouldTrackDeviceBrand()) {
                event.put("device_brand", replaceWithJSONNull(deviceInfo.getBrand()));
            }
            if (trackingOptions.shouldTrackDeviceManufacturer()) {
                event.put("device_manufacturer", replaceWithJSONNull(deviceInfo.getManufacturer()));
            }
            if (trackingOptions.shouldTrackDeviceModel()) {
                event.put("device_model", replaceWithJSONNull(deviceInfo.getModel()));
            }
            if (trackingOptions.shouldTrackCarrier()) {
                event.put("carrier", replaceWithJSONNull(deviceInfo.getCarrier()));
            }
            if (trackingOptions.shouldTrackCountry()) {
                event.put("country", replaceWithJSONNull(deviceInfo.getCountry()));
            }
            if (trackingOptions.shouldTrackLanguage()) {
                event.put("language", replaceWithJSONNull(deviceInfo.getLanguage()));
            }
            if (trackingOptions.shouldTrackPlatform()) {
                event.put("platform", platform);
            }

            JSONObject library = new JSONObject();
            library.put("name", Constants.LIBRARY);
            library.put("version", Constants.VERSION);
            event.put("library", library);

            apiProperties = (apiProperties == null) ? new JSONObject() : apiProperties;
            if (apiPropertiesTrackingOptions.length() > 0) {
                apiProperties.put("tracking_options", apiPropertiesTrackingOptions);
            }

            if (trackingOptions.shouldTrackLatLng()) {
                Location location = deviceInfo.getMostRecentLocation();
                if (location != null) {
                    JSONObject locationJSON = new JSONObject();
                    locationJSON.put("lat", location.getLatitude());
                    locationJSON.put("lng", location.getLongitude());
                    apiProperties.put("location", locationJSON);
                }
            }
            if (trackingOptions.shouldTrackAdid() && deviceInfo.getAdvertisingId() != null) {
                apiProperties.put("androidADID", deviceInfo.getAdvertisingId());
            }
            apiProperties.put("limit_ad_tracking", deviceInfo.isLimitAdTrackingEnabled());
            apiProperties.put("gps_enabled", deviceInfo.isGooglePlayServicesEnabled());

            event.put("api_properties", apiProperties);
            event.put("event_properties", (eventProperties == null) ? new JSONObject()
                : truncate(eventProperties));
            event.put("user_properties", (userProperties == null) ? new JSONObject()
                : truncate(userProperties));
            event.put("groups", (groups == null) ? new JSONObject() : truncate(groups));

            result = saveEvent(eventType, event);
        } catch (JSONException e) {
            logger.e(TAG, String.format(
                "JSON Serialization of event type %s failed, skipping: %s", eventType, e.toString()
            ));
        }

        return result;
    }

    /**
     * Save event long. Internal method to save an event to the database.
     *
     * @param eventType the event type
     * @param event     the event
     * @return the event ID if succeeded, else -1
     */
    protected long saveEvent(String eventType, JSONObject event) {
        String eventString = event.toString();
        if (Utils.isEmptyString(eventString)) {
            logger.e(TAG, String.format(
                "Detected empty event string for event type %s, skipping", eventType
            ));
            return -1;
        }

        if (eventType.equals(Constants.IDENTIFY_EVENT)) {
            lastIdentifyId = dbHelper.addIdentify(eventString);
            setLastIdentifyId(lastIdentifyId);
        } else {
            lastEventId = dbHelper.addEvent(eventString);
            setLastEventId(lastEventId);
        }

        int numEventsToRemove = Math.min(
                Math.max(1, eventMaxCount/10),
                Constants.EVENT_REMOVE_BATCH_SIZE
        );
        if (dbHelper.getEventCount() > eventMaxCount) {
            dbHelper.removeEvents(dbHelper.getNthEventId(numEventsToRemove));
        }
        if (dbHelper.getIdentifyCount() > eventMaxCount) {
            dbHelper.removeIdentifys(dbHelper.getNthIdentifyId(numEventsToRemove));
        }

        long totalEventCount = dbHelper.getTotalEventCount(); // counts may have changed, refetch
        if ((totalEventCount % eventUploadThreshold) == 0 &&
                totalEventCount >= eventUploadThreshold) {
            updateServer();
        } else {
            updateServerLater(eventUploadPeriodMillis);
        }

        return eventType.equals(Constants.IDENTIFY_EVENT) ? lastIdentifyId : lastEventId;
    }

    // fetches key from dbHelper longValueStore
    // if key does not exist, return defaultValue instead
    private long getLongvalue(String key, long defaultValue) {
        Long value = dbHelper.getLongValue(key);
        return value == null ? defaultValue : value;
    }

    /**
     * Internal method to increment and fetch the next event sequence number.
     *
     * @return the next sequence number
     */
    long getNextSequenceNumber() {
        sequenceNumber++;
        dbHelper.insertOrReplaceKeyLongValue(SEQUENCE_NUMBER_KEY, sequenceNumber);
        return sequenceNumber;
    }

    /**
     * Internal method to set the last event time.
     *
     * @param timestamp the timestamp
     */
    void setLastEventTime(long timestamp) {
        lastEventTime = timestamp;
        dbHelper.insertOrReplaceKeyLongValue(LAST_EVENT_TIME_KEY, timestamp);
    }

    /**
     * Internal method to set the last event id.
     *
     * @param eventId the event id
     */
    void setLastEventId(long eventId) {
        lastEventId = eventId;
        dbHelper.insertOrReplaceKeyLongValue(LAST_EVENT_ID_KEY, eventId);
    }

    /**
     * Internal method to set the last identify id.
     *
     * @param identifyId the identify id
     */
    void setLastIdentifyId(long identifyId) {
        lastIdentifyId = identifyId;
        dbHelper.insertOrReplaceKeyLongValue(LAST_IDENTIFY_ID_KEY, identifyId);
    }

    /**
     * Gets the current session id.
     *
     * @return The current sessionId value.
     */
    public long getSessionId() {
        return sessionId;
    }

    /**
     * Internal method to set the previous session id.
     *
     * @param timestamp the timestamp
     */
    void setPreviousSessionId(long timestamp) {
        previousSessionId = timestamp;
        dbHelper.insertOrReplaceKeyLongValue(PREVIOUS_SESSION_ID_KEY, timestamp);
    }

    /**
     * Internal method to start a new session if needed.
     *
     * @param timestamp the timestamp
     * @return whether or not a new session was started
     */
    boolean startNewSessionIfNeeded(long timestamp) {
        if (inSession()) {

            if (isWithinMinTimeBetweenSessions(timestamp)) {
                refreshSessionTime(timestamp);
                return false;
            }

            startNewSession(timestamp);
            return true;
        }

        // no current session - check for previous session
        if (isWithinMinTimeBetweenSessions(timestamp)) {
            if (previousSessionId == -1) {
                startNewSession(timestamp);
                return true;
            }

            // extend previous session
            setSessionId(previousSessionId);
            refreshSessionTime(timestamp);
            return false;
        }

        startNewSession(timestamp);
        return true;
    }

    private void startNewSession(long timestamp) {
        // end previous session
        if (trackingSessionEvents) {
            sendSessionEvent(END_SESSION_EVENT);
        }

        // start new session
        setSessionId(timestamp);
        refreshSessionTime(timestamp);
        if (trackingSessionEvents) {
            sendSessionEvent(START_SESSION_EVENT);
        }
    }

    private boolean inSession() {
        return sessionId >= 0;
    }

    private boolean isWithinMinTimeBetweenSessions(long timestamp) {
        long sessionLimit = usingForegroundTracking ?
                minTimeBetweenSessionsMillis : sessionTimeoutMillis;
        return (timestamp - lastEventTime) < sessionLimit;
    }

    private void setSessionId(long timestamp) {
        sessionId = timestamp;
        setPreviousSessionId(timestamp);
    }

    /**
     * Internal method to refresh the current session time.
     *
     * @param timestamp the timestamp
     */
    void refreshSessionTime(long timestamp) {
        if (!inSession()) {
            return;
        }

        setLastEventTime(timestamp);
    }

    private void sendSessionEvent(final String sessionEvent) {
        if (!contextAndApiKeySet(String.format("sendSessionEvent('%s')", sessionEvent))) {
            return;
        }

        if (!inSession()) {
            return;
        }

        JSONObject apiProperties = new JSONObject();
        try {
            apiProperties.put("special", sessionEvent);
        } catch (JSONException e) {
            return;
        }

        logEvent(sessionEvent, null, apiProperties, null, null, lastEventTime, false);
    }

    /**
     * Internal method to handle on app exit foreground behavior.
     *
     * @param timestamp the timestamp
     */
    void onExitForeground(final long timestamp) {
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (Utils.isEmptyString(apiKey)) {
                    return;
                }
                refreshSessionTime(timestamp);
                inForeground = false;
                if (flushEventsOnClose) {
                    updateServer();
                }
            }
        });
    }

    /**
     * Internal method to handle on app enter foreground behavior.
     *
     * @param timestamp the timestamp
     */
    void onEnterForeground(final long timestamp) {
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (Utils.isEmptyString(apiKey)) {
                    return;
                }
                startNewSessionIfNeeded(timestamp);
                inForeground = true;<commit_msg>re-persist metadata on app close<commit_after>package com.amplitude.api;

import android.app.Application;
import android.content.Context;
import android.content.SharedPreferences;
import android.location.Location;
import android.os.Build;
import android.util.Pair;

import com.amplitude.security.MD5;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;

import okhttp3.FormBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

/**
 * <h1>AmplitudeClient</h1>
 * This is the SDK instance class that contains all of the SDK functionality.<br><br>
 * <b>Note:</b> call the methods on the default shared instance in the Amplitude class,
 * for example: {@code Amplitude.getInstance().logEvent();}<br><br>
 * Many of the SDK functions return the SDK instance back, allowing you to chain multiple method
 * calls together, for example: {@code Amplitude.getInstance().initialize(this, "APIKEY").enableForegroundTracking(getApplication())}
 */
public class AmplitudeClient {

    /**
     * The class identifier tag used in logging. TAG = {@code "com.amplitude.api.AmplitudeClient";}
     */
    public static final String TAG = "com.amplitude.api.AmplitudeClient";

    /**
     * The event type for start session events.
     */
    public static final String START_SESSION_EVENT = "session_start";
    /**
     * The event type for end session events.
     */
    public static final String END_SESSION_EVENT = "session_end";

    /**
     * The pref/database key for the device ID value.
     */
    public static final String DEVICE_ID_KEY = "device_id";
    /**
     * The pref/database key for the user ID value.
     */
    public static final String USER_ID_KEY = "user_id";
    /**
     * The pref/database key for the opt out flag.
     */
    public static final String OPT_OUT_KEY = "opt_out";
    /**
     * The pref/database key for the sequence number.
     */
    public static final String SEQUENCE_NUMBER_KEY = "sequence_number";
    /**
     * The pref/database key for the last event time.
     */
    public static final String LAST_EVENT_TIME_KEY = "last_event_time";
    /**
     * The pref/database key for the last event ID value.
     */
    public static final String LAST_EVENT_ID_KEY = "last_event_id";
    /**
     * The pref/database key for the last identify ID value.
     */
    public static final String LAST_IDENTIFY_ID_KEY = "last_identify_id";
    /**
     * The pref/database key for the previous session ID value.
     */
    public static final String PREVIOUS_SESSION_ID_KEY = "previous_session_id";

    private static final AmplitudeLog logger = AmplitudeLog.getLogger();

    /**
     * The Android App Context.
     */
    protected Context context;
    /**
     * The shared OkHTTPClient instance.
     */
    protected OkHttpClient httpClient;
    /**
     * The shared Amplitude database helper instance.
     */
    protected DatabaseHelper dbHelper;
    /**
     * The Amplitude App API key.
     */
    protected String apiKey;
    /**
     * The name for this instance of AmplitudeClient.
     */
    protected String instanceName;
    /**
     * The user's ID value.
     */
    protected String userId;
    /**
     * The user's Device ID value.
     */
    protected String deviceId;
    private boolean newDeviceIdPerInstall = false;
    private boolean useAdvertisingIdForDeviceId = false;
    protected boolean initialized = false;
    private boolean optOut = false;
    private boolean offline = false;
    TrackingOptions trackingOptions = new TrackingOptions();
    JSONObject apiPropertiesTrackingOptions = new JSONObject();
    /**
     * The device's Platform value.
     */
    protected String platform;

    /**
     * Event metadata
     */
    long sessionId = -1;
    long sequenceNumber = 0;
    long lastEventId = -1;
    long lastIdentifyId = -1;
    long lastEventTime = -1;
    long previousSessionId = -1;

    private DeviceInfo deviceInfo;

    /**
     * The current session ID value.
     */
    private int eventUploadThreshold = Constants.EVENT_UPLOAD_THRESHOLD;
    private int eventUploadMaxBatchSize = Constants.EVENT_UPLOAD_MAX_BATCH_SIZE;
    private int eventMaxCount = Constants.EVENT_MAX_COUNT;
    private long eventUploadPeriodMillis = Constants.EVENT_UPLOAD_PERIOD_MILLIS;
    private long minTimeBetweenSessionsMillis = Constants.MIN_TIME_BETWEEN_SESSIONS_MILLIS;
    private long sessionTimeoutMillis = Constants.SESSION_TIMEOUT_MILLIS;
    private boolean backoffUpload = false;
    private int backoffUploadBatchSize = eventUploadMaxBatchSize;
    private boolean usingForegroundTracking = false;
    private boolean trackingSessionEvents = false;
    private boolean inForeground = false;
    private boolean flushEventsOnClose = true;

    private AtomicBoolean updateScheduled = new AtomicBoolean(false);
    /**
     * Whether or not the SDK is in the process of uploading events.
     */
    AtomicBoolean uploadingCurrently = new AtomicBoolean(false);

    /**
     * The last SDK error - used for testing.
     */
    Throwable lastError;
    /**
     * The url for Amplitude API endpoint
     */
    String url = Constants.EVENT_LOG_URL;
    /**
     * The background event logging worker thread instance.
     */
    WorkerThread logThread = new WorkerThread("logThread");
    /**
     * The background event uploading worker thread instance.
     */
    WorkerThread httpThread = new WorkerThread("httpThread");

    /**
     * Instantiates a new default instance AmplitudeClient and starts worker threads.
     */
    public AmplitudeClient() {
        this(null);
    }

    /**
     * Instantiates a new AmplitudeClient with instance name and starts worker threads.
     * @param instance
     */
    public AmplitudeClient(String instance) {
        this.instanceName = Utils.normalizeInstanceName(instance);
        logThread.start();
        httpThread.start();
    }

    /**
     * Initialize the Amplitude SDK with the Android application context and your Amplitude
     * App API key. <b>Note:</b> initialization is required before you log events and modify
     * user properties.
     *
     * @param context the Android application context
     * @param apiKey  your Amplitude App API key
     * @return the AmplitudeClient
     */
    public AmplitudeClient initialize(Context context, String apiKey) {
        return initialize(context, apiKey, null);
    }

    /**
     * Initialize the Amplitude SDK with the Android application context, your Amplitude App API
     * key, and a user ID for the current user. <b>Note:</b> initialization is required before
     * you log events and modify user properties.
     *
     * @param context the Android application context
     * @param apiKey  your Amplitude App API key
     * @param userId  the user id to set
     * @return the AmplitudeClient
     */
    public AmplitudeClient initialize(Context context, String apiKey, String userId) {
        return initialize(context, apiKey, userId, null);
    }

    /**
     * Initialize the Amplitude SDK with the Android application context, your Amplitude App API
     * key, a user ID for the current user, and a custom platform value.
     * <b>Note:</b> initialization is required before you log events and modify user properties.
     *
     * @param context the Android application context
     * @param apiKey  your Amplitude App API key
     * @param userId  the user id to set
     * @param
     * @return the AmplitudeClient
     */
    public synchronized AmplitudeClient initialize(final Context context, final String apiKey, final String userId, final String platform) {
        if (context == null) {
            logger.e(TAG, "Argument context cannot be null in initialize()");
            return this;
        }

        if (Utils.isEmptyString(apiKey)) {
            logger.e(TAG, "Argument apiKey cannot be null or blank in initialize()");
            return this;
        }

        this.context = context.getApplicationContext();
        this.apiKey = apiKey;
        this.dbHelper = DatabaseHelper.getDatabaseHelper(this.context, this.instanceName);
        this.platform = Utils.isEmptyString(platform) ? Constants.PLATFORM : platform;

        final AmplitudeClient client = this;
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (!initialized) {
                    // this try block is idempotent, so it's safe to retry initialize if failed
                    try {
                        if (instanceName.equals(Constants.DEFAULT_INSTANCE)) {
                            AmplitudeClient.upgradePrefs(context);
                            AmplitudeClient.upgradeSharedPrefsToDB(context);
                        }
                        httpClient = new OkHttpClient();
                        initializeDeviceInfo();

                        if (userId != null) {
                            client.userId = userId;
                            dbHelper.insertOrReplaceKeyValue(USER_ID_KEY, userId);
                        } else {
                            client.userId = dbHelper.getValue(USER_ID_KEY);
                        }
                        final Long optOutLong = dbHelper.getLongValue(OPT_OUT_KEY);
                        optOut = optOutLong != null && optOutLong == 1;

                        // try to restore previous session id
                        previousSessionId = getLongvalue(PREVIOUS_SESSION_ID_KEY, -1);
                        if (previousSessionId >= 0) {
                            sessionId = previousSessionId;
                        }

                        // reload event meta data
                        sequenceNumber = getLongvalue(SEQUENCE_NUMBER_KEY, 0);
                        lastEventId = getLongvalue(LAST_EVENT_ID_KEY, -1);
                        lastIdentifyId = getLongvalue(LAST_IDENTIFY_ID_KEY, -1);
                        lastEventTime = getLongvalue(LAST_EVENT_TIME_KEY, -1);

                        // install database reset listener to re-insert metadata in memory
                        dbHelper.setDatabaseResetListener(new DatabaseResetListener() {
                            @Override
                            public void onDatabaseReset() {
                                dbHelper.insertOrReplaceKeyValue(DEVICE_ID_KEY, deviceId);
                                dbHelper.insertOrReplaceKeyValue(USER_ID_KEY, client.userId);
                                dbHelper.insertOrReplaceKeyLongValue(OPT_OUT_KEY, optOut ? 1L : 0L);
                                dbHelper.insertOrReplaceKeyLongValue(PREVIOUS_SESSION_ID_KEY, sessionId);
                                dbHelper.insertOrReplaceKeyLongValue(LAST_EVENT_TIME_KEY, lastEventTime);
                            }
                        });

                        initialized = true;

                    } catch (CursorWindowAllocationException e) {  // treat as uninitialized SDK
                        logger.e(TAG, String.format(
                           "Failed to initialize Amplitude SDK due to: %s", e.getMessage()
                        ));
                        client.apiKey = null;
                    }
                }
            }
        });

        return this;
    }

    /**
     * Enable foreground tracking for the SDK. This is <b>HIGHLY RECOMMENDED</b>, and will allow
     * for accurate session tracking.
     *
     * @param app the Android application
     * @return the AmplitudeClient
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public AmplitudeClient enableForegroundTracking(Application app) {
        if (usingForegroundTracking || !contextAndApiKeySet("enableForegroundTracking()")) {
            return this;
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
            app.registerActivityLifecycleCallbacks(new AmplitudeCallbacks(this));
        }

        return this;
    }

    // this method should only be called from the background log thread
    private void initializeDeviceInfo() {
        deviceInfo = new DeviceInfo(context);
        deviceId = initializeDeviceId();
        deviceInfo.prefetch();
    }

    /**
     * Whether to set a new device ID per install. If true, then the SDK will always generate a new
     * device ID on app install (as opposed to re-using an existing value like ADID).
     *
     * @param newDeviceIdPerInstall whether to set a new device ID on app install.
     * @return the AmplitudeClient
     * @deprecated
     */
    public AmplitudeClient enableNewDeviceIdPerInstall(boolean newDeviceIdPerInstall) {
        this.newDeviceIdPerInstall = newDeviceIdPerInstall;
        return this;
    }

    /**
     * Whether to use the Android advertising ID (ADID) as the user's device ID.
     *
     * @return the AmplitudeClient
     */
    public AmplitudeClient useAdvertisingIdForDeviceId() {
        this.useAdvertisingIdForDeviceId = true;
        return this;
    }

    /**
     * Enable location listening in the SDK. This will add the user's current lat/lon coordinates
     * to every event logged.
     *
     * @return the AmplitudeClient
     */
    public AmplitudeClient enableLocationListening() {
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (deviceInfo == null) {
		    throw new IllegalStateException(
		            "Must initialize before acting on location listening.");
                }
                deviceInfo.setLocationListening(true);
            }
        });
        return this;
    }

    /**
     * Disable location listening in the SDK. This will stop the sending of the user's current
     * lat/lon coordinates.
     *
     * @return the AmplitudeClient
     */
    public AmplitudeClient disableLocationListening() {
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (deviceInfo == null) {
		    throw new IllegalStateException(
		            "Must initialize before acting on location listening.");
                }
                deviceInfo.setLocationListening(false);
            }
        });
        return this;
    }

    /**
     * Sets event upload threshold. The SDK will attempt to batch upload unsent events
     * every eventUploadPeriodMillis milliseconds, or if the unsent event count exceeds the
     * event upload threshold.
     *
     * @param eventUploadThreshold the event upload threshold
     * @return the AmplitudeClient
     */
    public AmplitudeClient setEventUploadThreshold(int eventUploadThreshold) {
        this.eventUploadThreshold = eventUploadThreshold;
        return this;
    }

    /**
     * Sets event upload max batch size. This controls the maximum number of events sent with
     * each upload request.
     *
     * @param eventUploadMaxBatchSize the event upload max batch size
     * @return the AmplitudeClient
     */
    public AmplitudeClient setEventUploadMaxBatchSize(int eventUploadMaxBatchSize) {
        this.eventUploadMaxBatchSize = eventUploadMaxBatchSize;
        this.backoffUploadBatchSize = eventUploadMaxBatchSize;
        return this;
    }

    /**
     * Sets event max count. This is the maximum number of unsent events to keep on the device
     * (for example if the device does not have internet connectivity and cannot upload events).
     * If the number of unsent events exceeds the max count, then the SDK begins dropping events,
     * starting from the earliest logged.
     *
     * @param eventMaxCount the event max count
     * @return the AmplitudeClient
     */
    public AmplitudeClient setEventMaxCount(int eventMaxCount) {
        this.eventMaxCount = eventMaxCount;
        return this;
    }

    /**
     * Sets event upload period millis. The SDK will attempt to batch upload unsent events
     * every eventUploadPeriodMillis milliseconds, or if the unsent event count exceeds the
     * event upload threshold.
     *
     * @param eventUploadPeriodMillis the event upload period millis
     * @return the AmplitudeClient
     */
    public AmplitudeClient setEventUploadPeriodMillis(int eventUploadPeriodMillis) {
        this.eventUploadPeriodMillis = eventUploadPeriodMillis;
        return this;
    }

    /**
     * Sets min time between sessions millis.
     *
     * @param minTimeBetweenSessionsMillis the min time between sessions millis
     * @return the min time between sessions millis
     */
    public AmplitudeClient setMinTimeBetweenSessionsMillis(long minTimeBetweenSessionsMillis) {
        this.minTimeBetweenSessionsMillis = minTimeBetweenSessionsMillis;
        return this;
    }

    /**
     * Sets session timeout millis. If foreground tracking has not been enabled with
     * @{code enableForegroundTracking()}, then new sessions will be started after
     * sessionTimeoutMillis milliseconds have passed since the last event logged.
     *
     * @param sessionTimeoutMillis the session timeout millis
     * @return the AmplitudeClient
     */
    public AmplitudeClient setSessionTimeoutMillis(long sessionTimeoutMillis) {
        this.sessionTimeoutMillis = sessionTimeoutMillis;
        return this;
    }

    public AmplitudeClient setTrackingOptions(TrackingOptions trackingOptions) {
        this.trackingOptions = trackingOptions;
        this.apiPropertiesTrackingOptions = trackingOptions.getApiPropertiesTrackingOptions();
        return this;
    }

    /**
     * Sets opt out. If true then the SDK does not track any events for the user.
     *
     * @param optOut whether or not to opt the user out of tracking
     * @return the AmplitudeClient
     */
    public AmplitudeClient setOptOut(final boolean optOut) {
        if (!contextAndApiKeySet("setOptOut()")) {
            return this;
        }

        final AmplitudeClient client = this;
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (Utils.isEmptyString(apiKey)) { // in case initialization failed
                    return;
                }
                client.optOut = optOut;
                dbHelper.insertOrReplaceKeyLongValue(OPT_OUT_KEY, optOut ? 1L : 0L);
            }
        });
        return this;
    }

    /**
     * Returns whether or not the user is opted out of tracking.
     *
     * @return the optOut flag value
     */
    public boolean isOptedOut() {
        return optOut;
    }

    /**
     * Enable/disable message logging by the SDK.
     *
     * @param enableLogging whether to enable message logging by the SDK.
     * @return the AmplitudeClient
     */
    public AmplitudeClient enableLogging(boolean enableLogging) {
        logger.setEnableLogging(enableLogging);
        return this;
    }

    /**
     * Sets the logging level. Logging messages will only appear if they are the same severity
     * level or higher than the set log level.
     *
     * @param logLevel the log level
     * @return the AmplitudeClient
     */
    public AmplitudeClient setLogLevel(int logLevel) {
        logger.setLogLevel(logLevel);
        return this;
    }

    /**
     * Sets offline. If offline is true, then the SDK will not upload events to Amplitude servers;
     * however, it will still log events.
     *
     * @param offline whether or not the SDK should be offline
     * @return the AmplitudeClient
     */
    public AmplitudeClient setOffline(boolean offline) {
        this.offline = offline;

        // Try to update to the server once offline mode is disabled.
        if (!offline) {
            uploadEvents();
        }

        return this;
    }

    /**
     * Enable/disable flushing of unsent events on app close (enabled by default).
     *
     * @param flushEventsOnClose whether to flush unsent events on app close
     * @return the AmplitudeClient
     */
    public AmplitudeClient setFlushEventsOnClose(boolean flushEventsOnClose) {
        this.flushEventsOnClose = flushEventsOnClose;
        return this;
    }

    /**
     * Track session events amplitude client. If enabled then the SDK will automatically send
     * start and end session events to mark the start and end of the user's sessions.
     *
     * @param trackingSessionEvents whether to enable tracking of session events
     * @return the AmplitudeClient
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public AmplitudeClient trackSessionEvents(boolean trackingSessionEvents) {
        this.trackingSessionEvents = trackingSessionEvents;
        return this;
    }

    /**
     * Set foreground tracking to true.
     */
    void useForegroundTracking() {
        usingForegroundTracking = true;
    }

    /**
     * Whether foreground tracking is enabled.
     *
     * @return whether foreground tracking is enabled
     */
    boolean isUsingForegroundTracking() { return usingForegroundTracking; }

    /**
     * Whether app is in the foreground.
     *
     * @return whether app is in the foreground
     */
    boolean isInForeground() { return inForeground; }

    /**
     * Log an event with the specified event type.
     * <b>Note:</b> this is asynchronous and happens on a background thread.
     *
     * @param eventType the event type
     */
    public void logEvent(String eventType) {
        logEvent(eventType, null);
    }

    /**
     * Log an event with the specified event type and event properties.
     * <b>Note:</b> this is asynchronous and happens on a background thread.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     */
    public void logEvent(String eventType, JSONObject eventProperties) {
        logEvent(eventType, eventProperties, false);
    }

    /**
     * Log an event with the specified event type, event properties, with optional out of session
     * flag. If out of session is true, then the sessionId will be -1 for the event, indicating
     * that it is not part of the current session. Note: this might be useful when logging events
     * for notifications received.
     * <b>Note:</b> this is asynchronous and happens on a background thread.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param outOfSession    the out of session
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public void logEvent(String eventType, JSONObject eventProperties, boolean outOfSession) {
        logEvent(eventType, eventProperties, null, outOfSession);
    }

    /**
     * Log an event with the specified event type, event properties, and groups. Use this to set
     * event-level groups, meaning the group(s) set only apply for this specific event and does
     * not persist on the user.
     * <b>Note:</b> this is asynchronous and happens on a background thread.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param groups          the groups
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-groups">
     *     Setting Groups</a>
     */
    public void logEvent(String eventType, JSONObject eventProperties, JSONObject groups) {
        logEvent(eventType, eventProperties, groups, false);
    }

    /**
     * Log event with the specified event type, event properties, groups, with optional out of
     * session flag. If out of session is true, then the sessionId will be -1 for the event,
     * indicating that it is not part of the current session. Note: this might be useful when
     * logging events for notifications received.
     * <b>Note:</b> this is asynchronous and happens on a background thread.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param groups          the groups
     * @param outOfSession    the out of session
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-groups">
     *     Setting Groups</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public void logEvent(String eventType, JSONObject eventProperties, JSONObject groups, boolean outOfSession) {
        logEvent(eventType, eventProperties, groups, getCurrentTimeMillis(), outOfSession);
    }

    /**
     * Log event with the specified event type, event properties, groups, timestamp, with optional
     * out of session flag. If out of session is true, then the sessionId will be -1 for the event,
     * indicating that it is not part of the current session. Note: this might be useful when
     * logging events for notifications received.
     * <b>Note:</b> this is asynchronous and happens on a background thread.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param groups          the groups
     * @param timestamp       the timestamp in millisecond since epoch
     * @param outOfSession    the out of session
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-groups">
     *     Setting Groups</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public void logEvent(String eventType, JSONObject eventProperties, JSONObject groups, long timestamp, boolean outOfSession) {
        if (validateLogEvent(eventType)) {
            logEventAsync(
                eventType, eventProperties, null, null, groups, timestamp, outOfSession
            );
        }
    }

    /**
     * Log an event with the specified event type.
     * <b>Note:</b> this is version is synchronous and blocks the main thread until done.
     *
     * @param eventType the event type
     */
    public void logEventSync(String eventType) {
        logEventSync(eventType, null);
    }

    /**
     * Log an event with the specified event type and event properties.
     * <b>Note:</b> this is version is synchronous and blocks the main thread until done.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     */
    public void logEventSync(String eventType, JSONObject eventProperties) {
        logEventSync(eventType, eventProperties, false);
    }

    /**
     * Log an event with the specified event type, event properties, with optional out of session
     * flag. If out of session is true, then the sessionId will be -1 for the event, indicating
     * that it is not part of the current session. Note: this might be useful when logging events
     * for notifications received.
     * <b>Note:</b> this is version is synchronous and blocks the main thread until done.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param outOfSession    the out of session
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public void logEventSync(String eventType, JSONObject eventProperties, boolean outOfSession) {
        logEventSync(eventType, eventProperties, null, outOfSession);
    }

    /**
     * Log an event with the specified event type, event properties, and groups. Use this to set
     * event-level groups, meaning the group(s) set only apply for this specific event and does
     * not persist on the user.
     * <b>Note:</b> this is version is synchronous and blocks the main thread until done.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param groups          the groups
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-groups">
     *     Setting Groups</a>
     */
    public void logEventSync(String eventType, JSONObject eventProperties, JSONObject groups) {
        logEventSync(eventType, eventProperties, groups, false);
    }

    /**
     * Log event with the specified event type, event properties, groups, with optional out of
     * session flag. If out of session is true, then the sessionId will be -1 for the event,
     * indicating that it is not part of the current session. Note: this might be useful when
     * logging events for notifications received.
     * <b>Note:</b> this is version is synchronous and blocks the main thread until done.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param groups          the groups
     * @param outOfSession    the out of session
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-groups">
     *     Setting Groups</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public void logEventSync(String eventType, JSONObject eventProperties, JSONObject groups, boolean outOfSession) {
        logEventSync(eventType, eventProperties, groups, getCurrentTimeMillis(), outOfSession);
    }

    /**
     * Log event with the specified event type, event properties, groups, timestamp,  with optional
     * sout of ession flag. If out of session is true, then the sessionId will be -1 for the event,
     * indicating that it is not part of the current session. Note: this might be useful when
     * logging events for notifications received.
     * <b>Note:</b> this is version is synchronous and blocks the main thread until done.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param groups          the groups
     * @param timestamp       the timestamp in milliseconds since epoch
     * @param outOfSession    the out of session
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-event-properties">
     *     Setting Event Properties</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#setting-groups">
     *     Setting Groups</a>
     * @see <a href="https://github.com/amplitude/Amplitude-Android#tracking-sessions">
     *     Tracking Sessions</a>
     */
    public void logEventSync(String eventType, JSONObject eventProperties, JSONObject groups, long timestamp, boolean outOfSession) {
        if (validateLogEvent(eventType)) {
            logEvent(eventType, eventProperties, null, null, groups, timestamp, outOfSession);
        }
    }

    /**
     * Validate the event type being logged. Also verifies that the context and API key
     * have been set already with an initialize call.
     *
     * @param eventType the event type
     * @return true if the event type is valid
     */
    protected boolean validateLogEvent(String eventType) {
        if (Utils.isEmptyString(eventType)) {
            logger.e(TAG, "Argument eventType cannot be null or blank in logEvent()");
            return false;
        }

        return contextAndApiKeySet("logEvent()");
    }

    /**
     * Log event async. Internal method to handle the synchronous logging of events.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param apiProperties   the api properties
     * @param userProperties  the user properties
     * @param groups          the groups
     * @param timestamp       the timestamp
     * @param outOfSession    the out of session
     */
    protected void logEventAsync(final String eventType, JSONObject eventProperties,
            JSONObject apiProperties, JSONObject userProperties,
            JSONObject groups, final long timestamp, final boolean outOfSession) {
        // Clone the incoming eventProperties object before sending over
        // to the log thread. Helps avoid ConcurrentModificationException
        // if the caller starts mutating the object they passed in.
        // Only does a shallow copy, so it's still possible, though unlikely,
        // to hit concurrent access if the caller mutates deep in the object.
        if (eventProperties != null) {
            eventProperties = Utils.cloneJSONObject(eventProperties);
        }

        if (apiProperties != null) {
            apiProperties = Utils.cloneJSONObject(apiProperties);
        }

        if (userProperties != null) {
            userProperties = Utils.cloneJSONObject(userProperties);
        }

        if (groups != null) {
            groups = Utils.cloneJSONObject(groups);
        }

        final JSONObject copyEventProperties = eventProperties;
        final JSONObject copyApiProperties = apiProperties;
        final JSONObject copyUserProperties = userProperties;
        final JSONObject copyGroups = groups;
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (Utils.isEmptyString(apiKey)) {  // in case initialization failed
                    return;
                }
                logEvent(
                    eventType, copyEventProperties, copyApiProperties,
                    copyUserProperties, copyGroups, timestamp, outOfSession
                );
            }
        });
    }

    /**
     * Log event. Internal method to handle the asynchronous logging of events on background
     * thread.
     *
     * @param eventType       the event type
     * @param eventProperties the event properties
     * @param apiProperties   the api properties
     * @param userProperties  the user properties
     * @param groups          the groups
     * @param timestamp       the timestamp
     * @param outOfSession    the out of session
     * @return the event ID if succeeded, else -1.
     */
    protected long logEvent(String eventType, JSONObject eventProperties, JSONObject apiProperties,
            JSONObject userProperties, JSONObject groups, long timestamp, boolean outOfSession) {
        logger.d(TAG, "Logged event to Amplitude: " + eventType);

        if (optOut) {
            return -1;
        }

        // skip session check if logging start_session or end_session events
        boolean loggingSessionEvent = trackingSessionEvents &&
                (eventType.equals(START_SESSION_EVENT) || eventType.equals(END_SESSION_EVENT));

        if (!loggingSessionEvent && !outOfSession) {
            // default case + corner case when async logEvent between onPause and onResume
            if (!inForeground){
                startNewSessionIfNeeded(timestamp);
            } else {
                refreshSessionTime(timestamp);
            }
        }

        long result = -1;
        JSONObject event = new JSONObject();
        try {
            event.put("event_type", replaceWithJSONNull(eventType));
            event.put("timestamp", timestamp);
            event.put("user_id", replaceWithJSONNull(userId));
            event.put("device_id", replaceWithJSONNull(deviceId));
            event.put("session_id", outOfSession ? -1 : sessionId);
            event.put("uuid", UUID.randomUUID().toString());
            event.put("sequence_number", getNextSequenceNumber());

            if (trackingOptions.shouldTrackVersionName()) {
                event.put("version_name", replaceWithJSONNull(deviceInfo.getVersionName()));
            }
            if (trackingOptions.shouldTrackOsName()) {
                event.put("os_name", replaceWithJSONNull(deviceInfo.getOsName()));
            }
            if (trackingOptions.shouldTrackOsVersion()) {
                event.put("os_version", replaceWithJSONNull(deviceInfo.getOsVersion()));
            }
            if (trackingOptions.shouldTrackDeviceBrand()) {
                event.put("device_brand", replaceWithJSONNull(deviceInfo.getBrand()));
            }
            if (trackingOptions.shouldTrackDeviceManufacturer()) {
                event.put("device_manufacturer", replaceWithJSONNull(deviceInfo.getManufacturer()));
            }
            if (trackingOptions.shouldTrackDeviceModel()) {
                event.put("device_model", replaceWithJSONNull(deviceInfo.getModel()));
            }
            if (trackingOptions.shouldTrackCarrier()) {
                event.put("carrier", replaceWithJSONNull(deviceInfo.getCarrier()));
            }
            if (trackingOptions.shouldTrackCountry()) {
                event.put("country", replaceWithJSONNull(deviceInfo.getCountry()));
            }
            if (trackingOptions.shouldTrackLanguage()) {
                event.put("language", replaceWithJSONNull(deviceInfo.getLanguage()));
            }
            if (trackingOptions.shouldTrackPlatform()) {
                event.put("platform", platform);
            }

            JSONObject library = new JSONObject();
            library.put("name", Constants.LIBRARY);
            library.put("version", Constants.VERSION);
            event.put("library", library);

            apiProperties = (apiProperties == null) ? new JSONObject() : apiProperties;
            if (apiPropertiesTrackingOptions.length() > 0) {
                apiProperties.put("tracking_options", apiPropertiesTrackingOptions);
            }

            if (trackingOptions.shouldTrackLatLng()) {
                Location location = deviceInfo.getMostRecentLocation();
                if (location != null) {
                    JSONObject locationJSON = new JSONObject();
                    locationJSON.put("lat", location.getLatitude());
                    locationJSON.put("lng", location.getLongitude());
                    apiProperties.put("location", locationJSON);
                }
            }
            if (trackingOptions.shouldTrackAdid() && deviceInfo.getAdvertisingId() != null) {
                apiProperties.put("androidADID", deviceInfo.getAdvertisingId());
            }
            apiProperties.put("limit_ad_tracking", deviceInfo.isLimitAdTrackingEnabled());
            apiProperties.put("gps_enabled", deviceInfo.isGooglePlayServicesEnabled());

            event.put("api_properties", apiProperties);
            event.put("event_properties", (eventProperties == null) ? new JSONObject()
                : truncate(eventProperties));
            event.put("user_properties", (userProperties == null) ? new JSONObject()
                : truncate(userProperties));
            event.put("groups", (groups == null) ? new JSONObject() : truncate(groups));

            result = saveEvent(eventType, event);
        } catch (JSONException e) {
            logger.e(TAG, String.format(
                "JSON Serialization of event type %s failed, skipping: %s", eventType, e.toString()
            ));
        }

        return result;
    }

    /**
     * Save event long. Internal method to save an event to the database.
     *
     * @param eventType the event type
     * @param event     the event
     * @return the event ID if succeeded, else -1
     */
    protected long saveEvent(String eventType, JSONObject event) {
        String eventString = event.toString();
        if (Utils.isEmptyString(eventString)) {
            logger.e(TAG, String.format(
                "Detected empty event string for event type %s, skipping", eventType
            ));
            return -1;
        }

        if (eventType.equals(Constants.IDENTIFY_EVENT)) {
            lastIdentifyId = dbHelper.addIdentify(eventString);
            setLastIdentifyId(lastIdentifyId);
        } else {
            lastEventId = dbHelper.addEvent(eventString);
            setLastEventId(lastEventId);
        }

        int numEventsToRemove = Math.min(
                Math.max(1, eventMaxCount/10),
                Constants.EVENT_REMOVE_BATCH_SIZE
        );
        if (dbHelper.getEventCount() > eventMaxCount) {
            dbHelper.removeEvents(dbHelper.getNthEventId(numEventsToRemove));
        }
        if (dbHelper.getIdentifyCount() > eventMaxCount) {
            dbHelper.removeIdentifys(dbHelper.getNthIdentifyId(numEventsToRemove));
        }

        long totalEventCount = dbHelper.getTotalEventCount(); // counts may have changed, refetch
        if ((totalEventCount % eventUploadThreshold) == 0 &&
                totalEventCount >= eventUploadThreshold) {
            updateServer();
        } else {
            updateServerLater(eventUploadPeriodMillis);
        }

        return eventType.equals(Constants.IDENTIFY_EVENT) ? lastIdentifyId : lastEventId;
    }

    // fetches key from dbHelper longValueStore
    // if key does not exist, return defaultValue instead
    private long getLongvalue(String key, long defaultValue) {
        Long value = dbHelper.getLongValue(key);
        return value == null ? defaultValue : value;
    }

    /**
     * Internal method to increment and fetch the next event sequence number.
     *
     * @return the next sequence number
     */
    long getNextSequenceNumber() {
        sequenceNumber++;
        dbHelper.insertOrReplaceKeyLongValue(SEQUENCE_NUMBER_KEY, sequenceNumber);
        return sequenceNumber;
    }

    /**
     * Internal method to set the last event time.
     *
     * @param timestamp the timestamp
     */
    void setLastEventTime(long timestamp) {
        lastEventTime = timestamp;
        dbHelper.insertOrReplaceKeyLongValue(LAST_EVENT_TIME_KEY, timestamp);
    }

    /**
     * Internal method to set the last event id.
     *
     * @param eventId the event id
     */
    void setLastEventId(long eventId) {
        lastEventId = eventId;
        dbHelper.insertOrReplaceKeyLongValue(LAST_EVENT_ID_KEY, eventId);
    }

    /**
     * Internal method to set the last identify id.
     *
     * @param identifyId the identify id
     */
    void setLastIdentifyId(long identifyId) {
        lastIdentifyId = identifyId;
        dbHelper.insertOrReplaceKeyLongValue(LAST_IDENTIFY_ID_KEY, identifyId);
    }

    /**
     * Gets the current session id.
     *
     * @return The current sessionId value.
     */
    public long getSessionId() {
        return sessionId;
    }

    /**
     * Internal method to set the previous session id.
     *
     * @param timestamp the timestamp
     */
    void setPreviousSessionId(long timestamp) {
        previousSessionId = timestamp;
        dbHelper.insertOrReplaceKeyLongValue(PREVIOUS_SESSION_ID_KEY, timestamp);
    }

    /**
     * Internal method to start a new session if needed.
     *
     * @param timestamp the timestamp
     * @return whether or not a new session was started
     */
    boolean startNewSessionIfNeeded(long timestamp) {
        if (inSession()) {

            if (isWithinMinTimeBetweenSessions(timestamp)) {
                refreshSessionTime(timestamp);
                return false;
            }

            startNewSession(timestamp);
            return true;
        }

        // no current session - check for previous session
        if (isWithinMinTimeBetweenSessions(timestamp)) {
            if (previousSessionId == -1) {
                startNewSession(timestamp);
                return true;
            }

            // extend previous session
            setSessionId(previousSessionId);
            refreshSessionTime(timestamp);
            return false;
        }

        startNewSession(timestamp);
        return true;
    }

    private void startNewSession(long timestamp) {
        // end previous session
        if (trackingSessionEvents) {
            sendSessionEvent(END_SESSION_EVENT);
        }

        // start new session
        setSessionId(timestamp);
        refreshSessionTime(timestamp);
        if (trackingSessionEvents) {
            sendSessionEvent(START_SESSION_EVENT);
        }
    }

    private boolean inSession() {
        return sessionId >= 0;
    }

    private boolean isWithinMinTimeBetweenSessions(long timestamp) {
        long sessionLimit = usingForegroundTracking ?
                minTimeBetweenSessionsMillis : sessionTimeoutMillis;
        return (timestamp - lastEventTime) < sessionLimit;
    }

    private void setSessionId(long timestamp) {
        sessionId = timestamp;
        setPreviousSessionId(timestamp);
    }

    /**
     * Internal method to refresh the current session time.
     *
     * @param timestamp the timestamp
     */
    void refreshSessionTime(long timestamp) {
        if (!inSession()) {
            return;
        }

        setLastEventTime(timestamp);
    }

    private void sendSessionEvent(final String sessionEvent) {
        if (!contextAndApiKeySet(String.format("sendSessionEvent('%s')", sessionEvent))) {
            return;
        }

        if (!inSession()) {
            return;
        }

        JSONObject apiProperties = new JSONObject();
        try {
            apiProperties.put("special", sessionEvent);
        } catch (JSONException e) {
            return;
        }

        logEvent(sessionEvent, null, apiProperties, null, null, lastEventTime, false);
    }

    /**
     * Internal method to handle on app exit foreground behavior.
     *
     * @param timestamp the timestamp
     */
    void onExitForeground(final long timestamp) {
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (Utils.isEmptyString(apiKey)) {
                    return;
                }
                refreshSessionTime(timestamp);
                inForeground = false;
                if (flushEventsOnClose) {
                    updateServer();
                }

                // re-persist metadata into database for good measure
                dbHelper.insertOrReplaceKeyValue(DEVICE_ID_KEY, deviceId);
                dbHelper.insertOrReplaceKeyValue(USER_ID_KEY, userId);
                dbHelper.insertOrReplaceKeyLongValue(OPT_OUT_KEY, optOut ? 1L : 0L);
            }
        });
    }

    /**
     * Internal method to handle on app enter foreground behavior.
     *
     * @param timestamp the timestamp
     */
    void onEnterForeground(final long timestamp) {
        runOnLogThread(new Runnable() {
            @Override
            public void run() {
                if (Utils.isEmptyString(apiKey)) {
                    return;
                }
                startNewSessionIfNeeded(timestamp);
                inForeground = true;
{'fraction_non_alphanumeric': 0.08147217423171561, 'fraction_numerical': 0.0009214177268205853, 'mean_word_length': 2.3151445728765316, 'pattern_counts': {'":': 0, '<': 181, '<?xml version=': 0, '>': 193, 'https://': 48, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '813076', 'n_tokens_mistral': 24728, 'n_tokens_neox': 23804, 'n_words': 7466}