io.rakam.api.RakamClient Maven / Gradle / Ivy
Show all versions of android-sdk Show documentation
package io.rakam.api;
import android.app.Application;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.sqlite.SQLiteDatabase;
import android.location.Location;
import android.os.Build;
import android.text.TextUtils;
import android.util.Pair;
import okhttp3.*;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.net.URL;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import static io.rakam.api.Constants.EVENT_BATCH_ENDPOINT;
import static io.rakam.api.Constants.MAX_STRING_LENGTH;
/**
* RakamClient
* This is the SDK instance class that contains all of the SDK functionality.
* Note: call the methods on the default shared instance in the Rakam class,
* for example: {@code Rakam.getInstance().logEvent();}
* Many of the SDK functions return the SDK instance back, allowing you to chain multiple method
* calls together, for example: {@code Rakam.getInstance().initialize(this, "APIKEY").enableForegroundTracking(getApplication())}
*/
public class RakamClient {
public static final MediaType JSON
= MediaType.parse("application/json; charset=utf-8");
/**
* The class identifier tag used in logging. TAG = {@code "RakamClient";}
*/
public static final String TAG = "RakamClient";
/**
* 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 super properties.
*/
public static final String SUPER_PROPERTIES_KEY = "super_properties";
/**
* The pref/database key for the opt out flag.
*/
public static final String OPT_OUT_KEY = "opt_out";
/**
* 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 RakamLog logger = RakamLog.getLogger();
/**
* The Android App Context.
*/
protected Context context;
/**
* The shared OkHTTPClient instance.
*/
protected OkHttpClient httpClient;
/**
* The shared Rakam database helper instance.
*/
protected DatabaseHelper dbHelper;
/**
* The Rakam App API key.
*/
protected String apiKey;
/**
* The name for this instance of RakamClient.
*/
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;
/**
* The device's Platform value.
*/
protected String platform;
/**
* Event metadata
*/
long sessionId = -1;
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 JSONObject superProperties;
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 Rakam API url that will store the events.
*/
private String apiUrl;
/**
* 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 RakamClient and starts worker threads.
*/
public RakamClient() {
this(null);
}
/**
* Instantiates a new RakamClient and starts worker threads.
*/
public RakamClient(String instance) {
this.instanceName = Utils.normalizeInstanceName(instance);
logThread.start();
httpThread.start();
logThread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
logger.e(TAG, "Unknown exception thrown from log thread.", e);
}
});
httpThread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
logger.e(TAG, "Unknown exception thrown from HTTP thread.", e);
}
});
}
/**
* Initialize the Rakam SDK with the Android application context and your Rakam
* App API key. Note: initialization is required before you log events and modify
* user properties.
*
* @param context the Android application context
* @param apiUrl your Rakam API Url
* @param apiKey your Rakam App API key
* @return the RakamClient
*/
public RakamClient initialize(Context context, URL apiUrl, String apiKey) {
return initialize(context, apiUrl, apiKey, null);
}
/**
* Initialize the Rakam SDK with the Android application context and your Rakam
* App API key. Note: initialization is required before you log events and modify
* user properties.
*
* @param context the Android application context
* @param apiUrl your Rakam API Url
* @param apiKey your Rakam App API key
* @param userId your Application User Id
* @return the RakamClient
*/
public synchronized RakamClient initialize(Context context, URL apiUrl, String apiKey, String userId) {
return initialize(context, apiUrl, apiKey, userId, null, true);
}
/**
* Initialize the Rakam SDK with the Android application context, your Rakam App API
* key, and a user ID for the current user. Note: initialization is required before
* you log events and modify user properties.
*
* @param context the Android application context
* @param apiUrl your Rakam App API Url
* @param apiKey your Rakam App API key
* @param userId your Application User Id
* @param platform The platform name
* @param enableDiagnosticLogging Enable error tracking to Rakam APIs
* @return the RakamClient
*/
public synchronized RakamClient initialize(final Context context, final URL apiUrl, final String apiKey, final String userId, final String platform, final boolean enableDiagnosticLogging) {
if (context == null) {
logger.e(TAG, "Argument context cannot be null in initialize()");
return this;
}
setApiUrl(apiUrl);
if (TextUtils.isEmpty(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 RakamClient 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)) {
RakamClient.upgradePrefs(context);
RakamClient.upgradeSharedPrefsToDB(context);
}
httpClient = new OkHttpClient();
deviceInfo = new DeviceInfo(context);
deviceId = initializeDeviceId();
if (enableDiagnosticLogging) {
Diagnostics.getLogger().enableLogging(httpClient, apiKey, deviceId);
}
deviceInfo.prefetch();
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
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(SQLiteDatabase db) {
dbHelper.insertOrReplaceKeyValueToTable(db, DatabaseHelper.STORE_TABLE_NAME, DEVICE_ID_KEY, client.deviceId);
dbHelper.insertOrReplaceKeyValueToTable(db, DatabaseHelper.STORE_TABLE_NAME, USER_ID_KEY, client.userId);
dbHelper.insertOrReplaceKeyValueToTable(db, DatabaseHelper.LONG_STORE_TABLE_NAME, OPT_OUT_KEY, client.optOut ? 1L : 0L);
dbHelper.insertOrReplaceKeyValueToTable(db, DatabaseHelper.LONG_STORE_TABLE_NAME, PREVIOUS_SESSION_ID_KEY, client.sessionId);
dbHelper.insertOrReplaceKeyValueToTable(db, DatabaseHelper.LONG_STORE_TABLE_NAME, LAST_EVENT_TIME_KEY, client.lastEventTime);
}
});
initialized = true;
String value = dbHelper.getValue(SUPER_PROPERTIES_KEY);
if (value != null) {
try {
superProperties = new JSONObject(value);
} catch (JSONException e) {
dbHelper.insertOrReplaceKeyValue(SUPER_PROPERTIES_KEY, null);
}
}
} catch (CursorWindowAllocationException e) { // treat as uninitialized SDK
logger.e(TAG, String.format(
"Failed to initialize Rakam SDK due to: %s", e.getMessage()
));
Diagnostics.getLogger().logError("Failed to initialize Rakam SDK", e);
client.apiKey = null;
}
}
}
});
return this;
}
/**
* Sets super property keys for the user.
* Super properties allow you to continuously attach a property to every event you track automatically.
*
* @param superProperties Super properties
* @return the RakamClient
*/
public RakamClient setSuperProperties(JSONObject superProperties) {
this.superProperties = superProperties;
dbHelper.insertOrReplaceKeyValue(SUPER_PROPERTIES_KEY, superProperties.toString());
return this;
}
/**
* Get super property keys for the user.
*
* @return the super properties
*/
public JSONObject getSuperProperties() {
return Utils.cloneJSONObject(superProperties);
}
/**
* Enable foreground tracking for the SDK. This is HIGHLY RECOMMENDED, and will allow
* for accurate session tracking.
*
* @param app the Android application
* @return the RakamClient
* @see
* Tracking Sessions
*/
public RakamClient enableForegroundTracking(Application app) {
if (usingForegroundTracking || !contextAndApiKeySet("enableForegroundTracking()")) {
return this;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
app.registerActivityLifecycleCallbacks(new RakamCallbacks(this));
}
return this;
}
public RakamClient enableDiagnosticLogging() {
if (!contextAndApiKeySet("enableDiagnosticLogging")) {
return this;
}
Diagnostics.getLogger().enableLogging(httpClient, apiKey, deviceId);
return this;
}
public RakamClient disableDiagnosticLogging() {
Diagnostics.getLogger().disableLogging();
return this;
}
public RakamClient setDiagnosticEventMaxCount(int eventMaxCount) {
Diagnostics.getLogger().setDiagnosticEventMaxCount(eventMaxCount);
return this;
}
/**
* 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 RakamClient
* @deprecated
*/
public RakamClient enableNewDeviceIdPerInstall(boolean newDeviceIdPerInstall) {
this.newDeviceIdPerInstall = newDeviceIdPerInstall;
return this;
}
/**
* Whether to use the Android advertising ID (ADID) as the user's device ID.
*
* @return the RakamClient
*/
public RakamClient 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 RakamClient
*/
public RakamClient 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 RakamClient
*/
public RakamClient 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 RakamClient
*/
public RakamClient 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 RakamClient
*/
public RakamClient 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 RakamClient
*/
public RakamClient 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 RakamClient
*/
public RakamClient 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 RakamClient setMinTimeBetweenSessionsMillis(long minTimeBetweenSessionsMillis) {
this.minTimeBetweenSessionsMillis = minTimeBetweenSessionsMillis;
return this;
}
/**
* Sets session timeout millis. If foreground tracking has not been enabled with
*
* @param sessionTimeoutMillis the session timeout millis
* @return the RakamClient
* @{code enableForegroundTracking()}, then new sessions will be started after
* sessionTimeoutMillis milliseconds have passed since the last event logged.
*/
public RakamClient setSessionTimeoutMillis(long sessionTimeoutMillis) {
this.sessionTimeoutMillis = sessionTimeoutMillis;
return this;
}
public RakamClient 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 RakamClient
*/
public RakamClient setOptOut(final boolean optOut) {
if (!contextAndApiKeySet("setOptOut()")) {
return this;
}
final RakamClient 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 RakamClient
*/
public RakamClient 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 RakamClient
*/
public RakamClient setLogLevel(int logLevel) {
logger.setLogLevel(logLevel);
return this;
}
/**
* Sets offline. If offline is true, then the SDK will not upload events to Rakam servers;
* however, it will still log events.
*
* @param offline whether or not the SDK should be offline
* @return the RakamClient
*/
public RakamClient 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 RakamClient
*/
public RakamClient setFlushEventsOnClose(boolean flushEventsOnClose) {
this.flushEventsOnClose = flushEventsOnClose;
return this;
}
/**
* Track session events rakam 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 RakamClient
* @see
* Tracking Sessions
*/
public RakamClient 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.
* Note: 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.
* Note: this is asynchronous and happens on a background thread.
*
* @param eventType the event type
* @param eventProperties the event properties
* @see
* Setting Event Properties
*/
public void logEvent(String eventType, JSONObject eventProperties) {
logEvent(eventType, eventProperties, false);
}
/**
* Log an event with the specified event type and event properties.
* Note: 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
* Setting Event Properties
*/
public void logEvent(String eventType, JSONObject eventProperties, boolean outOfSession) {
if (validateLogEvent(eventType)) {
logEvent(eventType, eventProperties, getCurrentTimeMillis(), outOfSession);
}
}
/**
* Log an event with the specified event type.
* Note: 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.
* Note: this is version is synchronous and blocks the main thread until done.
*
* @param eventType the event type
* @param eventProperties the event properties
* @see
* Setting Event Properties
*/
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.
* Note: 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
* Setting Event Properties
* @see
* Tracking Sessions
*/
public void logEventSync(String eventType, JSONObject eventProperties, boolean outOfSession) {
if (validateLogEvent(eventType)) {
logEvent(eventType, eventProperties, getCurrentTimeMillis(), 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 (TextUtils.isEmpty(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 properties the request properties
* @param timestamp the timestamp
* @param outOfSession the out of session
*/
protected void logEventAsync(final String eventType, JSONObject properties,
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 (properties != null) {
properties = Utils.cloneJSONObject(properties);
}
final JSONObject copyProperties = properties;
runOnLogThread(new Runnable() {
@Override
public void run() {
if (Utils.isEmptyString(apiKey)) { // in case initialization failed
return;
}
logEvent(
eventType, copyProperties, 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 timestamp the timestamp
* @param outOfSession the out of session
* @return the event ID if succeeded, else -1.
*/
protected long logEvent(String eventType, JSONObject eventProperties, long timestamp, boolean outOfSession) {
logger.d(TAG, "Logged event to Rakam: " + 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 properties = new JSONObject();
try {
properties.put("_id", UUID.randomUUID().toString());
properties.put("_local_id", lastEventId);
properties.put("_time", timestamp);
properties.put("_user", replaceWithJSONNull(userId));
properties.put("_device_id", replaceWithJSONNull(deviceId));
properties.put("_session_id", outOfSession ? -1 : sessionId);
if (trackingOptions.shouldTrackVersionName()) {
properties.put("_version_name", replaceWithJSONNull(deviceInfo.getVersionName()));
}
if (trackingOptions.shouldTrackOsName()) {
properties.put("_os_name", replaceWithJSONNull(deviceInfo.getOsName()));
}
if (trackingOptions.shouldTrackOsVersion()) {
properties.put("_os_version", replaceWithJSONNull(deviceInfo.getOsVersion()));
}
if (trackingOptions.shouldTrackDeviceBrand()) {
properties.put("_device_brand", replaceWithJSONNull(deviceInfo.getBrand()));
}
if (trackingOptions.shouldTrackDeviceManufacturer()) {
properties.put("_device_manufacturer", replaceWithJSONNull(deviceInfo.getManufacturer()));
}
if (trackingOptions.shouldTrackDeviceModel()) {
properties.put("_device_model", replaceWithJSONNull(deviceInfo.getModel()));
}
if (trackingOptions.shouldTrackCarrier()) {
properties.put("_carrier", replaceWithJSONNull(deviceInfo.getCarrier()));
}
if (trackingOptions.shouldTrackCountry()) {
properties.put("_country_code", replaceWithJSONNull(deviceInfo.getCountry()));
}
if (trackingOptions.shouldTrackLanguage()) {
properties.put("_language", replaceWithJSONNull(deviceInfo.getLanguage()));
}
if (trackingOptions.shouldTrackPlatform()) {
properties.put("_platform", platform);
}
properties.put("_library_name", Constants.LIBRARY);
properties.put("_library_version", Constants.VERSION);
properties.put("_ip", true);
if (trackingOptions.shouldTrackLatLng()) {
Location location = deviceInfo.getMostRecentLocation();
if (location != null) {
properties.put("_latitude", location.getLatitude());
properties.put("_longitude", location.getLongitude());
}
}
if (trackingOptions.shouldTrackAdid() && deviceInfo.getAdvertisingId() != null) {
properties.put("_android_adid", deviceInfo.getAdvertisingId());
}
properties.put("_limit_ad_tracking", deviceInfo.isLimitAdTrackingEnabled());
properties.put("_gps_enabled", deviceInfo.isGooglePlayServicesEnabled());
if (eventProperties != null) {
Iterator keys = eventProperties.keys();
while (keys.hasNext()) {
String next = keys.next();
properties.put(next, eventProperties.get(next));
}
}
if (superProperties != null) {
Iterator keys = superProperties.keys();
while (keys.hasNext()) {
String next = keys.next();
if (eventProperties != null && eventProperties.has(next)) {
continue;
}
properties.put(next, superProperties.get(next));
}
}
JSONObject event = new JSONObject();
event.put("properties", truncate(properties));
event.put("collection", replaceWithJSONNull(eventType));
result = saveEvent(eventType, event);
} catch (JSONException e) {
logger.e(TAG, String.format(
"JSON Serialization of event type %s failed, skipping: %s", eventType, e.toString()
));
Diagnostics.getLogger().logError(
String.format("Failed to JSON serialize event type %s", eventType), e
);
}
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 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);
}
/**
* Public method to start a new session if needed.
*
* @param timestamp the timestamp
* @return whether or not a new session was started
*/
public 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;
}
logEvent(sessionEvent, 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);
dbHelper.insertOrReplaceKeyLongValue(PREVIOUS_SESSION_ID_KEY, sessionId);
dbHelper.insertOrReplaceKeyLongValue(LAST_EVENT_TIME_KEY, lastEventTime);
}
});
}
/**
* 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;
}
});
}
/**
* Log revenue. Create a {@link io.rakam.api.Revenue} object to hold your revenue data and
* properties, and log it as a revenue event using {@code logRevenue}.
*
* @param revenue a {@link io.rakam.api.Revenue} object
* @see io.rakam.api.Revenue
* @see
* Tracking Revenue
*/
public void logRevenue(Revenue revenue) {
if (!contextAndApiKeySet("logRevenue()") || revenue == null || !revenue.isValidRevenue()) {
return;
}
logEvent(Constants.REVENUE_EVENT, revenue.toJSONObject());
}
/**
* Sets user properties. This is a convenience wrapper around the
* {@link io.rakam.api.Identify} API to set multiple user properties with a single
* command. Note: the replace parameter is deprecated and has no effect.
*
* @param userProperties the user properties
* @param replace the replace - has no effect
* @see
* User Properties
* @deprecated
*/
public void setUserProperties(final JSONObject userProperties, final boolean replace) {
setUserProperties(userProperties);
}
/**
* Sets user properties. This is a convenience wrapper around the
* {@link io.rakam.api.Identify} API to set multiple user properties with a single
* command.
*
* @param userProperties the user properties
* @see
* User Properties
*/
public void setUserProperties(final JSONObject userProperties) {
if (userProperties == null || userProperties.length() == 0 ||
!contextAndApiKeySet("setUserProperties")) {
return;
}
// sanitize and truncate properties before trying to convert to identify
JSONObject sanitized = truncate(userProperties);
if (sanitized.length() == 0) {
return;
}
Identify identify = new Identify();
Iterator> keys = sanitized.keys();
while (keys.hasNext()) {
String key = (String) keys.next();
try {
identify.setUserProperty(key, sanitized.get(key));
} catch (JSONException e) {
logger.e(TAG, e.toString());
Diagnostics.getLogger().logError(
String.format("Failed to set user property %s", key), e
);
}
}
identify(identify);
}
/**
* Clear user properties. This will clear all user properties at once. Note: the
* result is irreversible!
*
* @see
* User Properties
*/
public void clearUserProperties() {
Identify identify = new Identify().clearAll();
identify(identify);
}
/**
* Clear super properties. This will clear all super properties at once. Note: the
* result is irreversible!
*
* @see
* Super Properties
*/
public void clearSuperProperties() {
dbHelper.insertOrReplaceKeyValue(SUPER_PROPERTIES_KEY, null);
superProperties = null;
}
/**
* Identify. Use this to send an {@link io.rakam.api.Identify} object containing
* user property operations to Rakam server.
*
* @param identify an {@link io.rakam.api.Identify} object
* @see io.rakam.api.Identify
* @see
* User Properties
*/
public void identify(Identify identify) {
identify(identify, false);
}
/**
* Identify. Use this to send an {@link io.rakam.api.Identify} object containing
* user property operations to Rakam server. If outOfSession is true, then the identify
* event is sent with a session id of -1, and does not trigger any session-handling logic.
*
* @param identify an {@link io.rakam.api.Identify} object
* @param outOfSession whther to log the identify event out of session
* @see io.rakam.api.Identify
* @see
* User Properties
*/
public void identify(Identify identify, boolean outOfSession) {
if (
identify == null || identify.userPropertiesOperations.length() == 0 ||
!contextAndApiKeySet("identify()")
) return;
logEventAsync(
Constants.IDENTIFY_EVENT, identify.userPropertiesOperations, getCurrentTimeMillis(), outOfSession
);
}
/**
* Truncate values in a JSON object. Any string values longer than 1024 characters will be
* truncated to 1024 characters.
*
* @param object the object
* @return the truncated JSON object
*/
public JSONObject truncate(JSONObject object) {
if (object == null) {
return new JSONObject();
}
if (object.length() > Constants.MAX_PROPERTY_KEYS) {
logger.w(TAG, "Warning: too many properties (more than 1000), ignoring");
return new JSONObject();
}
Iterator> keys = object.keys();
while (keys.hasNext()) {
String key = (String) keys.next();
try {
Object value = object.get(key);
// do not truncate revenue receipt and receipt sig fields
if (value.getClass().equals(String.class)) {
object.put(key, truncate((String) value));
} else if (value.getClass().equals(JSONObject.class)) {
object.put(key, truncate((JSONObject) value));
} else if (value.getClass().equals(JSONArray.class)) {
object.put(key, truncate((JSONArray) value));
}
} catch (JSONException e) {
logger.e(TAG, e.toString());
}
}
return object;
}
/**
* Truncate values in a JSON array. Any string values longer than 1024 characters will be
* truncated to 1024 characters.
*
* @param array the array
* @return the truncated JSON array
* @throws JSONException the json exception
*/
public JSONArray truncate(JSONArray array) throws JSONException {
if (array == null) {
return new JSONArray();
}
for (int i = 0; i < array.length(); i++) {
Object value = array.get(i);
if (value.getClass().equals(String.class)) {
array.put(i, truncate((String) value));
} else if (value.getClass().equals(JSONObject.class)) {
array.put(i, truncate((JSONObject) value));
} else if (value.getClass().equals(JSONArray.class)) {
array.put(i, truncate((JSONArray) value));
}
}
return array;
}
/**
* Truncate a string to 1024 characters.
*
* @param value the value
* @return the truncated string
*/
static String truncate(String value) {
return value.length() <= MAX_STRING_LENGTH ? value : value.substring(0, MAX_STRING_LENGTH);
}
/**
* Gets the user's id. Can be null.
*
* @return The developer specified identifier for tracking within the analytics system.
*/
public Object getUserId() {
return userId;
}
/**
* Sets the user id (can be null).
*
* @param userId the user id
* @return the RakamClient
*/
public RakamClient setUserId(final String userId) {
return setUserId(userId, false);
}
/**
* Sets the user id (can be null).
* If startNewSession is true, ends the session for the previous user and starts a new
* session for the new user id.
*
* @param userId the user id
* @return the RakamClient
*/
public RakamClient setUserId(final String userId, final boolean startNewSession) {
if (!contextAndApiKeySet("setUserId()")) {
return this;
}
final RakamClient client = this;
runOnLogThread(new Runnable() {
@Override
public void run() {
if (Utils.isEmptyString(client.apiKey)) { // in case initialization failed
return;
}
// end previous session
if (startNewSession && trackingSessionEvents) {
sendSessionEvent(END_SESSION_EVENT);
}
client.userId = userId;
dbHelper.insertOrReplaceKeyValue(USER_ID_KEY, userId);
// start new session
if (startNewSession) {
long timestamp = getCurrentTimeMillis();
setSessionId(timestamp);
refreshSessionTime(timestamp);
if (trackingSessionEvents) {
sendSessionEvent(START_SESSION_EVENT);
}
}
}
});
return this;
}
/**
* Sets the user id (can be null).
*
* @param userId the user id
* @return the RakamClient
*/
public RakamClient setUserId(int userId) {
return setUserId(String.valueOf(userId));
}
/**
* Sets a custom device id. Note: only do this if you know what you are doing!
*
* @param deviceId the device id
* @return the RakamClient
* @see
* Custom Device Ids
*/
public RakamClient setDeviceId(final String deviceId) {
Set invalidDeviceIds = getInvalidDeviceIds();
if (!contextAndApiKeySet("setDeviceId()") || Utils.isEmptyString(deviceId) ||
invalidDeviceIds.contains(deviceId)) {
return this;
}
final RakamClient client = this;
runOnLogThread(new Runnable() {
@Override
public void run() {
if (Utils.isEmptyString(client.apiKey)) { // in case initialization failed
return;
}
client.deviceId = deviceId;
saveDeviceId(deviceId);
}
});
return this;
}
/**
* Regenerates a new random deviceId for current user. Note: this is not recommended unless you
* know what you are doing. This can be used in conjunction with setUserId(null) to anonymize
* users after they log out. With a null userId and a completely new deviceId, the current user
* would appear as a brand new user in dashboard.
*
* @see
* Logging Out Users
*/
public RakamClient regenerateDeviceId() {
if (!contextAndApiKeySet("regenerateDeviceId()")) {
return this;
}
final RakamClient client = this;
runOnLogThread(new Runnable() {
@Override
public void run() {
if (Utils.isEmptyString(client.apiKey)) { // in case initialization failed
return;
}
String randomId = DeviceInfo.generateUUID() + "R";
setDeviceId(randomId);
}
});
return this;
}
/**
* Force SDK to upload any unsent events.
*/
public void uploadEvents() {
if (!contextAndApiKeySet("uploadEvents()")) {
return;
}
logThread.post(new Runnable() {
@Override
public void run() {
if (Utils.isEmptyString(apiKey)) { // in case initialization failed
return;
}
updateServer();
}
});
}
private void updateServerLater(long delayMillis) {
if (updateScheduled.getAndSet(true)) {
return;
}
logThread.postDelayed(new Runnable() {
@Override
public void run() {
updateScheduled.set(false);
updateServer();
}
}, delayMillis);
}
/**
* Internal method to upload unsent events.
*/
protected void updateServer() {
updateServer(false);
Diagnostics.getLogger().flushEvents();
}
/**
* Internal method to upload unsent events. Limit controls whether to use event upload max
* batch size or backoff upload batch size. Note: always call this on logThread
*
* @param limit the limit
*/
protected void updateServer(boolean limit) {
if (optOut || offline) {
return;
}
// if returning out of this block, always be sure to set uploadingCurrently to false!!
if (!uploadingCurrently.getAndSet(true)) {
long totalEventCount = dbHelper.getTotalEventCount();
long batchSize = Math.min(
limit ? backoffUploadBatchSize : eventUploadMaxBatchSize,
totalEventCount
);
if (batchSize <= 0) {
uploadingCurrently.set(false);
return;
}
try {
List events = dbHelper.getEvents(lastEventId, batchSize);
List identifys = dbHelper.getIdentifys(lastIdentifyId, batchSize);
final Pair, JSONArray> merged = mergeEventsAndIdentifys(events, identifys, batchSize);
final JSONArray mergedEvents = merged.second;
if (mergedEvents.length() == 0) {
uploadingCurrently.set(false);
return;
}
final long maxEventId = merged.first.first;
final long maxIdentifyId = merged.first.second;
final String body;
try {
body = new JSONObject().put("api", getApi()).put("events", merged.second).toString();
} catch (JSONException e) {
uploadingCurrently.set(false);
logger.e(TAG, e.toString());
return;
}
httpThread.post(new Runnable() {
@Override
public void run() {
makeEventUploadPostRequest(httpClient, body, maxEventId, maxIdentifyId);
}
});
} catch (JSONException e) {
uploadingCurrently.set(false);
logger.e(TAG, e.toString());
Diagnostics.getLogger().logError("Failed to update server", e);
// handle CursorWindowAllocationException when fetching events, defer upload
} catch (CursorWindowAllocationException e) {
uploadingCurrently.set(false);
logger.e(TAG, String.format(
"Caught Cursor window exception during event upload, deferring upload: %s",
e.getMessage()
));
Diagnostics.getLogger().logError("Failed to update server", e);
}
}
}
/**
* Internal method to merge unsent events and identifies into a single array by sequence number.
*
* @param events the events
* @param identifys the identifys
* @param numEvents the num events
* @return the merged array, max event id, and max identify id
* @throws JSONException the json exception
*/
protected Pair, JSONArray> mergeEventsAndIdentifys(List events,
List identifys, long numEvents) throws JSONException {
JSONArray merged = new JSONArray();
long maxEventId = -1;
long maxIdentifyId = -1;
while (merged.length() < numEvents) {
boolean noEvents = events.isEmpty();
boolean noIdentifys = identifys.isEmpty();
// case 0: no events or identifys, nothing to grab
// this case should never happen, as it means there are less identifys and events
// than expected
if (noEvents && noIdentifys) {
logger.w(TAG, String.format(
"mergeEventsAndIdentifys: number of events and identifys " +
"less than expected by %d", numEvents - merged.length())
);
break;
// case 1: no identifys, grab from events
} else if (noIdentifys) {
JSONObject event = events.remove(0);
maxEventId = event.getLong("event_id");
merged.put(event);
// case 2: no events, grab from identifys
} else if (noEvents) {
JSONObject identify = identifys.remove(0);
maxIdentifyId = identify.getLong("event_id");
merged.put(identify);
// case 3: need to compare sequence numbers
} else {
// events logged before v2.1.0 won't have a sequence number, put those first
if (!events.get(0).has("event_id") ||
events.get(0).getLong("event_id") <
identifys.get(0).getLong("event_id")) {
JSONObject event = events.remove(0);
maxEventId = event.getLong("event_id");
merged.put(event);
} else {
JSONObject identify = identifys.remove(0);
maxIdentifyId = identify.getLong("event_id");
merged.put(identify);
}
}
}
return new Pair, JSONArray>(new Pair(maxEventId, maxIdentifyId), merged);
}
private JSONObject getApi()
throws JSONException {
return new JSONObject()
.put("api_key", apiKey)
.put("library", new JSONObject()
.put("name", Constants.LIBRARY)
.put("version", Constants.VERSION))
.put("upload_time", getCurrentTimeMillis());
}
/**
* Internal method to generate the event upload post request.
*
* @param client the client
* @param body request body
* @param maxEventId the max event id
* @param maxIdentifyId the max identify id
*/
protected void makeEventUploadPostRequest(OkHttpClient client, String body, final long maxEventId, final long maxIdentifyId) {
Request request;
try {
request = new Request.Builder()
.url(apiUrl + EVENT_BATCH_ENDPOINT)
.post(RequestBody.create(JSON, body))
.build();
} catch (IllegalArgumentException e) {
logger.e(TAG, e.toString());
uploadingCurrently.set(false);
Diagnostics.getLogger().logError("Failed to build upload request", e);
return;
}
boolean uploadSuccess = false;
try {
Response response = client.newCall(request).execute();
String stringResponse = response.body().string();
if (stringResponse.equals("1")) {
uploadSuccess = true;
logThread.post(new Runnable() {
@Override
public void run() {
if (maxEventId >= 0) dbHelper.removeEvents(maxEventId);
if (maxIdentifyId >= 0) dbHelper.removeIdentifys(maxIdentifyId);
uploadingCurrently.set(false);
if (dbHelper.getTotalEventCount() > eventUploadThreshold) {
logThread.post(new Runnable() {
@Override
public void run() {
updateServer(backoffUpload);
}
});
} else {
backoffUpload = false;
backoffUploadBatchSize = eventUploadMaxBatchSize;
}
}
});
} else if (response.code() == 403) {
logger.e(TAG, "Invalid API key, make sure your API key is correct in initialize()");
} else if (stringResponse.equals("bad_checksum")) {
logger.w(TAG,
"Bad checksum, post request was mangled in transit, will attempt to reupload later");
} else if (stringResponse.equals("request_db_write_failed")) {
logger.w(TAG,
"Couldn't write to request database on server, will attempt to reupload later");
} else if (response.code() == 413 || response.code() == 400) {
// If blocked by one massive event, drop it
if (backoffUpload && backoffUploadBatchSize == 1) {
if (maxEventId >= 0) dbHelper.removeEvent(maxEventId);
if (maxIdentifyId >= 0) dbHelper.removeIdentify(maxIdentifyId);
// maybe we want to reset backoffUploadBatchSize after dropping massive event
}
// Server complained about length of request, backoff and try again
backoffUpload = true;
int numEvents = Math.min((int) dbHelper.getEventCount(), backoffUploadBatchSize);
backoffUploadBatchSize = (int) Math.ceil(numEvents / 2.0);
logger.w(TAG, String.format("Request too large or invalid: %s, will decrease size and attempt to reupload", response.code()));
logThread.post(new Runnable() {
@Override
public void run() {
uploadingCurrently.set(false);
updateServer(true);
}
});
} else if (response.code() == 500) {
logger.w(TAG,
"A server error occurred, will attempt to reupload later");
} else {
logger.w(TAG, "Upload failed, " + stringResponse + ", will attempt to reupload later");
}
} catch (java.net.ConnectException e) {
// logger.w(TAG,
// "No internet connection found, unable to upload events");
lastError = e;
Diagnostics.getLogger().logError("Failed to post upload request", e);
} catch (java.net.UnknownHostException e) {
// logger.w(TAG,
// "No internet connection found, unable to upload events");
lastError = e;
Diagnostics.getLogger().logError("Failed to post upload request", e);
} catch (IOException e) {
logger.e(TAG, e.toString());
lastError = e;
Diagnostics.getLogger().logError("Failed to post upload request", e);
} catch (AssertionError e) {
// This can be caused by a NoSuchAlgorithmException thrown by DefaultHttpClient
logger.e(TAG, "Exception:", e);
lastError = e;
Diagnostics.getLogger().logError("Failed to post upload request", e);
} catch (Exception e) {
// Just log any other exception so things don't crash on upload
logger.e(TAG, "Exception:", e);
lastError = e;
Diagnostics.getLogger().logError("Failed to post upload request", e);
}
if (!uploadSuccess) {
uploadingCurrently.set(false);
}
}
/**
* Get the current device id. Can be null if deviceId hasn't been initialized yet.
*
* @return A unique identifier for tracking within the analytics system.
*/
public String getDeviceId() {
return deviceId;
}
// don't need to keep this in memory, if only using it at most 1 or 2 times
private Set getInvalidDeviceIds() {
Set invalidDeviceIds = new HashSet();
invalidDeviceIds.add("");
invalidDeviceIds.add("9774d56d682e549c");
invalidDeviceIds.add("unknown");
invalidDeviceIds.add("000000000000000"); // Common Serial Number
invalidDeviceIds.add("00000000-0000-0000-0000-000000000000"); // Empty UUID
invalidDeviceIds.add("Android");
invalidDeviceIds.add("DEFACE");
return invalidDeviceIds;
}
private String initializeDeviceId() {
Set invalidIds = getInvalidDeviceIds();
// see if device id already stored in db
String deviceId = dbHelper.getValue(DEVICE_ID_KEY);
String sharedPrefDeviceId = Utils.getStringFromSharedPreferences(context, instanceName, DEVICE_ID_KEY);
if (!(Utils.isEmptyString(deviceId) || invalidIds.contains(deviceId))) {
// compare against device id stored in backup storage and update if necessary
if (!deviceId.equals(sharedPrefDeviceId)) {
saveDeviceId(deviceId);
}
return deviceId;
}
// backup #1: check if device id is stored in shared preferences
if (!(Utils.isEmptyString(sharedPrefDeviceId) || invalidIds.contains(sharedPrefDeviceId))) {
saveDeviceId(sharedPrefDeviceId);
return sharedPrefDeviceId;
}
if (!newDeviceIdPerInstall && useAdvertisingIdForDeviceId && !deviceInfo.isLimitAdTrackingEnabled()) {
// Android ID is deprecated by Google.
// We are required to use Advertising ID, and respect the advertising ID preference
String advertisingId = deviceInfo.getAdvertisingId();
if (!(Utils.isEmptyString(advertisingId) || invalidIds.contains(advertisingId))) {
saveDeviceId(advertisingId);
return advertisingId;
}
}
// If this still fails, generate random identifier that does not persist
// across installations. Append R to distinguish as randomly generated
String randomId = deviceInfo.generateUUID() + "R";
saveDeviceId(randomId);
return randomId;
}
private void saveDeviceId(String deviceId) {
dbHelper.insertOrReplaceKeyValue(DEVICE_ID_KEY, deviceId);
Utils.writeStringToSharedPreferences(context, instanceName, DEVICE_ID_KEY, deviceId);
}
private void runOnLogThread(Runnable r) {
if (Thread.currentThread() != logThread) {
logThread.post(r);
} else {
r.run();
}
}
public String getApiUrl() {
return apiUrl;
}
public void setApiUrl(URL apiUrl) {
if (apiUrl == null) {
logger.e(TAG, "apiUrl can't be null");
return;
}
String scheme = apiUrl.getProtocol();
String serverName = apiUrl.getHost();
int serverPort = apiUrl.getPort();
String address = scheme + "://" + serverName;
if (apiUrl.getPath() != null && !(apiUrl.getPath().equals("/") || apiUrl.getPath().isEmpty())) {
throw new IllegalStateException(String.format("Please set root address of the API address." +
" A valid example is %s, %s is not valid.", address, apiUrl.toString()));
}
if (serverPort > -1) {
address = address + ":" + serverPort;
}
this.apiUrl = address;
}
/**
* Internal method to replace null event fields with JSON null object.
*
* @param obj the obj
* @return the object
*/
protected Object replaceWithJSONNull(Object obj) {
return obj == null ? JSONObject.NULL : obj;
}
/**
* Internal method to check whether application context and api key are set
*
* @param methodName the parent method name to print in error message
* @return whether application context and api key are set
*/
protected synchronized boolean contextAndApiKeySet(String methodName) {
if (context == null) {
logger.e(TAG, "context cannot be null, set context with initialize() before calling "
+ methodName);
return false;
}
if (TextUtils.isEmpty(apiKey)) {
logger.e(TAG,
"apiKey cannot be null or empty, set apiKey with initialize() before calling "
+ methodName);
return false;
}
return true;
}
/**
* Internal method to convert bytes to hex string
*
* @param bytes the bytes
* @return the string
*/
protected String bytesToHexString(byte[] bytes) {
final char[] hexArray = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b',
'c', 'd', 'e', 'f'};
char[] hexChars = new char[bytes.length * 2];
int v;
for (int j = 0; j < bytes.length; j++) {
v = bytes[j] & 0xFF;
hexChars[j * 2] = hexArray[v >>> 4];
hexChars[j * 2 + 1] = hexArray[v & 0x0F];
}
return new String(hexChars);
}
/**
* Move all preference data from the legacy name to the new, static name if needed.
*
* Constants.PACKAGE_NAME used to be set using "Constants.class.getPackage().getName()"
* Some aggressive proguard optimizations broke the reflection and caused apps
* to crash on startup.
*
* Now that Constants.PACKAGE_NAME is changed, old data on devices needs to be
* moved over to the new location so that device ids remain consistent.
*
* This should only happen once -- the first time a user loads the app after updating.
* This logic needs to remain in place for quite a long time. It was first introduced in
* April 2015 in version 1.6.0.
*
* @param context the context
* @return the boolean
*/
static boolean upgradePrefs(Context context) {
return upgradePrefs(context, null, null);
}
/**
* Upgrade prefs boolean.
*
* @param context the context
* @param sourcePkgName the source pkg name
* @param targetPkgName the target pkg name
* @return the boolean
*/
static boolean upgradePrefs(Context context, String sourcePkgName, String targetPkgName) {
try {
if (sourcePkgName == null) {
// Try to load the package name using the old reflection strategy.
sourcePkgName = Constants.PACKAGE_NAME;
try {
sourcePkgName = Constants.class.getPackage().getName();
} catch (Exception e) {
}
}
if (targetPkgName == null) {
targetPkgName = Constants.PACKAGE_NAME;
}
// No need to copy if the source and target are the same.
if (targetPkgName.equals(sourcePkgName)) {
return false;
}
// Copy over any preferences that may exist in a source preference store.
String sourcePrefsName = sourcePkgName + "." + context.getPackageName();
SharedPreferences source =
context.getSharedPreferences(sourcePrefsName, Context.MODE_PRIVATE);
// Nothing left in the source store to copy
if (source.getAll().size() == 0) {
return false;
}
String prefsName = targetPkgName + "." + context.getPackageName();
SharedPreferences targetPrefs =
context.getSharedPreferences(prefsName, Context.MODE_PRIVATE);
SharedPreferences.Editor target = targetPrefs.edit();
// Copy over all existing data.
if (source.contains(sourcePkgName + ".previousSessionId")) {
target.putLong(Constants.PREFKEY_PREVIOUS_SESSION_ID,
source.getLong(sourcePkgName + ".previousSessionId", -1));
}
if (source.contains(sourcePkgName + ".deviceId")) {
target.putString(Constants.PREFKEY_DEVICE_ID,
source.getString(sourcePkgName + ".deviceId", null));
}
if (source.contains(sourcePkgName + ".userId")) {
target.putString(Constants.PREFKEY_USER_ID,
source.getString(sourcePkgName + ".userId", null));
}
if (source.contains(sourcePkgName + ".optOut")) {
target.putBoolean(Constants.PREFKEY_OPT_OUT,
source.getBoolean(sourcePkgName + ".optOut", false));
}
// Commit the changes and clear the source store so we don't recopy.
target.apply();
source.edit().clear().apply();
logger.i(TAG, "Upgraded shared preferences from " + sourcePrefsName + " to " + prefsName);
return true;
} catch (Exception e) {
logger.e(TAG, "Error upgrading shared preferences", e);
Diagnostics.getLogger().logError("Failed to upgrade shared prefs", e);
return false;
}
}
/**
* Upgrade shared prefs to db boolean.
*
* @param context the context
* @return the boolean
*/
/*
* Move all data from sharedPrefs to sqlite key value store to support multi-process apps.
* sharedPrefs is known to not be process-safe.
*/
static boolean upgradeSharedPrefsToDB(Context context) {
return upgradeSharedPrefsToDB(context, null);
}
/**
* Upgrade shared prefs to db boolean.
*
* @param context the context
* @param sourcePkgName the source pkg name
* @return the boolean
*/
static boolean upgradeSharedPrefsToDB(Context context, String sourcePkgName) {
if (sourcePkgName == null) {
sourcePkgName = Constants.PACKAGE_NAME;
}
// check if upgrade needed
DatabaseHelper dbHelper = DatabaseHelper.getDatabaseHelper(context);
String deviceId = dbHelper.getValue(DEVICE_ID_KEY);
Long previousSessionId = dbHelper.getLongValue(PREVIOUS_SESSION_ID_KEY);
Long lastEventTime = dbHelper.getLongValue(LAST_EVENT_TIME_KEY);
if (!Utils.isEmptyString(deviceId) && previousSessionId != null && lastEventTime != null) {
return true;
}
String prefsName = sourcePkgName + "." + context.getPackageName();
SharedPreferences preferences =
context.getSharedPreferences(prefsName, Context.MODE_PRIVATE);
migrateStringValue(
preferences, Constants.PREFKEY_DEVICE_ID, null, dbHelper, DEVICE_ID_KEY
);
migrateLongValue(
preferences, Constants.PREFKEY_LAST_EVENT_TIME, -1, dbHelper, LAST_EVENT_TIME_KEY
);
migrateLongValue(
preferences, Constants.PREFKEY_LAST_EVENT_ID, -1, dbHelper, LAST_EVENT_ID_KEY
);
migrateLongValue(
preferences, Constants.PREFKEY_LAST_IDENTIFY_ID, -1, dbHelper, LAST_IDENTIFY_ID_KEY
);
migrateLongValue(
preferences, Constants.PREFKEY_PREVIOUS_SESSION_ID, -1,
dbHelper, PREVIOUS_SESSION_ID_KEY
);
migrateStringValue(
preferences, Constants.PREFKEY_USER_ID, null, dbHelper, USER_ID_KEY
);
migrateBooleanValue(
preferences, Constants.PREFKEY_OPT_OUT, false, dbHelper, OPT_OUT_KEY
);
return true;
}
private static void migrateLongValue(SharedPreferences prefs, String prefKey, long defValue, DatabaseHelper dbHelper, String dbKey) {
Long value = dbHelper.getLongValue(dbKey);
if (value != null) { // if value already exists don't need to migrate
return;
}
long oldValue = prefs.getLong(prefKey, defValue);
dbHelper.insertOrReplaceKeyLongValue(dbKey, oldValue);
prefs.edit().remove(prefKey).apply();
}
private static void migrateStringValue(SharedPreferences prefs, String prefKey, String defValue, DatabaseHelper dbHelper, String dbKey) {
String value = dbHelper.getValue(dbKey);
if (!Utils.isEmptyString(value)) {
return;
}
String oldValue = prefs.getString(prefKey, defValue);
if (!Utils.isEmptyString(oldValue)) {
dbHelper.insertOrReplaceKeyValue(dbKey, oldValue);
prefs.edit().remove(prefKey).apply();
}
}
private static void migrateBooleanValue(SharedPreferences prefs, String prefKey, boolean defValue, DatabaseHelper dbHelper, String dbKey) {
Long value = dbHelper.getLongValue(dbKey);
if (value != null) {
return;
}
boolean oldValue = prefs.getBoolean(prefKey, defValue);
dbHelper.insertOrReplaceKeyLongValue(dbKey, oldValue ? 1L : 0L);
prefs.edit().remove(prefKey).apply();
}
/**
* Internal method to fetch the current time millis. Used for testing.
*
* @return the current time millis
*/
protected long getCurrentTimeMillis() {
return System.currentTimeMillis();
}
}