
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.location.Location;
import android.os.Build;
import android.text.TextUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
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 {
private 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 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";
/**
* The default shared instance. This is fetched by {@code Rakam.getInstance()}
*/
protected static RakamClient instance = new RakamClient();
/**
* The Rakam API url that will store the events.
*/
private String apiUrl;
/**
* Gets the default RakamClient instance.
*
* @return the default instance
*/
public static RakamClient getInstance() {
return instance;
}
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 user's ID value.
*/
protected Object userId;
/**
* The user's Device ID value.
*/
protected String deviceId;
private boolean useAdvertisingIdForDeviceId = false;
private boolean initialized = false;
private boolean optOut = false;
private boolean offline = false;
private DeviceInfo deviceInfo;
/**
* The current session ID value.
*/
long sessionId = -1;
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 AtomicBoolean updateScheduled = new AtomicBoolean(false);
/**
* Whether or not the SDK is in the process of uploading events.
*/
AtomicBoolean uploadingEventsCurrently = new AtomicBoolean(false);
/**
* Whether or not the SDK is in the process of uploading identifys.
*/
AtomicBoolean uploadingIdentifysCurrently = new AtomicBoolean(false);
/**
* The last SDK error - used for testing.
*/
Throwable lastError;
String url = apiUrl == null ? Constants.DEFAULT_EVENT_LOG_URL : 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 RakamClient and starts worker threads.
*/
public RakamClient() {
logThread.start();
httpThread.start();
}
/**
* 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 apiKey your Rakam App API key
* @return the RakamClient
*/
public RakamClient initialize(Context context, String apiKey) {
return initialize(context, apiKey, null);
}
/**
* 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;
return this;
}
/**
* 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 apiKey your Rakam App API key
* @param userId the user id to set
* @return the RakamClient
*/
public synchronized RakamClient initialize(Context context, String apiKey, String userId) {
if (context == null) {
logger.e(TAG, "Argument context cannot be null in initialize()");
return this;
}
RakamClient.upgradePrefs(context);
RakamClient.upgradeSharedPrefsToDB(context);
if (TextUtils.isEmpty(apiKey)) {
logger.e(TAG, "Argument apiKey cannot be null or blank in initialize()");
return this;
}
if (!initialized) {
this.context = context.getApplicationContext();
this.httpClient = new OkHttpClient();
this.dbHelper = DatabaseHelper.getDatabaseHelper(this.context);
this.apiKey = apiKey;
initializeDeviceInfo();
if (userId != null) {
this.userId = userId;
dbHelper.insertOrReplaceKeyValue(USER_ID_KEY, userId);
} else {
this.userId = dbHelper.getValue(USER_ID_KEY);
}
Long optOut = dbHelper.getLongValue(OPT_OUT_KEY);
this.optOut = optOut != null && optOut == 1;
// try to restore previous session id
long previousSessionId = getPreviousSessionId();
if (previousSessionId >= 0) {
sessionId = previousSessionId;
}
initialized = true;
}
return this;
}
/**
* 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;
}
private void initializeDeviceInfo() {
deviceInfo = new DeviceInfo(context);
runOnLogThread(new Runnable() {
@Override
public void run() {
deviceId = initializeDeviceId();
deviceInfo.prefetch();
}
});
}
/**
* 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() {
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() {
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;
}
/**
* 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(boolean optOut) {
if (!contextAndApiKeySet("setOptOut()")) {
return this;
}
this.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;
}
/**
* 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, getCurrentTimeMillis(), 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) {
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) {
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() {
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);
}
}
JSONObject event = new JSONObject();
try {
JSONObject properties = new JSONObject();
properties.put("_time", timestamp);
properties.put("_user", replaceWithJSONNull(userId));
properties.put("_device_id", replaceWithJSONNull(deviceId));
properties.put("_session_id", outOfSession ? -1 : sessionId);
properties.put("_version_name", replaceWithJSONNull(deviceInfo.getVersionName()));
properties.put("_os_name", replaceWithJSONNull(deviceInfo.getOsName()));
properties.put("_os_version", replaceWithJSONNull(deviceInfo.getOsVersion()));
properties.put("_device_brand", replaceWithJSONNull(deviceInfo.getBrand()));
properties.put("_device_manufacturer", replaceWithJSONNull(deviceInfo.getManufacturer()));
properties.put("_device_model", replaceWithJSONNull(deviceInfo.getModel()));
properties.put("_carrier", replaceWithJSONNull(deviceInfo.getCarrier()));
properties.put("_country_code", replaceWithJSONNull(deviceInfo.getCountry()));
properties.put("_language", replaceWithJSONNull(deviceInfo.getLanguage()));
properties.put("_platform", Constants.PLATFORM);
properties.put("_library_name", Constants.LIBRARY);
properties.put("_library_version", Constants.VERSION);
Location location = deviceInfo.getMostRecentLocation();
if (location != null) {
properties.put("_latitude", location.getLatitude());
properties.put("_longitude", location.getLongitude());
}
if (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();
properties.put(next, eventProperties.get(next));
}
}
event.put("properties", truncate(properties));
event.put("collection", replaceWithJSONNull(eventType));
} catch (JSONException e) {
logger.e(TAG, e.toString());
}
return recordAction(event);
}
/**
* Save event log. Internal method to save an event to the database.
*
* @param event the event
* @return the event ID if succeeded, else -1
*/
protected long recordAction(JSONObject event) {
long eventId;
eventId = dbHelper.addEvent(event.toString());
setLastEventId(eventId);
int numEventsToRemove = Math.min(
Math.max(1, eventMaxCount / 10),
Constants.EVENT_REMOVE_BATCH_SIZE
);
if (dbHelper.getEventCount() > eventMaxCount) {
dbHelper.removeEvents(dbHelper.getNthEventId(numEventsToRemove));
}
long totalEventCount = dbHelper.getEventCount(); // counts may have changed, re-fetch
if ((totalEventCount % eventUploadThreshold) == 0 &&
totalEventCount >= eventUploadThreshold) {
syncEventsWithServer();
} else {
syncServerLater(eventUploadPeriodMillis);
}
return eventId;
}
// 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 get the last event time.
*
* @return the last event time
*/
long getLastEventTime() {
return getLongvalue(LAST_EVENT_TIME_KEY, -1);
}
/**
* Internal method to set the last event time.
*
* @param timestamp the timestamp
*/
void setLastEventTime(long timestamp) {
dbHelper.insertOrReplaceKeyLongValue(LAST_EVENT_TIME_KEY, timestamp);
}
/**
* Internal method to get the last event id.
*
* @return the last event id
*/
long getLastEventId() {
return getLongvalue(LAST_EVENT_ID_KEY, -1);
}
/**
* Internal method to set the last event id.
*
* @param eventId the event id
*/
void setLastEventId(long eventId) {
dbHelper.insertOrReplaceKeyLongValue(LAST_EVENT_ID_KEY, eventId);
}
/**
* Internal method to get the last identify id.
*
* @return the last identify id
*/
long getLastIdentifyId() {
return getLongvalue(LAST_IDENTIFY_ID_KEY, -1);
}
/**
* Internal method to set the last identify id.
*
* @param identifyId the identify id
*/
void setLastIdentifyId(long 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 get the previous session id.
*
* @return the previous session id
*/
long getPreviousSessionId() {
return getLongvalue(PREVIOUS_SESSION_ID_KEY, -1);
}
/**
* Internal method to set the previous session id.
*
* @param timestamp the timestamp
*/
void setPreviousSessionId(long 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)) {
long previousSessionId = getPreviousSessionId();
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 lastEventTime = getLastEventTime();
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;
}
long timestamp = getLastEventTime();
logEvent(sessionEvent, null, timestamp, 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() {
refreshSessionTime(timestamp);
inForeground = false;
}
});
}
/**
* 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() {
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.AMP_REVENUE_EVENT, revenue.toJSONObject());
}
/**
* Sets user properties. This is a convenience wrapper around the
* {@link 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;
}
runOnLogThread(new Runnable() {
@Override
public void run() {
// Create deep copy to try and prevent ConcurrentModificationException
JSONObject copy;
try {
copy = new JSONObject(userProperties.toString());
} catch (JSONException e) {
logger.e(TAG, e.toString());
return; // could not create copy
}
Identify identify = new Identify();
Iterator> keys = copy.keys();
while (keys.hasNext()) {
String key = (String) keys.next();
try {
identify.setUserProperty(key, copy.get(key));
} catch (JSONException e) {
logger.e(TAG, e.toString());
}
}
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);
}
/**
* Identify. Use this to send an {@link Identify} object containing
* user property operations to Rakam server.
*
* @param identify an {@link Identify} object
* @see Identify
* @see
* User Properties
*/
public void identify(final Identify identify) {
if (identify == null || identify.userPropertiesOperations.length() == 0
|| !contextAndApiKeySet("identify()")) {
return;
}
final String body;
try {
body = new JSONObject()
.put("api", getApi())
.put("data", truncate(identify.userPropertiesOperations))
.toString();
} catch (JSONException e) {
uploadingEventsCurrently.set(false);
logger.e(TAG, e.toString());
return;
}
recordIdentify(body);
}
protected long recordIdentify(String identify) {
long identifyId;
identifyId = dbHelper.addIdentify(identify);
setLastIdentifyId(identifyId);
int numEventsToRemove = Math.min(
Math.max(1, eventMaxCount / 10),
Constants.EVENT_REMOVE_BATCH_SIZE
);
if (dbHelper.getIdentifyCount() > eventMaxCount) {
dbHelper.removeIdentifys(dbHelper.getNthEventId(numEventsToRemove));
}
long totalEventCount = dbHelper.getIdentifyCount(); // counts may have changed, re-fetch
if ((totalEventCount % eventUploadThreshold) == 0 &&
totalEventCount >= eventUploadThreshold) {
syncEventsWithServer();
} else {
syncServerLater(eventUploadPeriodMillis);
}
return identifyId;
}
/**
* 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
*/
protected JSONObject truncate(JSONObject object) {
if (object == null) {
return null;
}
Iterator> keys = object.keys();
while (keys.hasNext()) {
String key = (String) keys.next();
try {
Object value = object.get(key);
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
*/
protected JSONArray truncate(JSONArray array) throws JSONException {
if (array == null) {
return null;
}
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
*/
protected 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(String userId) {
if (!contextAndApiKeySet("setUserId()")) {
return this;
}
this.userId = userId;
dbHelper.insertOrReplaceKeyValue(USER_ID_KEY, userId);
return this;
}
/**
* Sets the user id (can be null).
*
* @param userId the user id
* @return the RakamClient
*/
public RakamClient setUserId(long userId) {
if (!contextAndApiKeySet("setUserId()")) {
return this;
}
this.userId = userId;
dbHelper.insertOrReplaceKeyValue(USER_ID_KEY, userId);
return this;
}
/**
* Sets the user id (can be null).
*
* @param userId the user id
* @return the RakamClient
*/
public RakamClient setUserId(int userId) {
if (!contextAndApiKeySet("setUserId()")) {
return this;
}
this.userId = userId;
dbHelper.insertOrReplaceKeyValue(USER_ID_KEY, userId);
return this;
}
/**
* 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()") || TextUtils.isEmpty(deviceId) ||
invalidDeviceIds.contains(deviceId)) {
return this;
}
this.deviceId = deviceId;
dbHelper.insertOrReplaceKeyValue(DEVICE_ID_KEY, deviceId);
return this;
}
/**
* Force SDK to upload any unsent events.
*/
public void uploadEvents() {
if (!contextAndApiKeySet("uploadEvents()")) {
return;
}
logThread.post(new Runnable() {
@Override
public void run() {
syncEventsWithServer();
}
});
}
private void syncServerLater(long delayMillis) {
if (updateScheduled.getAndSet(true)) {
return;
}
logThread.postDelayed(new Runnable() {
@Override
public void run() {
updateScheduled.set(false);
syncEventsWithServer();
}
}, delayMillis);
}
/**
* Internal method to upload unsent events.
*/
protected void syncEventsWithServer() {
syncEventsWithServer(false);
}
/**
* 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 syncEventsWithServer(boolean limit) {
if (optOut || offline) {
return;
}
updateEvents(limit);
updateIdentifys(limit);
}
private void updateIdentifys(boolean limit) {
// if returning out of this block, always be sure to set uploadingIdentifysCurrently to false!!
if (!uploadingIdentifysCurrently.getAndSet(true)) {
long totalEventCount = dbHelper.getIdentifyCount();
long batchSize = Math.min(
limit ? backoffUploadBatchSize : eventUploadMaxBatchSize,
totalEventCount
);
if (batchSize <= 0) {
uploadingIdentifysCurrently.set(false);
return;
}
try {
final List events = dbHelper.getIdentifys(getLastIdentifyId(), batchSize);
if (events.size() == 0) {
uploadingIdentifysCurrently.set(false);
return;
}
int maxEventId = -1;
for (JSONObject event : events) {
maxEventId = Math.max(event.getInt("event_id"), maxEventId);
}
final int finalMaxEventId = maxEventId;
httpThread.post(new Runnable() {
@Override
public void run() {
makeEventUploadPostRequest(httpClient, Constants.USER_SET_PROPERTIES_ENDPOINT, cleanEventIds(events).toString(),
finalMaxEventId, new CleanerFunction() {
@Override
public void clean(long id) {
dbHelper.removeIdentifys(id);
}
});
}
});
} catch (JSONException e) {
uploadingEventsCurrently.set(false);
logger.e(TAG, e.toString());
}
}
}
private void updateEvents(boolean limit) {
// if returning out of this block, always be sure to set uploadingEventsCurrently to false!!
if (!uploadingEventsCurrently.getAndSet(true)) {
long totalEventCount = dbHelper.getEventCount();
long batchSize = Math.min(
limit ? backoffUploadBatchSize : eventUploadMaxBatchSize,
totalEventCount
);
if (batchSize <= 0) {
uploadingEventsCurrently.set(false);
return;
}
try {
final List events = dbHelper.getEvents(getLastEventId(), batchSize);
if (events.size() == 0) {
uploadingEventsCurrently.set(false);
return;
}
int maxEventId = -1;
for (JSONObject event : events) {
maxEventId = Math.max(event.getInt("event_id"), maxEventId);
}
final int finalMaxEventId = maxEventId;
httpThread.post(new Runnable() {
@Override
public void run() {
String body;
try {
body = new JSONObject().put("api", getApi()).put("events", cleanEventIds(events)).toString();
} catch (JSONException e) {
uploadingEventsCurrently.set(false);
logger.e(TAG, e.toString());
return;
}
makeEventUploadPostRequest(httpClient, Constants.EVENT_BATCH_ENDPOINT, body,
finalMaxEventId, new CleanerFunction() {
@Override
public void clean(long id) {
dbHelper.removeEvents(id);
}
});
}
});
} catch (JSONException e) {
uploadingEventsCurrently.set(false);
logger.e(TAG, e.toString());
}
}
}
private JSONObject getApi() throws JSONException {
return new JSONObject()
.put("api_key", null)
.put("library", new JSONObject()
.put("name", Constants.LIBRARY)
.put("version", Constants.VERSION))
.put("upload_time", getCurrentTimeMillis());
}
private JSONArray cleanEventIds(List list) {
JSONArray array = new JSONArray();
for (JSONObject event : list) {
event.remove("event_id");
array.put(event);
}
return array;
}
public interface CleanerFunction {
void clean(long id);
}
/**
* Internal method to generate the event upload post request.
*/
protected void makeEventUploadPostRequest(OkHttpClient client, String endpoint, String body, final long maxId, final CleanerFunction cleanerFunction) {
Request request = new Request.Builder()
.url(url + endpoint)
.post(RequestBody.create(JSON, body))
.build();
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 (maxId >= 0) cleanerFunction.clean(maxId);
uploadingEventsCurrently.set(false);
uploadingIdentifysCurrently.set(false);
if (dbHelper.getTotalEventCount() > eventUploadThreshold) {
logThread.post(new Runnable() {
@Override
public void run() {
syncEventsWithServer(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 (response.code() == 500) {
logger.w(TAG,
"Couldn't write to request database on server, will attempt to reupload later");
} else if (response.code() == 413) {
// If blocked by one massive event, drop it
if (backoffUpload && backoffUploadBatchSize == 1) {
if (maxId >= 0) cleanerFunction.clean(maxId);
// 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, "Request too large, will decrease size and attempt to reupload");
logThread.post(new Runnable() {
@Override
public void run() {
uploadingEventsCurrently.set(false);
uploadingIdentifysCurrently.set(false);
syncEventsWithServer(true);
}
});
} 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 bodyList");
lastError = e;
} catch (java.net.UnknownHostException e) {
// logger.w(TAG,
// "No internet connection found, unable to upload bodyList");
lastError = e;
} catch (IOException e) {
logger.e(TAG, e.toString());
lastError = e;
} catch (AssertionError e) {
// This can be caused by a NoSuchAlgorithmException thrown by DefaultHttpClient
logger.e(TAG, "Exception:", e);
lastError = e;
} catch (Exception e) {
// Just log any other exception so things don't crash on upload
logger.e(TAG, "Exception:", e);
lastError = e;
}
if (!uploadSuccess) {
uploadingEventsCurrently.set(false);
uploadingIdentifysCurrently.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("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);
if (!(TextUtils.isEmpty(deviceId) || invalidIds.contains(deviceId))) {
return deviceId;
}
if (useAdvertisingIdForDeviceId) {
// Android ID is deprecated by Google.
// We are required to use Advertising ID, and respect the advertising ID preference
String advertisingId = deviceInfo.getAdvertisingId();
if (!(TextUtils.isEmpty(advertisingId) || invalidIds.contains(advertisingId))) {
dbHelper.insertOrReplaceKeyValue(DEVICE_ID_KEY, 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";
dbHelper.insertOrReplaceKeyValue(DEVICE_ID_KEY, randomId);
return randomId;
}
private void runOnLogThread(Runnable r) {
if (Thread.currentThread() != logThread) {
logThread.post(r);
} else {
r.run();
}
}
public void setApiUrl(String apiUrl) {
this.apiUrl = apiUrl;
}
/**
* 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);
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
*/
private 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 (!TextUtils.isEmpty(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 (!TextUtils.isEmpty(value)) {
return;
}
String oldValue = prefs.getString(prefKey, defValue);
if (!TextUtils.isEmpty(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();
}
}