// @formatter:off /* * LocalyticsSession.java Copyright (C) 2011 Char Software Inc., DBA Localytics This code is provided under the Localytics * Modified BSD License. A copy of this license has been distributed in a file called LICENSE with this source code. Please visit * www.localytics.com for more information. */ // @formatter:on package com.localytics.android; import android.Manifest.permission; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.CursorJoiner; import android.os.Build; import android.os.Build.VERSION; import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.os.Message; import android.os.SystemClock; import android.telephony.TelephonyManager; import android.text.TextUtils; import android.text.format.DateUtils; import android.util.Log; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.TreeMap; import java.util.UUID; import java.util.zip.GZIPOutputStream; import org.apache.http.HttpResponse; import org.apache.http.StatusLine; import org.apache.http.client.ClientProtocolException; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.ByteArrayEntity; import org.apache.http.impl.client.DefaultHttpClient; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import com.localytics.android.JsonObjects.BlobHeader; import com.localytics.android.LocalyticsProvider.ApiKeysDbColumns; import com.localytics.android.LocalyticsProvider.AttributesDbColumns; import com.localytics.android.LocalyticsProvider.EventHistoryDbColumns; import com.localytics.android.LocalyticsProvider.EventsDbColumns; import com.localytics.android.LocalyticsProvider.SessionsDbColumns; import com.localytics.android.LocalyticsProvider.UploadBlobEventsDbColumns; import com.localytics.android.LocalyticsProvider.UploadBlobsDbColumns; /** * This class manages creating, collecting, and uploading a Localytics session. Please see the following guides for information on * how to best use this library, sample code, and other useful information: *
* Permissions required: *
* This library will create a database called "com.android.localytics.sqlite" within the host application's * {@link Context#getDatabasePath(String)} directory. For security, this file directory will be created * {@link Context#MODE_PRIVATE}. The host application must not modify this database file. If the host application implements a * backup/restore mechanism, such as {@code android.app.backup.BackupManager}, the host application should not worry about backing * up the data in the Localytics database. *
** This library is thread-safe but is not multi-process safe. Unless the application explicitly uses different process attributes * in the Android Manifest, this is not an issue. *
* Best Practices ** This class is thread-safe. * * @version 2.0 */ public final class LocalyticsSession { /* * DESIGN NOTES * * The LocalyticsSession stores all of its state as a SQLite database in the parent application's private database storage * directory. * * Every action performed within (open, close, opt-in, opt-out, customer events) are all treated as events by the library. * Events are given a package prefix to ensure a namespace without collisions. Events internal to the library are flagged with * the Localytics package name, while events from the customer's code are flagged with the customer's package name. There's no * need to worry about the customer changing the package name and disrupting the naming convention, as changing the package * name means that a new user is created in Android and the app with a new package name gets its own storage directory. * * * MULTI-THREADING * * The LocalyticsSession stores all of its state as a SQLite database in the parent application's private database storage * directory. Disk access is slow and can block the UI in Android, so the LocalyticsSession object is a wrapper around a pair * of Handler objects, with each Handler object running on its own separate thread. * * All requests made of the LocalyticsSession are passed along to the mSessionHandler object, which does most of the work. The * mSessionHandler will pass off upload requests to the mUploadHandler, to prevent the mSessionHandler from being blocked by * network traffic. * * If an upload request is made, the mSessionHandler will set a flag that an upload is in progress (this flag is important for * thread-safety of the session data stored on disk). Then the upload request is passed to the mUploadHandler's queue. If a * second upload request is made while the first one is underway, the mSessionHandler notifies the mUploadHandler, which will * notify the mSessionHandler to retry that upload request when the first upload is completed. * * Although each LocalyticsSession object will have its own unique instance of mSessionHandler, thread-safety is handled by * using a single sSessionHandlerThread. */ /** * Format string for events */ /* package */static final String EVENT_FORMAT = "%s:%s"; //$NON-NLS-1$ /** * Open event */ /* package */static final String OPEN_EVENT = String.format(EVENT_FORMAT, Constants.LOCALYTICS_PACKAGE_NAME, "open"); //$NON-NLS-1$ /** * Close event */ /* package */static final String CLOSE_EVENT = String.format(EVENT_FORMAT, Constants.LOCALYTICS_PACKAGE_NAME, "close"); //$NON-NLS-1$ /** * Opt-in event */ /* package */static final String OPT_IN_EVENT = String.format(EVENT_FORMAT, Constants.LOCALYTICS_PACKAGE_NAME, "opt_in"); //$NON-NLS-1$ /** * Opt-out event */ /* package */static final String OPT_OUT_EVENT = String.format(EVENT_FORMAT, Constants.LOCALYTICS_PACKAGE_NAME, "opt_out"); //$NON-NLS-1$ /** * Flow event */ /* package */static final String FLOW_EVENT = String.format(EVENT_FORMAT, Constants.LOCALYTICS_PACKAGE_NAME, "flow"); //$NON-NLS-1$ /** * Background thread used for all Localytics session processing. This thread is shared across all instances of * LocalyticsSession within a process. */ /* * By using the class name for the HandlerThread, obfuscation through Proguard is more effective: if Proguard changes the * class name, the thread name also changes. */ private static final HandlerThread sSessionHandlerThread = getHandlerThread(SessionHandler.class.getSimpleName()); /** * Background thread used for all Localytics upload processing. This thread is shared across all instances of * LocalyticsSession within a process. */ /* * By using the class name for the HandlerThread, obfuscation through Proguard is more effective: if Proguard changes the * class name, the thread name also changes. */ private static final HandlerThread sUploadHandlerThread = getHandlerThread(UploadHandler.class.getSimpleName()); /** * Helper to obtain a new {@link HandlerThread}. * * @param name to give to the HandlerThread. Useful for debugging, as the thread name is shown in DDMS. * @return HandlerThread whose {@link HandlerThread#start()} method has already been called. */ private static HandlerThread getHandlerThread(final String name) { final HandlerThread thread = new HandlerThread(name, android.os.Process.THREAD_PRIORITY_BACKGROUND); thread.start(); /* * The exception handler needs to be set after start() is called. If it is set before, sometime's the HandlerThread's * looper is null. This appears to be a bug in Android. */ thread.setUncaughtExceptionHandler(new ExceptionHandler()); return thread; } /** * Handler object where all session requests of this instance of LocalyticsSession are handed off to. *
* This Handler is the key thread synchronization point for all work inside the LocalyticsSession. *
* This handler runs on {@link #sSessionHandlerThread}. */ private final Handler mSessionHandler; /** * Application context */ private final Context mContext; /** * Localytics application key */ private final String mLocalyticsKey; /** * Keeps track of which Localytics clients are currently uploading, in order to allow only one upload for a given key at a * time. *
* This field can only be read/written to from the {@link #sSessionHandlerThread}. This invariant is maintained by only
* accessing this field from within the {@link #mSessionHandler}.
*/
private static Map
* If for any reason this is called more than once without an intervening call to {@link #close()}, subsequent calls to open
* will be ignored.
*
* For applications with multiple Activities, every Activity should call
* This message must be sent before any other messages.
*/
public static final int MESSAGE_INIT = 0;
/**
* Empty handler message to open a localytics session
*/
public static final int MESSAGE_OPEN = 1;
/**
* Empty handler message to close a localytics session
*/
public static final int MESSAGE_CLOSE = 2;
/**
* Handler message to tag an event.
*
* {@link Message#obj} is a {@link Pair} instance. This object cannot be null.
*/
public static final int MESSAGE_TAG_EVENT = 3;
/**
* Handler message to upload all data collected so far
*
* {@link Message#obj} is a {@code Runnable} to execute when upload is complete. The thread that this runnable will
* executed on is undefined.
*/
public static final int MESSAGE_UPLOAD = 4;
/**
* Empty Handler message indicating that a previous upload attempt was completed.
*/
public static final int MESSAGE_UPLOAD_COMPLETE = 5;
/**
* Handler message indicating an opt-out choice.
*
* {@link Message#arg1} == 1 for true (opt out). 0 means opt-in.
*/
public static final int MESSAGE_OPT_OUT = 6;
/**
* Handler message indicating a tag screen event
*
* {@link Message#obj} is a string representing the screen visited.
*/
public static final int MESSAGE_TAG_SCREEN = 7;
/**
* Sort order for the upload blobs.
*
* This is a workaround for Android bug 3707
* This is a workaround for Android bug 3707
* This handler runs on {@link #sUploadHandlerThread}.
*/
private Handler mUploadHandler;
/**
* Constructs a new Handler that runs on the given looper.
*
* @param context The context used to access resources on behalf of the app. It is recommended to use
* {@link Context#getApplicationContext()} to avoid the potential memory leak incurred by maintaining
* references to {@code Activity} instances. Cannot be null.
* @param key The key unique for each application generated at www.localytics.com. Cannot be null or empty.
* @param looper to run the Handler on. Cannot be null.
* @throws IllegalArgumentException if {@code context} is null
* @throws IllegalArgumentException if {@code key} is null or empty
*/
public SessionHandler(final Context context, final String key, final Looper looper)
{
super(looper);
if (Constants.ENABLE_PARAMETER_CHECKING)
{
if (context == null)
{
throw new IllegalArgumentException("context cannot be null"); //$NON-NLS-1$
}
if (TextUtils.isEmpty(key))
{
throw new IllegalArgumentException("key cannot be null or empty"); //$NON-NLS-1$
}
}
mContext = context;
mApiKey = key;
}
@Override
public void handleMessage(final Message msg)
{
switch (msg.what)
{
case MESSAGE_INIT:
{
if (Constants.IS_LOGGABLE)
{
Log.v(Constants.LOG_TAG, "Handler received MESSAGE_INIT"); //$NON-NLS-1$
}
init();
break;
}
case MESSAGE_OPT_OUT:
{
if (Constants.IS_LOGGABLE)
{
Log.v(Constants.LOG_TAG, "Handler received MESSAGE_OPT_OUT"); //$NON-NLS-1$
}
final boolean isOptingOut = msg.arg1 == 0 ? false : true;
SessionHandler.this.optOut(isOptingOut);
break;
}
case MESSAGE_OPEN:
{
if (Constants.IS_LOGGABLE)
{
Log.v(Constants.LOG_TAG, "Handler received MESSAGE_OPEN"); //$NON-NLS-1$
}
SessionHandler.this.open(false);
break;
}
case MESSAGE_CLOSE:
{
if (Constants.IS_LOGGABLE)
{
Log.d(Constants.LOG_TAG, "Handler received MESSAGE_CLOSE"); //$NON-NLS-1$
}
SessionHandler.this.close();
break;
}
case MESSAGE_TAG_EVENT:
{
if (Constants.IS_LOGGABLE)
{
Log.d(Constants.LOG_TAG, "Handler received MESSAGE_TAG"); //$NON-NLS-1$
}
@SuppressWarnings("unchecked")
final Pair
* This method must only be called once.
*
* Note: This method is a private implementation detail. It is only made public for unit testing purposes. The public
* interface is to send {@link #MESSAGE_INIT} to the Handler.
*
* @see #MESSAGE_INIT
*/
public void init()
{
mProvider = LocalyticsProvider.getInstance(mContext, mApiKey);
/*
* Check whether this session key is opted out
*/
Cursor cursor = null;
try
{
cursor = mProvider.query(ApiKeysDbColumns.TABLE_NAME, new String[]
{
ApiKeysDbColumns._ID,
ApiKeysDbColumns.OPT_OUT }, String.format("%s = ?", ApiKeysDbColumns.API_KEY), new String[] //$NON-NLS-1$
{ mApiKey }, null);
if (cursor.moveToFirst())
{
// API key was previously created
if (Constants.IS_LOGGABLE)
{
Log.v(Constants.LOG_TAG, String.format("Loading details for API key %s", mApiKey)); //$NON-NLS-1$
}
mApiKeyId = cursor.getLong(cursor.getColumnIndexOrThrow(ApiKeysDbColumns._ID));
mIsOptedOut = cursor.getInt(cursor.getColumnIndexOrThrow(ApiKeysDbColumns.OPT_OUT)) != 0;
}
else
{
// perform first-time initialization of API key
if (Constants.IS_LOGGABLE)
{
Log.v(Constants.LOG_TAG, String.format("Performing first-time initialization for new API key %s", mApiKey)); //$NON-NLS-1$
}
final ContentValues values = new ContentValues();
values.put(ApiKeysDbColumns.API_KEY, mApiKey);
values.put(ApiKeysDbColumns.UUID, UUID.randomUUID().toString());
values.put(ApiKeysDbColumns.OPT_OUT, Boolean.FALSE);
values.put(ApiKeysDbColumns.CREATED_TIME, Long.valueOf(System.currentTimeMillis()));
mApiKeyId = mProvider.insert(ApiKeysDbColumns.TABLE_NAME, values);
}
}
finally
{
if (cursor != null)
{
cursor.close();
cursor = null;
}
}
if (!sIsUploadingMap.containsKey(mApiKey))
{
sIsUploadingMap.put(mApiKey, Boolean.FALSE);
}
/*
* Perform lazy initialization of the UploadHandler
*/
mUploadHandler = new UploadHandler(mContext, this, mApiKey, sUploadHandlerThread.getLooper());
}
/**
* Set the opt-in/out-out state for all sessions using the current API key.
*
* This method must only be called after {@link #init()} is called.
*
* Note: This method is a private implementation detail. It is only made package accessible for unit testing purposes. The
* public interface is to send {@link #MESSAGE_OPT_OUT} to the Handler.
*
* @param isOptingOut true if the user is opting out. False if the user is opting back in.
* @see #MESSAGE_OPT_OUT
*/
/* package */void optOut(final boolean isOptingOut)
{
if (Constants.IS_LOGGABLE)
{
Log.v(Constants.LOG_TAG, String.format("Prior opt-out state is %b, requested opt-out state is %b", Boolean.valueOf(mIsOptedOut), Boolean.valueOf(isOptingOut))); //$NON-NLS-1$
}
// Do nothing if opt-out is unchanged
if (mIsOptedOut == isOptingOut)
{
return;
}
mProvider.runBatchTransaction(new Runnable()
{
@Override
public void run()
{
final ContentValues values = new ContentValues();
values.put(ApiKeysDbColumns.OPT_OUT, Boolean.valueOf(isOptingOut));
mProvider.update(ApiKeysDbColumns.TABLE_NAME, values, String.format("%s = ?", ApiKeysDbColumns._ID), new String[] { Long.toString(mApiKeyId) }); //$NON-NLS-1$
if (!mIsSessionOpen)
{
/*
* Force a session to contain the opt event
*/
open(true);
tagEvent(isOptingOut ? OPT_OUT_EVENT : OPT_IN_EVENT, null);
close();
}
else
{
tagEvent(isOptingOut ? OPT_OUT_EVENT : OPT_IN_EVENT, null);
}
}
});
/*
* Update the in-memory representation. It is important for the in-memory representation to be updated after the
* on-disk representation, just in case the database update fails.
*/
mIsOptedOut = isOptingOut;
}
/**
* Open a session. While this method should only be called once without an intervening call to {@link #close()}, nothing
* bad will happen if it is called multiple times.
*
* This method must only be called after {@link #init()} is called.
*
* Note: This method is a private implementation detail. It is only made package accessible for unit testing purposes. The
* public interface is to send {@link #MESSAGE_OPEN} to the Handler.
*
* @param ignoreLimits true to ignore limits on the number of sessions. False to enforce limits.
* @see #MESSAGE_OPEN
*/
/* package */void open(final boolean ignoreLimits)
{
if (mIsSessionOpen)
{
if (Constants.IS_LOGGABLE)
{
Log.w(Constants.LOG_TAG, "Session was already open"); //$NON-NLS-1$
}
return;
}
if (mIsOptedOut)
{
if (Constants.IS_LOGGABLE)
{
Log.d(Constants.LOG_TAG, "Data collection is opted out"); //$NON-NLS-1$
}
return;
}
/*
* There are two cases: 1. New session and 2. Re-connect to old session. There are two ways to reconnect to an old
* session. One is by the age of the close event, and the other is by the age of the open event.
*/
long closeEventId = -1; // sentinel value
{
Cursor eventsCursor = null;
Cursor blob_eventsCursor = null;
try
{
eventsCursor = mProvider.query(EventsDbColumns.TABLE_NAME, new String[]
{ EventsDbColumns._ID }, String.format("%s = ? AND %s >= ?", EventsDbColumns.EVENT_NAME, EventsDbColumns.WALL_TIME), new String[] { CLOSE_EVENT, Long.toString(System.currentTimeMillis() - Constants.SESSION_EXPIRATION) }, EVENTS_SORT_ORDER); //$NON-NLS-1$
blob_eventsCursor = mProvider.query(UploadBlobEventsDbColumns.TABLE_NAME, new String[]
{ UploadBlobEventsDbColumns.EVENTS_KEY_REF }, null, null, UPLOAD_BLOBS_EVENTS_SORT_ORDER);
final int idColumn = eventsCursor.getColumnIndexOrThrow(EventsDbColumns._ID);
final CursorJoiner joiner = new CursorJoiner(eventsCursor, new String[]
{ EventsDbColumns._ID }, blob_eventsCursor, new String[]
{ UploadBlobEventsDbColumns.EVENTS_KEY_REF });
for (final CursorJoiner.Result joinerResult : joiner)
{
switch (joinerResult)
{
case LEFT:
{
if (-1 != closeEventId)
{
/*
* This should never happen
*/
if (Constants.IS_LOGGABLE)
{
Log.w(Constants.LOG_TAG, "There were multiple close events within SESSION_EXPIRATION"); //$NON-NLS-1$
}
long newClose = eventsCursor.getLong(eventsCursor.getColumnIndexOrThrow(EventsDbColumns._ID));
if (newClose > closeEventId)
{
closeEventId = newClose;
}
}
if (-1 == closeEventId)
{
closeEventId = eventsCursor.getLong(idColumn);
}
break;
}
case BOTH:
break;
case RIGHT:
break;
}
}
/*
* Verify that the session hasn't already been flagged for upload. That could happen if
*/
}
finally
{
if (eventsCursor != null)
{
eventsCursor.close();
}
if (blob_eventsCursor != null)
{
blob_eventsCursor.close();
}
}
}
if (-1 != closeEventId)
{
Log.v(Constants.LOG_TAG, "Opening old closed session and reconnecting"); //$NON-NLS-1$
mIsSessionOpen = true;
openClosedSession(closeEventId);
}
else
{
Cursor sessionsCursor = null;
try
{
sessionsCursor = mProvider.query(SessionsDbColumns.TABLE_NAME, new String[]
{
SessionsDbColumns._ID,
SessionsDbColumns.SESSION_START_WALL_TIME }, null, null, SessionsDbColumns._ID);
if (sessionsCursor.moveToLast())
{
if (sessionsCursor.getLong(sessionsCursor.getColumnIndexOrThrow(SessionsDbColumns.SESSION_START_WALL_TIME)) >= System.currentTimeMillis()
- Constants.SESSION_EXPIRATION)
{
// reconnect
Log.v(Constants.LOG_TAG, "Opening old unclosed session and reconnecting"); //$NON-NLS-1$
mIsSessionOpen = true;
mSessionId = sessionsCursor.getLong(sessionsCursor.getColumnIndexOrThrow(SessionsDbColumns._ID));
return;
}
// delete empties
Cursor eventsCursor = null;
try
{
String sessionId = Long.toString(sessionsCursor.getLong(sessionsCursor.getColumnIndexOrThrow(SessionsDbColumns._ID)));
eventsCursor = mProvider.query(EventsDbColumns.TABLE_NAME, new String[]
{ EventsDbColumns._ID }, String.format("%s = ?", EventsDbColumns.SESSION_KEY_REF), new String[] //$NON-NLS-1$
{ sessionId }, null);
if (eventsCursor.getCount() == 0)
{
mProvider.delete(SessionsDbColumns.TABLE_NAME, String.format("%s = ?", SessionsDbColumns._ID), new String[] { sessionId }); //$NON-NLS-1$
}
}
finally
{
if (null != eventsCursor)
{
eventsCursor.close();
eventsCursor = null;
}
}
}
}
finally
{
if (null != sessionsCursor)
{
sessionsCursor.close();
sessionsCursor = null;
}
}
/*
* Check that the maximum number of sessions hasn't been exceeded
*/
if (!ignoreLimits)
{
Cursor cursor = null;
try
{
cursor = mProvider.query(SessionsDbColumns.TABLE_NAME, new String[]
{ SessionsDbColumns._ID }, null, null, null);
if (cursor.getCount() >= Constants.MAX_NUM_SESSIONS)
{
if (Constants.IS_LOGGABLE)
{
Log.w(Constants.LOG_TAG, "Maximum number of sessions are already on disk--not writing any new sessions until old sessions are cleared out. Try calling upload() to store more sessions."); //$NON-NLS-1$
}
return;
}
}
finally
{
if (cursor != null)
{
cursor.close();
cursor = null;
}
}
}
Log.v(Constants.LOG_TAG, "Opening new session"); //$NON-NLS-1$
mIsSessionOpen = true;
openNewSession();
}
}
/**
* Opens a new session. This is a helper method to {@link #open(boolean)}.
*
* @effects Updates the database by creating a new entry in the {@link SessionsDbColumns} table.
*/
private void openNewSession()
{
final TelephonyManager telephonyManager = (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE);
final ContentValues values = new ContentValues();
values.put(SessionsDbColumns.API_KEY_REF, Long.valueOf(mApiKeyId));
values.put(SessionsDbColumns.SESSION_START_WALL_TIME, Long.valueOf(System.currentTimeMillis()));
values.put(SessionsDbColumns.UUID, UUID.randomUUID().toString());
values.put(SessionsDbColumns.APP_VERSION, DatapointHelper.getAppVersion(mContext));
values.put(SessionsDbColumns.ANDROID_SDK, Integer.valueOf(Constants.CURRENT_API_LEVEL));
values.put(SessionsDbColumns.ANDROID_VERSION, VERSION.RELEASE);
// Try and get the deviceId. If it is unavailable (or invalid) use the installation ID instead.
String deviceId = DatapointHelper.getAndroidIdHashOrNull(mContext);
if (deviceId == null)
{
Cursor cursor = null;
try
{
cursor = mProvider.query(ApiKeysDbColumns.TABLE_NAME, null, String.format("%s = ?", ApiKeysDbColumns.API_KEY), new String[] { mApiKey }, null); //$NON-NLS-1$
if (cursor.moveToFirst())
{
deviceId = cursor.getString(cursor.getColumnIndexOrThrow(ApiKeysDbColumns.UUID));
}
}
finally
{
if (null != cursor)
{
cursor.close();
cursor = null;
}
}
}
values.put(SessionsDbColumns.DEVICE_ANDROID_ID_HASH, deviceId);
values.put(SessionsDbColumns.DEVICE_COUNTRY, telephonyManager.getSimCountryIso());
values.put(SessionsDbColumns.DEVICE_MANUFACTURER, DatapointHelper.getManufacturer());
values.put(SessionsDbColumns.DEVICE_MODEL, Build.MODEL);
values.put(SessionsDbColumns.DEVICE_SERIAL_NUMBER_HASH, DatapointHelper.getSerialNumberHashOrNull());
values.put(SessionsDbColumns.DEVICE_TELEPHONY_ID, DatapointHelper.getTelephonyDeviceIdOrNull(mContext));
values.put(SessionsDbColumns.DEVICE_TELEPHONY_ID_HASH, DatapointHelper.getTelephonyDeviceIdHashOrNull(mContext));
values.put(SessionsDbColumns.LOCALE_COUNTRY, Locale.getDefault().getCountry());
values.put(SessionsDbColumns.LOCALE_LANGUAGE, Locale.getDefault().getLanguage());
values.put(SessionsDbColumns.LOCALYTICS_LIBRARY_VERSION, Constants.LOCALYTICS_CLIENT_LIBRARY_VERSION);
values.putNull(SessionsDbColumns.LATITUDE);
values.putNull(SessionsDbColumns.LONGITUDE);
values.put(SessionsDbColumns.NETWORK_CARRIER, telephonyManager.getNetworkOperatorName());
values.put(SessionsDbColumns.NETWORK_COUNTRY, telephonyManager.getNetworkCountryIso());
values.put(SessionsDbColumns.NETWORK_TYPE, DatapointHelper.getNetworkType(mContext, telephonyManager));
mProvider.runBatchTransaction(new Runnable()
{
@Override
public void run()
{
mSessionId = mProvider.insert(SessionsDbColumns.TABLE_NAME, values);
if (mSessionId == -1)
{
throw new RuntimeException("session insert failed"); //$NON-NLS-1$
}
tagEvent(OPEN_EVENT, null);
}
});
/*
* This is placed here so that the DatapointHelper has a chance to retrieve the old UUID before it is deleted.
*/
LocalyticsProvider.deleteOldFiles(mContext);
}
/**
* Reopens a previous session. This is a helper method to {@link #open(boolean)}.
*
* @param closeEventId The last close event which is to be deleted so that the old session can be reopened
* @effects Updates the database by deleting the last close event and sets {@link #mSessionId} to the session id of the
* last close event
*/
private void openClosedSession(final long closeEventId)
{
Cursor eventCursor = null;
try
{
eventCursor = mProvider.query(EventsDbColumns.TABLE_NAME, new String[]
{ EventsDbColumns.SESSION_KEY_REF }, String.format("%s = ?", EventsDbColumns._ID), new String[] { Long.toString(closeEventId) }, null); //$NON-NLS-1$
if (eventCursor.moveToFirst())
{
mSessionId = eventCursor.getLong(eventCursor.getColumnIndexOrThrow(EventsDbColumns.SESSION_KEY_REF));
mProvider.delete(EventsDbColumns.TABLE_NAME, String.format("%s = ?", EventsDbColumns._ID), new String[] { Long.toString(closeEventId) }); //$NON-NLS-1$
}
else
{
/*
* This should never happen
*/
if (Constants.IS_LOGGABLE)
{
Log.e(Constants.LOG_TAG, "Event no longer exists"); //$NON-NLS-1$
}
openNewSession();
}
}
finally
{
if (eventCursor != null)
{
eventCursor.close();
}
}
}
/**
* Close a session. While this method should only be called after {@link #open(boolean)}, nothing bad will happen if it is
* called and {@link #open(boolean)} wasn't called. Similarly, nothing bad will happen if close is called multiple times.
*
* This method must only be called after {@link #init()} is called.
*
* Note: This method is a private implementation detail. It is only made package accessible for unit testing purposes. The
* public interface is to send {@link #MESSAGE_CLOSE} to the Handler.
*
* @see #MESSAGE_OPEN
*/
/* package */void close()
{
if (!mIsSessionOpen) // do nothing if session is not open
{
if (Constants.IS_LOGGABLE)
{
Log.w(Constants.LOG_TAG, "Session was not open, so close is not possible."); //$NON-NLS-1$
}
return;
}
tagEvent(CLOSE_EVENT, null);
mIsSessionOpen = false;
}
/**
* Tag an event in a session. While this method shouldn't be called unless {@link #open(boolean)} is called first, this
* method will simply do nothing if {@link #open(boolean)} hasn't been called.
*
* This method must only be called after {@link #init()} is called.
*
* Note: This method is a private implementation detail. It is only made package accessible for unit testing purposes. The
* public interface is to send {@link #MESSAGE_TAG_EVENT} to the Handler.
*
* @param event The name of the event which occurred.
* @param attributes The collection of attributes for this particular event. If this parameter is null, then calling this
* method has the same effect as calling {@link #tagEvent(String)}.
* @see #MESSAGE_TAG_EVENT
*/
/* package */void tagEvent(final String event, final Map
* This method performs duplicate suppression, preventing multiple screens with the same value in a row within a given
* session.
*
* This method must only be called after {@link #init()} is called.
*
* Note: This method is a private implementation detail. It is only made public for unit testing purposes. The public
* interface is to send {@link #MESSAGE_TAG_SCREEN} to the Handler.
*
* @param screen The name of the screen which occurred. Cannot be null or empty.
* @see #MESSAGE_TAG_SCREEN
*/
/* package */void tagScreen(final String screen)
{
if (!mIsSessionOpen)
{
if (Constants.IS_LOGGABLE)
{
Log.w(Constants.LOG_TAG, "Tag not written because the session was not open"); //$NON-NLS-1$
}
return;
}
/*
* Do duplicate suppression
*/
Cursor cursor = null;
try
{
cursor = mProvider.query(EventHistoryDbColumns.TABLE_NAME, new String[]
{ EventHistoryDbColumns.NAME }, String.format("%s = ? AND %s = ?", EventHistoryDbColumns.TYPE, EventHistoryDbColumns.SESSION_KEY_REF), new String[] { Integer.toString(EventHistoryDbColumns.TYPE_SCREEN), Long.toString(mSessionId) }, String.format("%s DESC", EventHistoryDbColumns._ID)); //$NON-NLS-1$ //$NON-NLS-2$
if (cursor.moveToFirst())
{
if (screen.equals(cursor.getString(cursor.getColumnIndexOrThrow(EventHistoryDbColumns.NAME))))
{
if (Constants.IS_LOGGABLE)
{
Log.v(Constants.LOG_TAG, String.format("Suppressed duplicate screen %s", screen)); //$NON-NLS-1$
}
return;
}
}
}
finally
{
if (null != cursor)
{
cursor.close();
cursor = null;
}
}
/*
* Write the screen to the database
*/
final ContentValues values = new ContentValues();
values.put(EventHistoryDbColumns.NAME, screen);
values.put(EventHistoryDbColumns.TYPE, Integer.valueOf(EventHistoryDbColumns.TYPE_SCREEN));
values.put(EventHistoryDbColumns.SESSION_KEY_REF, Long.valueOf(mSessionId));
values.putNull(EventHistoryDbColumns.PROCESSED_IN_BLOB);
mProvider.insert(EventHistoryDbColumns.TABLE_NAME, values);
conditionallyAddFlowEvent();
}
/**
* Conditionally adds a flow event if no flow event exists in the current upload blob.
*/
private void conditionallyAddFlowEvent()
{
/*
* Creating a flow "event" is required to act as a placeholder so that the uploader will know that an upload needs to
* occur. A flow event should only be created if there isn't already a flow event that hasn't been associated with an
* upload blob.
*/
boolean foundUnassociatedFlowEvent = false;
Cursor eventsCursor = null;
Cursor blob_eventsCursor = null;
try
{
eventsCursor = mProvider.query(EventsDbColumns.TABLE_NAME, new String[]
{ EventsDbColumns._ID }, String.format("%s = ?", EventsDbColumns.EVENT_NAME), new String[] //$NON-NLS-1$
{ FLOW_EVENT }, EVENTS_SORT_ORDER);
blob_eventsCursor = mProvider.query(UploadBlobEventsDbColumns.TABLE_NAME, new String[]
{ UploadBlobEventsDbColumns.EVENTS_KEY_REF }, null, null, UPLOAD_BLOBS_EVENTS_SORT_ORDER);
final CursorJoiner joiner = new CursorJoiner(eventsCursor, new String[]
{ EventsDbColumns._ID }, blob_eventsCursor, new String[]
{ UploadBlobEventsDbColumns.EVENTS_KEY_REF });
for (final CursorJoiner.Result joinerResult : joiner)
{
switch (joinerResult)
{
case LEFT:
{
foundUnassociatedFlowEvent = true;
break;
}
case BOTH:
break;
case RIGHT:
break;
}
}
}
finally
{
if (eventsCursor != null)
{
eventsCursor.close();
eventsCursor = null;
}
if (blob_eventsCursor != null)
{
blob_eventsCursor.close();
blob_eventsCursor = null;
}
}
if (!foundUnassociatedFlowEvent)
{
tagEvent(FLOW_EVENT, null);
}
}
/**
* Builds upload blobs for all events.
*
* @effects Mutates the database by creating a new upload blob for all events that are unassociated at the time this
* method is called.
*/
/* package */void preUploadBuildBlobs()
{
/*
* Group all events that aren't part of an upload blob into a new blob. While this process is a linear algorithm that
* requires scanning two database tables, the performance won't be a problem for two reasons: 1. This process happens
* frequently so the number of events to group will always be low. 2. There is a maximum number of events, keeping the
* overall size low. Note that close events that are younger than SESSION_EXPIRATION will be skipped to allow session
* reconnects.
*/
// temporary set of event ids that aren't in a blob
final Set
* This method must only be called after {@link #init()} is called. The session does not need to be open for an upload to
* occur.
*
* Note: This method is a private implementation detail. It is only made package accessible for unit testing purposes. The
* public interface is to send {@link #MESSAGE_UPLOAD} to the Handler.
*
* @param callback An optional callback to perform once the upload completes. May be null for no callback.
* @see #MESSAGE_UPLOAD
*/
/* package */void upload(final Runnable callback)
{
if (sIsUploadingMap.get(mApiKey).booleanValue())
{
if (Constants.IS_LOGGABLE)
{
Log.d(Constants.LOG_TAG, "Already uploading"); //$NON-NLS-1$
}
mUploadHandler.sendMessage(mUploadHandler.obtainMessage(UploadHandler.MESSAGE_RETRY_UPLOAD_REQUEST, callback));
return;
}
try
{
mProvider.runBatchTransaction(new Runnable()
{
public void run()
{
preUploadBuildBlobs();
}
});
sIsUploadingMap.put(mApiKey, Boolean.TRUE);
mUploadHandler.sendMessage(mUploadHandler.obtainMessage(UploadHandler.MESSAGE_UPLOAD, callback));
}
catch (final Exception e)
{
if (Constants.IS_LOGGABLE)
{
Log.w(Constants.LOG_TAG, "Error occurred during upload", e); //$NON-NLS-1$
}
sIsUploadingMap.put(mApiKey, Boolean.FALSE);
// Notify the caller the upload is "complete"
if (callback != null)
{
/*
* Note that a new thread is created for the callback. This ensures that client code can't affect the
* performance of the SessionHandler's thread.
*/
new Thread(callback, UploadHandler.UPLOAD_CALLBACK_THREAD_NAME).start();
}
}
}
}
/**
* Helper object to the {@link SessionHandler} which helps process upload requests.
*/
/* package */static final class UploadHandler extends Handler
{
/**
* Thread name that the upload callback runnable is executed on.
*/
private static final String UPLOAD_CALLBACK_THREAD_NAME = "upload_callback"; //$NON-NLS-1$
/**
* Localytics upload URL, as a format string that contains a format for the API key.
*/
private final static String ANALYTICS_URL = "http://analytics.localytics.com/api/v2/applications/%s/uploads"; //$NON-NLS-1$
/**
* Handler message to upload all data collected so far
*
* {@link Message#obj} is a {@code Runnable} to execute when upload is complete. The thread that this runnable will
* executed on is undefined.
*/
public static final int MESSAGE_UPLOAD = 1;
/**
* Handler message indicating that there is a queued upload request. When this message is processed, this handler simply
* forwards the request back to {@link LocalyticsSession#mSessionHandler} with {@link SessionHandler#MESSAGE_UPLOAD}.
*
* {@link Message#obj} is a {@code Runnable} to execute when upload is complete. The thread that this runnable will
* executed on is undefined.
*/
public static final int MESSAGE_RETRY_UPLOAD_REQUEST = 2;
/**
* Reference to the Localytics database
*/
private final LocalyticsProvider mProvider;
/**
* Application context
*/
private final Context mContext;
/**
* The Localytics API key
*/
private final String mApiKey;
/**
* Parent session handler to notify when an upload completes.
*/
private final Handler mSessionHandler;
/**
* Constructs a new Handler that runs on {@code looper}.
*
* Note: This constructor may perform disk access.
*
* @param context Application context. Cannot be null.
* @param sessionHandler Parent {@link SessionHandler} object to notify when uploads are completed. Cannot be null.
* @param apiKey Localytics API key. Cannot be null.
* @param looper to run the Handler on. Cannot be null.
*/
public UploadHandler(final Context context, final Handler sessionHandler, final String apiKey, final Looper looper)
{
super(looper);
mContext = context;
mProvider = LocalyticsProvider.getInstance(context, apiKey);
mSessionHandler = sessionHandler;
mApiKey = apiKey;
}
@Override
public void handleMessage(final Message msg)
{
super.handleMessage(msg);
switch (msg.what)
{
case MESSAGE_UPLOAD:
{
if (Constants.IS_LOGGABLE)
{
Log.d(Constants.LOG_TAG, "UploadHandler Received MESSAGE_UPLOAD"); //$NON-NLS-1$
}
/*
* Note that callback may be null
*/
final Runnable callback = (Runnable) msg.obj;
try
{
final List
* This should be called after a successful upload completes.
*
* @param provider Localytics database provider. Cannot be null.
*/
/* package */static void deleteBlobsAndSessions(final LocalyticsProvider provider)
{
/*
* Deletion needs to occur in a specific order due to database constraints. Specifically, blobevents need to be
* deleted first. Then blobs themselves can be deleted. Then attributes need to be deleted first. Then events. Then
* sessions.
*/
final LinkedList
* There are three types of events: open, close, and application. Open and close events are Localytics events, while
* application events are generated by the app. The return value of this method will vary based on the type of event that
* is being converted.
*
* @param provider Localytics database instance. Cannot be null.
* @param context Application context. Cannot be null.
* @param eventId {@link EventsDbColumns#_ID} of the event to convert.
* @param blobId {@link UploadBlobEventsDbColumns#_ID} of the upload blob that contains this event.
* @param apiKey the Localytics API key. Cannot be null.
* @return JSON representation of the event.
* @throws JSONException if a problem occurred converting the element to JSON.
*/
/* package */static JSONObject convertEventToJson(final LocalyticsProvider provider, final Context context, final long eventId, final long blobId, final String apiKey)
throws JSONException
{
final JSONObject result = new JSONObject();
Cursor cursor = null;
try
{
cursor = provider.query(EventsDbColumns.TABLE_NAME, null, String.format("%s = ?", EventsDbColumns._ID), new String[] //$NON-NLS-1$
{ Long.toString(eventId) }, EventsDbColumns._ID);
if (cursor.moveToFirst())
{
final String eventName = cursor.getString(cursor.getColumnIndexOrThrow(EventsDbColumns.EVENT_NAME));
final long sessionId = getSessionIdForEventId(provider, eventId);
final String sessionUuid = getSessionUuid(provider, sessionId);
final long sessionStartTime = getSessionStartTime(provider, sessionId);
if (OPEN_EVENT.equals(eventName))
{
result.put(JsonObjects.SessionOpen.KEY_DATA_TYPE, JsonObjects.SessionOpen.VALUE_DATA_TYPE);
result.put(JsonObjects.SessionOpen.KEY_WALL_TIME_SECONDS, Math.round((double) cursor.getLong(cursor.getColumnIndex(EventsDbColumns.WALL_TIME))
/ DateUtils.SECOND_IN_MILLIS));
result.put(JsonObjects.SessionOpen.KEY_EVENT_UUID, sessionUuid);
/*
* Both the database and the web service use 1-based indexing.
*/
result.put(JsonObjects.SessionOpen.KEY_COUNT, sessionId);
}
else if (CLOSE_EVENT.equals(eventName))
{
result.put(JsonObjects.SessionClose.KEY_DATA_TYPE, JsonObjects.SessionClose.VALUE_DATA_TYPE);
result.put(JsonObjects.SessionClose.KEY_EVENT_UUID, cursor.getString(cursor.getColumnIndexOrThrow(EventsDbColumns.UUID)));
result.put(JsonObjects.SessionClose.KEY_SESSION_UUID, sessionUuid);
result.put(JsonObjects.SessionClose.KEY_SESSION_START_TIME, Math.round((double) sessionStartTime / DateUtils.SECOND_IN_MILLIS));
result.put(JsonObjects.SessionClose.KEY_WALL_TIME_SECONDS, Math.round((double) cursor.getLong(cursor.getColumnIndex(EventsDbColumns.WALL_TIME))
/ DateUtils.SECOND_IN_MILLIS));
/*
* length is a special case, as it depends on the start time embedded in the session table
*/
Cursor sessionCursor = null;
try
{
sessionCursor = provider.query(SessionsDbColumns.TABLE_NAME, new String[]
{ SessionsDbColumns.SESSION_START_WALL_TIME }, String.format("%s = ?", SessionsDbColumns._ID), new String[] { Long.toString(cursor.getLong(cursor.getColumnIndexOrThrow(EventsDbColumns.SESSION_KEY_REF))) }, null); //$NON-NLS-1$
if (sessionCursor.moveToFirst())
{
result.put(JsonObjects.SessionClose.KEY_SESSION_LENGTH_SECONDS, Math.round((double) cursor.getLong(cursor.getColumnIndex(EventsDbColumns.WALL_TIME))
/ DateUtils.SECOND_IN_MILLIS)
- Math.round((double) sessionCursor.getLong(sessionCursor.getColumnIndexOrThrow(SessionsDbColumns.SESSION_START_WALL_TIME))
/ DateUtils.SECOND_IN_MILLIS));
}
else
{
// this should never happen
throw new RuntimeException("Session didn't exist"); //$NON-NLS-1$
}
}
finally
{
if (null != sessionCursor)
{
sessionCursor.close();
sessionCursor = null;
}
}
/*
* The close also contains a special case element for the screens history
*/
Cursor eventHistoryCursor = null;
try
{
eventHistoryCursor = provider.query(EventHistoryDbColumns.TABLE_NAME, new String[]
{ EventHistoryDbColumns.NAME }, String.format("%s = ? AND %s = ?", EventHistoryDbColumns.SESSION_KEY_REF, EventHistoryDbColumns.TYPE), new String[] { Long.toString(sessionId), Integer.toString(EventHistoryDbColumns.TYPE_SCREEN) }, EventHistoryDbColumns._ID); //$NON-NLS-1$
final JSONArray screens = new JSONArray();
while (eventHistoryCursor.moveToNext())
{
screens.put(eventHistoryCursor.getString(eventHistoryCursor.getColumnIndexOrThrow(EventHistoryDbColumns.NAME)));
}
if (screens.length() > 0)
{
result.put(JsonObjects.SessionClose.KEY_FLOW_ARRAY, screens);
}
}
finally
{
if (null != eventHistoryCursor)
{
eventHistoryCursor.close();
eventHistoryCursor = null;
}
}
}
else if (OPT_IN_EVENT.equals(eventName) || OPT_OUT_EVENT.equals(eventName))
{
result.put(JsonObjects.OptEvent.KEY_DATA_TYPE, JsonObjects.OptEvent.VALUE_DATA_TYPE);
result.put(JsonObjects.OptEvent.KEY_API_KEY, apiKey);
result.put(JsonObjects.OptEvent.KEY_OPT, OPT_OUT_EVENT.equals(eventName) ? Boolean.TRUE.toString() : Boolean.FALSE.toString());
result.put(JsonObjects.OptEvent.KEY_WALL_TIME_SECONDS, Math.round((double) cursor.getLong(cursor.getColumnIndex(EventsDbColumns.WALL_TIME))
/ DateUtils.SECOND_IN_MILLIS));
}
else if (FLOW_EVENT.equals(eventName))
{
result.put(JsonObjects.EventFlow.KEY_DATA_TYPE, JsonObjects.EventFlow.VALUE_DATA_TYPE);
result.put(JsonObjects.EventFlow.KEY_EVENT_UUID, cursor.getString(cursor.getColumnIndexOrThrow(EventsDbColumns.UUID)));
result.put(JsonObjects.EventFlow.KEY_SESSION_START_TIME, Math.round((double) sessionStartTime / DateUtils.SECOND_IN_MILLIS));
/*
* Need to generate two objects: the old flow events and the new flow events
*/
/*
* Default sort order is ascending by _ID, so these will be sorted chronologically.
*/
Cursor eventHistoryCursor = null;
try
{
eventHistoryCursor = provider.query(EventHistoryDbColumns.TABLE_NAME, new String[]
{
EventHistoryDbColumns.TYPE,
EventHistoryDbColumns.PROCESSED_IN_BLOB,
EventHistoryDbColumns.NAME }, String.format("%s = ? AND %s <= ?", EventHistoryDbColumns.SESSION_KEY_REF, EventHistoryDbColumns.PROCESSED_IN_BLOB), new String[] { Long.toString(sessionId), Long.toString(blobId) }, EventHistoryDbColumns._ID); //$NON-NLS-1$
final JSONArray newScreens = new JSONArray();
final JSONArray oldScreens = new JSONArray();
while (eventHistoryCursor.moveToNext())
{
final String name = eventHistoryCursor.getString(eventHistoryCursor.getColumnIndexOrThrow(EventHistoryDbColumns.NAME));
final String type;
if (EventHistoryDbColumns.TYPE_EVENT == eventHistoryCursor.getInt(eventHistoryCursor.getColumnIndexOrThrow(EventHistoryDbColumns.TYPE)))
{
type = JsonObjects.EventFlow.Element.TYPE_EVENT;
}
else
{
type = JsonObjects.EventFlow.Element.TYPE_SCREEN;
}
if (blobId == eventHistoryCursor.getLong(eventHistoryCursor.getColumnIndexOrThrow(EventHistoryDbColumns.PROCESSED_IN_BLOB)))
{
newScreens.put(new JSONObject().put(type, name));
}
else
{
oldScreens.put(new JSONObject().put(type, name));
}
}
result.put(JsonObjects.EventFlow.KEY_FLOW_NEW, newScreens);
result.put(JsonObjects.EventFlow.KEY_FLOW_OLD, oldScreens);
}
finally
{
if (null != eventHistoryCursor)
{
eventHistoryCursor.close();
eventHistoryCursor = null;
}
}
}
else
{
/*
* This is a normal application event
*/
result.put(JsonObjects.SessionEvent.KEY_DATA_TYPE, JsonObjects.SessionEvent.VALUE_DATA_TYPE);
result.put(JsonObjects.SessionEvent.KEY_WALL_TIME_SECONDS, Math.round((double) cursor.getLong(cursor.getColumnIndex(EventsDbColumns.WALL_TIME))
/ DateUtils.SECOND_IN_MILLIS));
result.put(JsonObjects.SessionEvent.KEY_EVENT_UUID, cursor.getString(cursor.getColumnIndexOrThrow(EventsDbColumns.UUID)));
result.put(JsonObjects.SessionEvent.KEY_SESSION_UUID, sessionUuid);
result.put(JsonObjects.SessionEvent.KEY_NAME, eventName.substring(context.getPackageName().length() + 1, eventName.length()));
final JSONObject attributes = convertAttributesToJson(provider, eventId);
if (null != attributes)
{
result.put(JsonObjects.SessionEvent.KEY_ATTRIBUTES, attributes);
}
}
}
else
{
/*
* This should never happen
*/
throw new RuntimeException();
}
}
finally
{
if (null != cursor)
{
cursor.close();
}
}
return result;
}
/**
* Private helper to get the {@link SessionsDbColumns#_ID} for a given {@link EventsDbColumns#_ID}.
*
* @param provider Localytics database instance. Cannot be null.
* @param eventId {@link EventsDbColumns#_ID} of the event to look up
* @return The {@link SessionsDbColumns#_ID} of the session that owns the event.
*/
/* package */static long getSessionIdForEventId(final LocalyticsProvider provider, final long eventId)
{
Cursor cursor = null;
try
{
cursor = provider.query(EventsDbColumns.TABLE_NAME, new String[]
{ EventsDbColumns.SESSION_KEY_REF }, String.format("%s = ?", EventsDbColumns._ID), new String[] { Long.toString(eventId) }, null); //$NON-NLS-1$
if (cursor.moveToFirst())
{
return cursor.getLong(cursor.getColumnIndexOrThrow(EventsDbColumns.SESSION_KEY_REF));
}
/*
* This should never happen
*/
throw new RuntimeException();
}
finally
{
if (null != cursor)
{
cursor.close();
}
}
}
/**
* Private helper to get the {@link SessionsDbColumns#UUID} for a given {@link SessionsDbColumns#_ID}.
*
* @param provider Localytics database instance. Cannot be null.
* @param sessionId {@link SessionsDbColumns#_ID} of the event to look up
* @return The {@link SessionsDbColumns#UUID} of the session.
*/
/* package */static String getSessionUuid(final LocalyticsProvider provider, final long sessionId)
{
Cursor cursor = null;
try
{
cursor = provider.query(SessionsDbColumns.TABLE_NAME, new String[]
{ SessionsDbColumns.UUID }, String.format("%s = ?", SessionsDbColumns._ID), new String[] { Long.toString(sessionId) }, null); //$NON-NLS-1$
if (cursor.moveToFirst())
{
return cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.UUID));
}
/*
* This should never happen
*/
throw new RuntimeException();
}
finally
{
if (null != cursor)
{
cursor.close();
}
}
}
/**
* Private helper to get the {@link SessionsDbColumns#SESSION_START_WALL_TIME} for a given {@link SessionsDbColumns#_ID}.
*
* @param provider Localytics database instance. Cannot be null.
* @param sessionId {@link SessionsDbColumns#_ID} of the event to look up
* @return The {@link SessionsDbColumns#SESSION_START_WALL_TIME} of the session.
*/
/* package */static long getSessionStartTime(final LocalyticsProvider provider, final long sessionId)
{
Cursor cursor = null;
try
{
cursor = provider.query(SessionsDbColumns.TABLE_NAME, new String[]
{ SessionsDbColumns.SESSION_START_WALL_TIME }, String.format("%s = ?", SessionsDbColumns._ID), new String[] { Long.toString(sessionId) }, null); //$NON-NLS-1$
if (cursor.moveToFirst())
{
return cursor.getLong(cursor.getColumnIndexOrThrow(SessionsDbColumns.SESSION_START_WALL_TIME));
}
/*
* This should never happen
*/
throw new RuntimeException();
}
finally
{
if (null != cursor)
{
cursor.close();
}
}
}
/**
* Private helper to convert an event's attributes into a {@link JSONObject} representation.
*
* @param provider Localytics database instance. Cannot be null.
* @param eventId {@link EventsDbColumns#_ID} of the event whose attributes are to be loaded.
* @return {@link JSONObject} representing the attributes of the event. The order of attributes is undefined and may
* change from call to call of this method. If the event has no attributes, returns null.
* @throws JSONException if an error occurs converting the attributes to JSON
*/
/* package */static JSONObject convertAttributesToJson(final LocalyticsProvider provider, final long eventId) throws JSONException
{
Cursor cursor = null;
try
{
cursor = provider.query(AttributesDbColumns.TABLE_NAME, null, String.format("%s = ?", AttributesDbColumns.EVENTS_KEY_REF), new String[] { Long.toString(eventId) }, null); //$NON-NLS-1$
if (cursor.getCount() == 0)
{
return null;
}
final JSONObject attributes = new JSONObject();
final int keyColumn = cursor.getColumnIndexOrThrow(AttributesDbColumns.ATTRIBUTE_KEY);
final int valueColumn = cursor.getColumnIndexOrThrow(AttributesDbColumns.ATTRIBUTE_VALUE);
while (cursor.moveToNext())
{
attributes.put(cursor.getString(keyColumn), cursor.getString(valueColumn));
}
return attributes;
}
finally
{
if (null != cursor)
{
cursor.close();
}
}
}
/**
* Given an id of an upload blob, get the session id associated with that blob.
*
* @param blobId {@link UploadBlobsDbColumns#_ID} of the upload blob.
* @return id of the parent session.
*/
/* package */long getSessionIdForBlobId(final long blobId)
{
/*
* This implementation needs to walk up the tree of database elements.
*/
long eventId;
{
Cursor cursor = null;
try
{
cursor = mProvider.query(UploadBlobEventsDbColumns.TABLE_NAME, new String[]
{ UploadBlobEventsDbColumns.EVENTS_KEY_REF }, String.format("%s = ?", UploadBlobEventsDbColumns.UPLOAD_BLOBS_KEY_REF), new String[] //$NON-NLS-1$
{ Long.toString(blobId) }, null);
if (cursor.moveToFirst())
{
eventId = cursor.getLong(cursor.getColumnIndexOrThrow(UploadBlobEventsDbColumns.EVENTS_KEY_REF));
}
else
{
/*
* This should never happen
*/
throw new RuntimeException("No events associated with blob"); //$NON-NLS-1$
}
}
finally
{
if (null != cursor)
{
cursor.close();
}
}
}
long sessionId;
{
Cursor cursor = null;
try
{
cursor = mProvider.query(EventsDbColumns.TABLE_NAME, new String[]
{ EventsDbColumns.SESSION_KEY_REF }, String.format("%s = ?", EventsDbColumns._ID), new String[] //$NON-NLS-1$
{ Long.toString(eventId) }, null);
if (cursor.moveToFirst())
{
sessionId = cursor.getLong(cursor.getColumnIndexOrThrow(EventsDbColumns.SESSION_KEY_REF));
}
else
{
/*
* This should never happen
*/
throw new RuntimeException("No session associated with event"); //$NON-NLS-1$
}
}
finally
{
if (null != cursor)
{
cursor.close();
}
}
}
return sessionId;
}
}
/**
* Internal helper class to pass two objects to the Handler via the {@link Message#obj}.
*/
/*
* Once support for Android 1.6 is dropped, using Android's built-in Pair class would be preferable
*/
private static final class Pair
* There are very serious implications to the quality of your data when providing an opt out option. For example, users who
* have opted out will appear as never returning, causing your new/returning chart to skew.
* If two instances of the same application are running, and one is opted in and the second opts out, the first will also
* become opted out, and neither will collect any more data.
* If a session was started while the app was opted out, the session open event has already been lost. For this reason, all
* sessions started while opted out will not collect data even after the user opts back in or else it taints the comparisons
* of session lengths and other metrics.
*
* @param isOptedOut True if the user should be be opted out and have all his Localytics data deleted.
*/
public void setOptOut(final boolean isOptedOut)
{
mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_OPT_OUT, isOptedOut ? 1 : 0, 0));
}
/**
* Opens the Localytics session. The session time as presented on the website is the time between the first open
* and the final close
so it is recommended to open the session as early as possible, and close it at the last
* moment. The session must be opened before {@link #tagEvent(String)} or {@link #tagEvent(String, Map)} can be called, so
* this call should be placed in {@code Activity#onCreate(Bundle)}.
* open
in onCreate
. This will
* cause each Activity to reconnect to the currently running session.
*/
public void open()
{
mSessionHandler.sendEmptyMessage(SessionHandler.MESSAGE_OPEN);
}
/**
* Closes the Localytics session. This should be done when the application or activity is ending. Because of the way the
* Android lifecycle works, this call could end up in a place which gets called multiple times (such as onPause
* which is the recommended location). This is fine because only the last close is processed by the server.
* Closing does not cause the session to stop collecting data. This is a result of the application life cycle. It is possible
* for onPause to be called long before the application is actually ready to close the session.
*/
public void close()
{
mSessionHandler.sendEmptyMessage(SessionHandler.MESSAGE_CLOSE);
}
/**
* Allows a session to tag a particular event as having occurred. For example, if a view has three buttons, it might make
* sense to tag each button click with the name of the button which was clicked. For another example, in a game with many
* levels it might be valuable to create a new tag every time the user gets to a new level in order to determine how far the
* average user is progressing in the game.
* Tagging Best Practices
*
*
*
*
* @param event The name of the event which occurred. Cannot be null or empty string.
* @throws IllegalArgumentException if {@code event} is null.
* @throws IllegalArgumentException if {@code event} is empty.
*/
public void tagEvent(final String event)
{
tagEvent(event, null);
}
/**
* Allows a session to tag a particular event as having occurred, and optionally attach a collection of attributes to it. For
* example, if a view has three buttons, it might make sense to tag each button with the name of the button which was clicked.
* For another example, in a game with many levels it might be valuable to create a new tag every time the user gets to a new
* level in order to determine how far the average user is progressing in the game.
* Tagging Best Practices
*
*
*
*
* @param event The name of the event which occurred.
* @param attributes The collection of attributes for this particular event. If this parameter is null or empty, then calling
* this method has the same effect as calling {@link #tagEvent(String)}. This parameter may not contain null or
* empty keys or values.
* @throws IllegalArgumentException if {@code event} is null.
* @throws IllegalArgumentException if {@code event} is empty.
* @throws IllegalArgumentException if {@code attributes} contains null keys, empty keys, null values, or empty values.
*/
public void tagEvent(final String event, final Map