Java tutorial
// @formatter:off /* * LocalyticsSession.java Copyright (C) 2012 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.sidekickApp.analytics; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; 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 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 com.sidekickApp.analytics.JsonObjects.BlobHeader; import com.sidekickApp.analytics.LocalyticsProvider.ApiKeysDbColumns; import com.sidekickApp.analytics.LocalyticsProvider.AttributesDbColumns; import com.sidekickApp.analytics.LocalyticsProvider.EventHistoryDbColumns; import com.sidekickApp.analytics.LocalyticsProvider.EventsDbColumns; import com.sidekickApp.analytics.LocalyticsProvider.SessionsDbColumns; import com.sidekickApp.analytics.LocalyticsProvider.UploadBlobEventsDbColumns; import com.sidekickApp.analytics.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: * <ul> * <li><a href="http://wiki.localytics.com/index.php?title=Developer's_Integration_Guide">Main Developer's Integration Guide</a></li> * <li><a href="http://wiki.localytics.com/index.php?title=Android_2_Minute_Integration">Android 2 minute integration Guide</a></li> * <li><a href="http://wiki.localytics.com/index.php?title=Android_Integration_Guide">Android Integration Guide</a></li> * </ul> * <p> * Permissions required: * <ul> * <li>{@link permission#INTERNET}</li> - Necessary to upload data to the webservice.</li> * </ul> * Permissions recommended: * <ul> * <li>{@link permission#ACCESS_WIFI_STATE}</li> - Necessary to identify the type of network connection the user has. Without this * permission, users connecting via Wi-Fi will be reported as having a connection type of 'unknown.'</li> * </ul> * <strong>Basic Usage</strong> * <ol> * <li>In {@code Activity#onCreate(Bundle)}, instantiate a {@link LocalyticsSession} object and assign it to a global variable in * the Activity (e.g. {@code mLocalyticsSession}).</li> * <li>In {@code Activity#onResume()}, call {@link #open()} or {@link #open(List)}.</li> * <li>In {@code Activity#onResume()}, consider calling {@link #upload()}. Because the session was just opened, this upload will * submit that open to the server and allow you to capture real-time usage of your application.</li> * <li>In {@code Activity#onResume()}, consider calling {@link #tagScreen(String)} to note that the user entered the Activity. * Assuming your application uses multiple Activities for navigation (rather than a single Activity with multiple Fragments}, this * will capture the flow of users as they move from Activity to Activity. Don't worry about Activity re-entrance. Because * {@code Activity#onResume()} can be called multiple times for different reasons, the Localytics library manages duplicate * {@link #tagScreen(String)} calls for you.</li> * <li>As the user interacts with your Activity, call {@link #tagEvent(String)}, {@link #tagEvent(String, Map)} or * {@link #tagEvent(String, Map, List)} to collect usage data.</li> * <li>In {@code Activity#onPause()}, call {@link #close()} or {@link #close(List)}.</li> * </ol> * <strong>Notes</strong> * <ul> * <li>Do not call any {@link LocalyticsSession} methods inside a loop. Instead, calls such as {@link #tagEvent(String)} should * follow user actions. This limits the amount of data which is stored and uploaded.</li> * <li>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.</li> * <li>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. If you need to use multiple processes, then each process should have * its own Localytics API key in order to make data processing thread-safe.</li> * </ul> * * @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. */ protected 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(); /* * Note: we tried setting an uncaught exception handler here. But for some reason it causes looper initialization to fail * randomly. */ return thread; } /** * Maps an API key to a singleton instance of the {@link SessionHandler}. Lazily initialized during construction of the * {@link LocalyticsSession} object. */ private static final Map<String, SessionHandler> sLocalyticsSessionHandlerMap = new HashMap<String, SessionHandler>(); /** * Intrinsic lock for synchronizing the initialization of {@link #sLocalyticsSessionHandlerMap}. */ private static final Object[] sLocalyticsSessionIntrinsicLock = new Object[0]; /** * Handler object where all session requests of this instance of LocalyticsSession are handed off to. * <p> * This Handler is the key thread synchronization point for all work inside the LocalyticsSession. * <p> * This handler runs on {@link #sSessionHandlerThread}. */ private final Handler mSessionHandler; /** * Application context */ private final Context mContext; /** * Keeps track of which Localytics clients are currently uploading, in order to allow only one upload for a given key at a * time. * <p> * 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}. */ protected static final Map<String, Boolean> sIsUploadingMap = new HashMap<String, Boolean>(); /** * Constructs a new {@link LocalyticsSession} object. * * @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. * @throws IllegalArgumentException if {@code context} is null * @throws IllegalArgumentException if {@code key} is null or empty */ public LocalyticsSession(final Context context, final String key) { 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$ } /* * Prevent the client from providing a subclass of Context that returns the Localytics package name. * * Note that because getPackageName() is a method and could theoretically return different results with each invocation, * this check doesn't guarantee that a nefarious caller will be detected. */ if (Constants.LOCALYTICS_PACKAGE_NAME.equals(context.getPackageName()) && !context.getClass().getName().equals("android.test.IsolatedContext") //$NON-NLS-1$ && !context.getClass().getName().equals("android.test.RenamingDelegatingContext")) //$NON-NLS-1$ { throw new IllegalArgumentException( String.format("context.getPackageName() returned %s", context.getPackageName())); //$NON-NLS-1$ } /* * Get the application context to avoid having the Localytics object holding onto an Activity object. Using application * context is very important to prevent the customer from giving the library multiple different contexts with different * package names, which would corrupt the events in the database. * * Although RenamingDelegatingContext is part of the Android SDK, the class isn't present in the ClassLoader unless the * process is being run as a unit test. For that reason, comparing class names is necessary instead of doing instanceof. * * Note that getting the application context may have unpredictable results for apps sharing a process running Android 2.1 * and earlier. See <http://code.google.com/p/android/issues/detail?id=4469> for details. */ mContext = !(context.getClass().getName().equals("android.test.RenamingDelegatingContext")) //$NON-NLS-1$ && Constants.CURRENT_API_LEVEL >= 8 ? context.getApplicationContext() : context; synchronized (sLocalyticsSessionIntrinsicLock) { SessionHandler handler = sLocalyticsSessionHandlerMap.get(key); if (null == handler) { handler = new SessionHandler(mContext, key, sSessionHandlerThread.getLooper()); sLocalyticsSessionHandlerMap.put(key, handler); /* * Complete Handler initialization on a background thread. Note that this is not generally a good best practice, * as the LocalyticsSession object (and its child objects) should be fully initialized by the time the constructor * returns. However this implementation is safe, as the Handler will process this initialization message before * any other message. */ handler.sendMessage(handler.obtainMessage(SessionHandler.MESSAGE_INIT)); } mSessionHandler = handler; } } /** * Sets the Localytics opt-out state for this application. This call is not necessary and is provided for people who wish to * allow their users the ability to opt out of data collection. It can be called at any time. Passing true causes all further * data collection to stop, and an opt-out event to be sent to the server so the user's data is removed from the charts. <br> * 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. <br> * 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. <br> * 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)); } /** * Behaves identically to calling {@code open(null)}. * * @see #open(List) */ public void open() { open(null); } /** * Opens the Localytics session. The session should be opened before {@link #tagEvent(String)}, {@link #tagEvent(String, Map)} * , {@link #tagEvent(String, Map, List)}, or {@link #tagScreen(String)} are called. * <p> * If a new session is opened shortly--within a few seconds--after an earlier session is closed, Localytics will reconnect to * the previous session (effectively causing the previous close to be ignored). This ensures that as a user moves from * Activity to Activity in an app, that is considered a single session. When a session is reconnected, the * {@code customDimensions} for the initial open are kept and dimensions for the second open are ignored. * <p> * If for any reason open is called more than once without an intervening call to {@link #close()} or {@link #close(List)}, * subsequent calls to open will be ignored. * * @param customDimensions A set of custom reporting dimensions. If this parameter is null or empty, then no custom dimensions * are recorded and the behavior with respect to custom dimensions is like simply calling {@link #open()}. The * number of dimensions is capped at four. If there are more than four elements, the extra elements are ignored. * This parameter may not contain null or empty elements. This parameter is only used for enterprise level * accounts. For non-enterprise accounts, custom dimensions will be uploaded but will not be accessible in reports * until the account is upgraded to enterprise status. * @throws IllegalArgumentException if {@code customDimensions} contains null or empty elements. */ public void open(final List<String> customDimensions) { if (Constants.IS_PARAMETER_CHECKING_ENABLED) { if (null != customDimensions) { /* * Calling this with empty dimensions is a smell that indicates a possible programming error on the part of the * caller */ if (customDimensions.isEmpty()) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "customDimensions is empty. Did the caller make an error?"); //$NON-NLS-1$ } } if (customDimensions.size() > Constants.MAX_CUSTOM_DIMENSIONS) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, String.format( "customDimensions size is %d, exceeding the maximum size of %d. Did the caller make an error?", //$NON-NLS-1$ Integer.valueOf(customDimensions.size()), Integer.valueOf(Constants.MAX_CUSTOM_DIMENSIONS))); } } for (final String element : customDimensions) { if (null == element) { throw new IllegalArgumentException("customDimensions cannot contain null elements"); //$NON-NLS-1$ } if (0 == element.length()) { throw new IllegalArgumentException("customDimensions cannot contain empty elements"); //$NON-NLS-1$ } } } } if (null == customDimensions || customDimensions.isEmpty()) { mSessionHandler.sendEmptyMessage(SessionHandler.MESSAGE_OPEN); } else { mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_OPEN, new TreeMap<String, String>(convertDimensionsToAttributes(customDimensions)))); } } /** * Behaves identically to calling {@code close(null)}. * * @see #close(List) */ public void close() { close(null); } /** * Closes the Localytics session. Once a session has been opened via {@link #open()} or {@link #open(List)}, close the session * when data collection is complete. * <p> * If close is called without open having ever been called, the close has no effect. Similarly, once a session is closed, * subsequent calls to close will be ignored. * * @param customDimensions A set of custom reporting dimensions. If this parameter is null or empty, then no custom dimensions * are recorded and the behavior with respect to custom dimensions is like simply calling {@link #close()}. The * number of dimensions is capped at four. If there are more than four elements, the extra elements are ignored. * This parameter may not contain null or empty elements. This parameter is only used for enterprise level * accounts. For non-enterprise accounts, custom dimensions will be uploaded but will not be accessible in reports * until the account is upgraded to enterprise status. * @throws IllegalArgumentException if {@code customDimensions} contains null or empty elements. */ public void close(final List<String> customDimensions) { if (Constants.IS_PARAMETER_CHECKING_ENABLED) { if (null != customDimensions) { /* * Calling this with empty dimensions is a smell that indicates a possible programming error on the part of the * caller */ if (customDimensions.isEmpty()) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "customDimensions is empty. Did the caller make an error?"); //$NON-NLS-1$ } } if (customDimensions.size() > Constants.MAX_CUSTOM_DIMENSIONS) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, String.format( "customDimensions size is %d, exceeding the maximum size of %d. Did the caller make an error?", //$NON-NLS-1$ Integer.valueOf(customDimensions.size()), Integer.valueOf(Constants.MAX_CUSTOM_DIMENSIONS))); } } for (final String element : customDimensions) { if (null == element) { throw new IllegalArgumentException("customDimensions cannot contain null elements"); //$NON-NLS-1$ } if (0 == element.length()) { throw new IllegalArgumentException("customDimensions cannot contain empty elements"); //$NON-NLS-1$ } } } } if (null == customDimensions || customDimensions.isEmpty()) { mSessionHandler.sendEmptyMessage(SessionHandler.MESSAGE_CLOSE); } else { mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_CLOSE, new TreeMap<String, String>(convertDimensionsToAttributes(customDimensions)))); } } /** * Behaves identically to calling {@code tagEvent(event, null, null)}. * * @see #tagEvent(String, Map, List) * @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); } /** * Behaves identically to calling {@code tagEvent(event, attributes, null)}. * * @see #tagEvent(String, Map, List) * @param event The name of the event which occurred. Cannot be null or empty string. * @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<String, String> attributes) { tagEvent(event, attributes, null); } /** * <p> * Within the currently open session, tags that {@code event} occurred (with optionally included attributes and dimensions). * </p> * <p> * Attributes: Additional key/value pairs with data related to an event. For example, let's say your app displays a dialog * with two buttons: OK and Cancel. When the user clicks on one of the buttons, the event might be "button clicked." The * attribute key might be "button_label" and the value would either be "OK" or "Cancel" depending on which button was clicked. * </p> * <p> * Custom dimensions: TODO * </p> * <strong>Best Practices</strong> * <ul> * <li>DO NOT use events, attributes, or dimensions to record personally identifiable information.</li> * <li>The best way to use events is to create all the event strings as predefined constants and only use those. This is more * efficient and removes the risk of collecting personal information.</li> * <li>Do not tag events inside loops or any other place which gets called frequently. This can cause a lot of data to be * stored and uploaded.</li> * </ul> * * @param event The name of the event which occurred. Cannot be null or empty string. * @param attributes The collection of attributes for this particular event. If this parameter is null or empty, then no * attributes are recorded and the behavior with respect to attributes is like simply calling * {@link #tagEvent(String)}. This parameter may not contain null or empty keys or values. * @param customDimensions A set of custom reporting dimensions. If this parameter is null or empty, then no custom dimensions * are recorded and the behavior with respect to custom dimensions is like simply calling {@link #tagEvent(String)} * . The number of dimensions is capped at four. If there are more than four elements, the extra elements are * ignored. This parameter may not contain null or empty elements. This parameter is only used for enterprise level * accounts. For non-enterprise accounts, custom dimensions will be uploaded but will not be accessible in reports * until the account is upgraded to enterprise status. * @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. * @throws IllegalArgumentException if {@code customDimensions} contains null or empty elements. */ public void tagEvent(final String event, final Map<String, String> attributes, final List<String> customDimensions) { if (Constants.IS_PARAMETER_CHECKING_ENABLED) { if (null == event) { throw new IllegalArgumentException("event cannot be null"); //$NON-NLS-1$ } if (0 == event.length()) { throw new IllegalArgumentException("event cannot be empty"); //$NON-NLS-1$ } if (null != attributes) { /* * Calling this with empty attributes is a smell that indicates a possible programming error on the part of the * caller */ if (attributes.isEmpty()) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "attributes is empty. Did the caller make an error?"); //$NON-NLS-1$ } } if (attributes.size() > Constants.MAX_NUM_ATTRIBUTES) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, String.format( "attributes size is %d, exceeding the maximum size of %d. Did the caller make an error?", //$NON-NLS-1$ Integer.valueOf(attributes.size()), Integer.valueOf(Constants.MAX_NUM_ATTRIBUTES))); } } for (final Entry<String, String> entry : attributes.entrySet()) { final String key = entry.getKey(); final String value = entry.getValue(); if (null == key) { throw new IllegalArgumentException("attributes cannot contain null keys"); //$NON-NLS-1$ } if (null == value) { throw new IllegalArgumentException("attributes cannot contain null values"); //$NON-NLS-1$ } if (0 == key.length()) { throw new IllegalArgumentException("attributes cannot contain empty keys"); //$NON-NLS-1$ } if (0 == value.length()) { throw new IllegalArgumentException("attributes cannot contain empty values"); //$NON-NLS-1$ } } } if (null != customDimensions) { /* * Calling this with empty dimensions is a smell that indicates a possible programming error on the part of the * caller */ if (customDimensions.isEmpty()) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "customDimensions is empty. Did the caller make an error?"); //$NON-NLS-1$ } } if (customDimensions.size() > Constants.MAX_CUSTOM_DIMENSIONS) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, String.format( "customDimensions size is %d, exceeding the maximum size of %d. Did the caller make an error?", //$NON-NLS-1$ Integer.valueOf(customDimensions.size()), Integer.valueOf(Constants.MAX_CUSTOM_DIMENSIONS))); } } for (final String element : customDimensions) { if (null == element) { throw new IllegalArgumentException("customDimensions cannot contain null elements"); //$NON-NLS-1$ } if (0 == element.length()) { throw new IllegalArgumentException("customDimensions cannot contain empty elements"); //$NON-NLS-1$ } } } } final String eventString = String.format(EVENT_FORMAT, mContext.getPackageName(), event); if (null == attributes && null == customDimensions) { mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_TAG_EVENT, new Pair<String, Map<String, String>>(eventString, null))); } else { /* * Convert the attributes and custom dimensions into the internal representation of packagename:key */ final TreeMap<String, String> remappedAttributes = new TreeMap<String, String>(); if (null != attributes) { final String packageName = mContext.getPackageName(); for (final Entry<String, String> entry : attributes.entrySet()) { remappedAttributes.put( String.format(AttributesDbColumns.ATTRIBUTE_FORMAT, packageName, entry.getKey()), entry.getValue()); } } if (null != customDimensions) { remappedAttributes.putAll(convertDimensionsToAttributes(customDimensions)); } /* * Copying the map is very important to ensure that a client can't modify the map after this method is called. This is * especially important because the map is subsequently processed on a background thread. * * A TreeMap is used to ensure that the order that the attributes are written is deterministic. For example, if the * maximum number of attributes is exceeded the entries that occur later alphabetically will be skipped consistently. */ mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_TAG_EVENT, new Pair<String, Map<String, String>>(eventString, new TreeMap<String, String>(remappedAttributes)))); } } /** * Note: This implementation will perform duplicate suppression on two identical screen events that occur in a row within a * single session. For example, in the set of screens {"Screen 1", "Screen 1"} the second screen would be suppressed. However * in the set {"Screen 1", "Screen 2", "Screen 1"}, no duplicate suppression would occur. * * @param screen Name of the screen that was entered. Cannot be null or the empty string. * @throws IllegalArgumentException if {@code event} is null. * @throws IllegalArgumentException if {@code event} is empty. */ public void tagScreen(final String screen) { if (null == screen) { throw new IllegalArgumentException("event cannot be null"); //$NON-NLS-1$ } if (0 == screen.length()) { throw new IllegalArgumentException("event cannot be empty"); //$NON-NLS-1$ } mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_TAG_SCREEN, screen)); } /** * Initiates an upload of any Localytics data for this session's API key. This should be done early in the process life in * order to guarantee as much time as possible for slow connections to complete. It is necessary to do this even if the user * has opted out because this is how the opt out is transported to the webservice. */ public void upload() { mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_UPLOAD, null)); } /* * This is useful, but not necessarily needed for the public API. If so desired, someone can uncomment this out. */ // /** // * Initiates an upload of any Localytics data for this session's API key. This should be done early in the process life in // * order to guarantee as much time as possible for slow connections to complete. It is necessary to do this even if the user // * has opted out because this is how the opt out is transported to the webservice. // * // * @param callback a Runnable to execute when the upload completes. A typical use case would be to notify the caller that // the // * upload has completed. This runnable will be executed on an undefined thread, so the caller should anticipate // * this runnable NOT executing on the main thread or the thread that calls {@link #upload}. This parameter may be // * null. // */ // public void upload(final Runnable callback) // { // mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_UPLOAD, callback)); // } /** * Sorts an int value into a set of regular intervals as defined by the minimum, maximum, and step size. Both the min and max * values are inclusive, and in the instance where (max - min + 1) is not evenly divisible by step size, the method guarantees * only the minimum and the step size to be accurate to specification, with the new maximum will be moved to the next regular * step. * * @param actualValue The int value to be sorted. * @param minValue The int value representing the inclusive minimum interval. * @param maxValue The int value representing the inclusive maximum interval. * @param step The int value representing the increment of each interval. * @return a ranged attribute suitable for passing as the argument to {@link #tagEvent(String)} or * {@link #tagEvent(String, Map)}. */ public static String createRangedAttribute(final int actualValue, final int minValue, final int maxValue, final int step) { // Confirm there is at least one bucket if (step < 1) { if (Constants.IS_LOGGABLE) { Log.v(Constants.LOG_TAG, "Step must not be less than zero. Returning null."); //$NON-NLS-1$ } return null; } if (minValue >= maxValue) { if (Constants.IS_LOGGABLE) { Log.v(Constants.LOG_TAG, "maxValue must not be less than minValue. Returning null."); //$NON-NLS-1$ } return null; } // Determine the number of steps, rounding up using int math final int stepQuantity = (maxValue - minValue + step) / step; final int[] steps = new int[stepQuantity + 1]; for (int currentStep = 0; currentStep <= stepQuantity; currentStep++) { steps[currentStep] = minValue + (currentStep) * step; } return createRangedAttribute(actualValue, steps); } /** * Sorts an int value into a predefined, pre-sorted set of intervals, returning a string representing the new expected value. * The array must be sorted in ascending order, with the first element representing the inclusive lower bound and the last * element representing the exclusive upper bound. For instance, the array [0,1,3,10] will provide the following buckets: less * than 0, 0, 1-2, 3-9, 10 or greater. * * @param actualValue The int value to be bucketed. * @param steps The sorted int array representing the bucketing intervals. * @return String representation of {@code actualValue} that has been bucketed into the range provided by {@code steps}. * @throws IllegalArgumentException if {@code steps} is null. * @throws IllegalArgumentException if {@code steps} has length 0. */ public static String createRangedAttribute(final int actualValue, final int[] steps) { if (null == steps) { throw new IllegalArgumentException("steps cannot be null"); //$NON-NLS-1$ } if (steps.length == 0) { throw new IllegalArgumentException("steps length must be greater than 0"); //$NON-NLS-1$ } String bucket = null; // if less than smallest value if (actualValue < steps[0]) { bucket = "less than " + steps[0]; } // if greater than largest value else if (actualValue >= steps[steps.length - 1]) { bucket = steps[steps.length - 1] + " and above"; } else { // binarySearch returns the index of the value, or (-(insertion point) - 1) if not found int bucketIndex = Arrays.binarySearch(steps, actualValue); if (bucketIndex < 0) { // if the index wasn't found, then we want the value before the insertion point as the lower end // the special case where the insertion point is 0 is covered above, so we don't have to worry about it here bucketIndex = (-bucketIndex) - 2; } if (steps[bucketIndex] == (steps[bucketIndex + 1] - 1)) { bucket = Integer.toString(steps[bucketIndex]); } else { bucket = steps[bucketIndex] + "-" + (steps[bucketIndex + 1] - 1); //$NON-NLS-1$ } } return bucket; } /** * Helper to convert a list of dimensions into a set of attributes. * <p> * The number of dimensions is capped at 4. If there are more than 4 elements in {@code customDimensions}, all elements after * 4 are ignored. * * @param customDimensions List of dimensions to convert. * @return Attributes map for the set of dimensions. */ private static Map<String, String> convertDimensionsToAttributes(final List<String> customDimensions) { final TreeMap<String, String> attributes = new TreeMap<String, String>(); if (null != customDimensions) { int index = 0; for (final String element : customDimensions) { if (0 == index) { attributes.put(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_1, element); } else if (1 == index) { attributes.put(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_2, element); } else if (2 == index) { attributes.put(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_3, element); } else if (3 == index) { attributes.put(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_4, element); } index++; } } return attributes; } /** * Helper class to handle session-related work on the {@link LocalyticsSession#sSessionHandlerThread}. */ /* package */static final class SessionHandler extends Handler { /** * Empty handler message to initialize the callback. * <p> * This message must be sent before any other messages. */ public static final int MESSAGE_INIT = 0; /** * Handler message to open a session. * <p> * {@link Message#obj} is either null or a {@code Map<String, String>} containing attributes for the open. */ public static final int MESSAGE_OPEN = 1; /** * Handler message to close a session. * <p> * {@link Message#obj} is either null or a {@code Map<String, String>} containing attributes for the close. */ public static final int MESSAGE_CLOSE = 2; /** * Handler message to tag an event. * <p> * {@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 * <p> * {@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 previously requested upload attempt was completed. This does not mean the * attempt was successful. A callback occurs regardless of whether upload succeeded. */ public static final int MESSAGE_UPLOAD_CALLBACK = 5; /** * Handler message indicating an opt-out choice. * <p> * {@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 * <p> * {@link Message#obj} is a string representing the screen visited. */ public static final int MESSAGE_TAG_SCREEN = 7; /** * Sort order for the upload blobs. * <p> * This is a workaround for Android bug 3707 <http://code.google.com/p/android/issues/detail?id=3707>. */ private static final String UPLOAD_BLOBS_EVENTS_SORT_ORDER = String.format("CAST(%s AS TEXT)", //$NON-NLS-1$ UploadBlobEventsDbColumns.EVENTS_KEY_REF); /** * Sort order for the events. * <p> * This is a workaround for Android bug 3707 <http://code.google.com/p/android/issues/detail?id=3707>. */ private static final String EVENTS_SORT_ORDER = String.format("CAST(%s as TEXT)", EventsDbColumns._ID); //$NON-NLS-1$ /** * Application context */ private final Context mContext; /** * Localytics database */ protected LocalyticsProvider mProvider; /** * The Localytics API key for the session. */ private final String mApiKey; /** * {@link ApiKeysDbColumns#_ID} for the API key used by this Localytics session handler. */ private long mApiKeyId; /** * Handler object where all upload of this instance of LocalyticsSession are handed off to. * <p> * 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.IS_PARAMETER_CHECKING_ENABLED) { if (null == context) { 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) { try { super.handleMessage(msg); if (Constants.IS_LOGGABLE) { Log.v(Constants.LOG_TAG, String.format("Handler received %s", msg)); //$NON-NLS-1$ } switch (msg.what) { case MESSAGE_INIT: { if (Constants.IS_LOGGABLE) { Log.v(Constants.LOG_TAG, "Handler received MESSAGE_INIT"); //$NON-NLS-1$ } SessionHandler.this.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; mProvider.runBatchTransaction(new Runnable() { public void run() { SessionHandler.this.optOut(isOptingOut); } }); break; } case MESSAGE_OPEN: { if (Constants.IS_LOGGABLE) { Log.v(Constants.LOG_TAG, "Handler received MESSAGE_OPEN"); //$NON-NLS-1$ } mProvider.runBatchTransaction(new Runnable() { @SuppressWarnings("unchecked") public void run() { SessionHandler.this.open(false, (Map<String, String>) msg.obj); } }); break; } case MESSAGE_CLOSE: { if (Constants.IS_LOGGABLE) { Log.d(Constants.LOG_TAG, "Handler received MESSAGE_CLOSE"); //$NON-NLS-1$ } mProvider.runBatchTransaction(new Runnable() { @SuppressWarnings("unchecked") public void run() { SessionHandler.this.close((Map<String, String>) msg.obj); } }); break; } case MESSAGE_TAG_EVENT: { if (Constants.IS_LOGGABLE) { Log.d(Constants.LOG_TAG, "Handler received MESSAGE_TAG_EVENT"); //$NON-NLS-1$ } @SuppressWarnings("unchecked") final Pair<String, Map<String, String>> pair = (Pair<String, Map<String, String>>) msg.obj; final String event = pair.first; final Map<String, String> attributes = pair.second; mProvider.runBatchTransaction(new Runnable() { public void run() { if (null != getOpenSessionId(mProvider)) { tagEvent(event, attributes); } else { /* * The open and close only care about custom dimensions */ final Map<String, String> openCloseAttributes; if (null == attributes) { openCloseAttributes = null; } else if (attributes.containsKey(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_1) || attributes.containsKey(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_2) || attributes.containsKey(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_3) || attributes .containsKey(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_4)) { openCloseAttributes = new TreeMap<String, String>(); if (attributes.containsKey(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_1)) { openCloseAttributes.put(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_1, attributes.get(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_1)); } if (attributes.containsKey(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_2)) { openCloseAttributes.put(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_2, attributes.get(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_2)); } if (attributes.containsKey(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_3)) { openCloseAttributes.put(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_3, attributes.get(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_3)); } if (attributes.containsKey(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_4)) { openCloseAttributes.put(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_4, attributes.get(AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_4)); } } else { openCloseAttributes = null; } open(false, openCloseAttributes); tagEvent(event, attributes); close(openCloseAttributes); } } }); break; } case MESSAGE_TAG_SCREEN: { if (Constants.IS_LOGGABLE) { Log.d(Constants.LOG_TAG, "Handler received MESSAGE_TAG_SCREEN"); //$NON-NLS-1$ } final String screen = (String) msg.obj; mProvider.runBatchTransaction(new Runnable() { public void run() { SessionHandler.this.tagScreen(screen); } }); break; } case MESSAGE_UPLOAD: { if (Constants.IS_LOGGABLE) { Log.d(Constants.LOG_TAG, "SessionHandler received MESSAGE_UPLOAD"); //$NON-NLS-1$ } /* * Note that callback may be null */ final Runnable callback = (Runnable) msg.obj; mProvider.runBatchTransaction(new Runnable() { public void run() { SessionHandler.this.upload(callback); } }); break; } case MESSAGE_UPLOAD_CALLBACK: { if (Constants.IS_LOGGABLE) { Log.d(Constants.LOG_TAG, "Handler received MESSAGE_UPLOAD_CALLBACK"); //$NON-NLS-1$ } sIsUploadingMap.put(mApiKey, Boolean.FALSE); break; } default: { /* * This should never happen */ throw new RuntimeException("Fell through switch statement"); //$NON-NLS-1$ } } } catch (final Exception e) { if (Constants.IS_LOGGABLE) { Log.e(Constants.LOG_TAG, "Localytics library threw an uncaught exception", e); //$NON-NLS-1$ } if (!Constants.IS_EXCEPTION_SUPPRESSION_ENABLED) { throw new RuntimeException(e); } } } /** * Projection for querying details of the current API key */ private static final String[] PROJECTION_INIT_API_KEY = new String[] { ApiKeysDbColumns._ID, ApiKeysDbColumns.OPT_OUT, ApiKeysDbColumns.UUID }; /** * Selection for a specific API key ID */ private static final String SELECTION_INIT_API_KEY = String.format("%s = ?", ApiKeysDbColumns.API_KEY); //$NON-NLS-1$ /** * Initialize the handler post construction. * <p> * This method must only be called once. * <p> * 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_INIT} to the Handler. * * @see #MESSAGE_INIT */ /* package */void init() { mProvider = LocalyticsProvider.getInstance(mContext, mApiKey); Cursor cursor = null; try { cursor = mProvider.query(ApiKeysDbColumns.TABLE_NAME, PROJECTION_INIT_API_KEY, SELECTION_INIT_API_KEY, new String[] { 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)); } 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 (null != cursor) { 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()); } /** * Selection for {@link #optOut(boolean)}. */ private static final String SELECTION_OPT_IN_OUT = String.format("%s = ?", ApiKeysDbColumns._ID); //$NON-NLS-1$ /** * Set the opt-in/out-out state for all sessions using the current API key. * <p> * This method must only be called after {@link #init()} is called. * <p> * 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. * <p> * If a session is already open when an opt-out request is made, then data for the remainder of that session will be * collected. For example, calls to {@link #tagEvent(String, Map)} and {@link #tagScreen(String)} will be recorded until * {@link #close(Map)} is called. * <p> * If a session is not already open when an opt-out request is made, a new session is opened and closed by this method in * order to cause the opt-out event to be uploaded. * * @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("Requested opt-out state is %b", Boolean.valueOf(isOptingOut))); //$NON-NLS-1$ } // Do nothing if opt-out is unchanged if (isOptedOut(mProvider, mApiKey) == isOptingOut) { return; } if (null == getOpenSessionId(mProvider)) { /* * Force a session to contain the opt event */ open(true, null); tagEvent(isOptingOut ? OPT_OUT_EVENT : OPT_IN_EVENT, null); close(null); } else { tagEvent(isOptingOut ? OPT_OUT_EVENT : OPT_IN_EVENT, null); } final ContentValues values = new ContentValues(); values.put(ApiKeysDbColumns.OPT_OUT, Boolean.valueOf(isOptingOut)); mProvider.update(ApiKeysDbColumns.TABLE_NAME, values, SELECTION_OPT_IN_OUT, new String[] { Long.toString(mApiKeyId) }); } /** * Projection for {@link #getOpenSessionId(LocalyticsProvider)}. */ private static final String[] PROJECTION_GET_OPEN_SESSION_ID_SESSION_ID = new String[] { SessionsDbColumns._ID }; /** * Projection for getting the event count in {@link #getOpenSessionId(LocalyticsProvider)}. */ private static final String[] PROJECTION_GET_OPEN_SESSION_ID_EVENT_COUNT = new String[] { EventsDbColumns._COUNT }; /** * Selection for {@link #getOpenSessionId(LocalyticsProvider)}. */ private static final String SELECTION_GET_OPEN_SESSION_ID_EVENT_COUNT = String.format("%s = ? AND %s = ?", EventsDbColumns.SESSION_KEY_REF, EventsDbColumns.EVENT_NAME); /** * @param provider The database to query. Cannot be null. * @return The {@link SessionsDbColumns#_ID} of the currently open session or {@code null} if no session is open. The * definition of "open" is whether a session has been opened without a corresponding close event. */ /* package */static Long getOpenSessionId(final LocalyticsProvider provider) { /* * Get the ID of the last session */ final Long sessionId; { Cursor sessionsCursor = null; try { /* * Query all sessions sorted by session ID, which guarantees to obtain the last session regardless of whether * the system clock changed. */ sessionsCursor = provider.query(SessionsDbColumns.TABLE_NAME, PROJECTION_GET_OPEN_SESSION_ID_SESSION_ID, null, null, SessionsDbColumns._ID); if (sessionsCursor.moveToLast()) { sessionId = Long.valueOf(sessionsCursor .getLong(sessionsCursor.getColumnIndexOrThrow(SessionsDbColumns._ID))); } else { return null; } } finally { if (null != sessionsCursor) { sessionsCursor.close(); sessionsCursor = null; } } } /* * See if the session has a close event. */ Cursor eventsCursor = null; try { eventsCursor = provider.query(EventsDbColumns.TABLE_NAME, PROJECTION_GET_OPEN_SESSION_ID_EVENT_COUNT, SELECTION_GET_OPEN_SESSION_ID_EVENT_COUNT, new String[] { sessionId.toString(), CLOSE_EVENT }, null); if (eventsCursor.moveToFirst()) { if (0 == eventsCursor.getInt(0)) { return sessionId; } } } finally { if (null != eventsCursor) { eventsCursor.close(); eventsCursor = null; } } return null; } /** * Projection for {@link #open(boolean, Map)}. */ private static final String[] PROJECTION_OPEN_EVENT_ID = new String[] { EventsDbColumns._ID }; /** * Selection for {@link #open(boolean, Map)}. */ private static final String SELECTION_OPEN = String.format("%s = ? AND %s >= ?", EventsDbColumns.EVENT_NAME, //$NON-NLS-1$ EventsDbColumns.WALL_TIME); /** * Projection for {@link #open(boolean, Map)}. */ private static final String[] PROJECTION_OPEN_BLOB_EVENTS = new String[] { UploadBlobEventsDbColumns.EVENTS_KEY_REF }; /** * Projection for {@link #open(boolean, Map)}. */ private static final String[] PROJECTION_OPEN_SESSIONS = new String[] { SessionsDbColumns._ID, SessionsDbColumns.SESSION_START_WALL_TIME }; /** * Selection for {@link #openNewSession(Map)}. */ private static final String SELECTION_OPEN_NEW_SESSION = String.format("%s = ?", ApiKeysDbColumns.API_KEY); //$NON-NLS-1$ /** * Selection for {@link #open(boolean, Map)}. */ private static final String SELECTION_OPEN_DELETE_EMPTIES_EVENT_HISTORY_SESSION_KEY_REF = String .format("%s = ?", EventHistoryDbColumns.SESSION_KEY_REF); //$NON-NLS-1$ /** * Selection for {@link #open(boolean, Map)}. */ private static final String SELECTION_OPEN_DELETE_EMPTIES_EVENTS_SESSION_KEY_REF = String.format("%s = ?", //$NON-NLS-1$ EventsDbColumns.SESSION_KEY_REF); /** * Projection for {@link #open(boolean, Map)}. */ private static final String[] PROJECTION_OPEN_DELETE_EMPTIES_EVENT_ID = new String[] { EventsDbColumns._ID }; /** * Projection for {@link #open(boolean, Map)}. */ private static final String[] PROJECTION_OPEN_DELETE_EMPTIES_PROCESSED_IN_BLOB = new String[] { EventHistoryDbColumns.PROCESSED_IN_BLOB }; /** * Selection for {@link #open(boolean, Map)}. */ private static final String SELECTION_OPEN_DELETE_EMPTIES_UPLOAD_BLOBS_ID = String.format("%s = ?", //$NON-NLS-1$ UploadBlobsDbColumns._ID); /** * Selection for {@link #open(boolean, Map)}. */ private static final String SELECTION_OPEN_DELETE_EMPTIES_SESSIONS_ID = String.format("%s = ?", //$NON-NLS-1$ SessionsDbColumns._ID); /** * Open a session. While this method should only be called once without an intervening call to {@link #close(Map)}, * nothing bad will happen if it is called multiple times. * <p> * This method must only be called after {@link #init()} is called. * <p> * 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. * @param attributes Attributes to attach to the open. May be null indicating no attributes. Cannot contain null or empty * keys or values. * @see #MESSAGE_OPEN */ /* package */void open(final boolean ignoreLimits, final Map<String, String> attributes) { // if (null != getOpenSessionId(mProvider)) // { // if (Constants.IS_LOGGABLE) // { // Log.w(Constants.LOG_TAG, "Session was already open"); //$NON-NLS-1$ // } // // return; // } if (isOptedOut(mProvider, mApiKey)) { 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, PROJECTION_OPEN_EVENT_ID, SELECTION_OPEN, new String[] { CLOSE_EVENT, Long.toString(System.currentTimeMillis() - Constants.SESSION_EXPIRATION) }, EVENTS_SORT_ORDER); blob_eventsCursor = mProvider.query(UploadBlobEventsDbColumns.TABLE_NAME, PROJECTION_OPEN_BLOB_EVENTS, null, null, UPLOAD_BLOBS_EVENTS_SORT_ORDER); final int idColumn = eventsCursor.getColumnIndexOrThrow(EventsDbColumns._ID); final CursorJoiner joiner = new CursorJoiner(eventsCursor, PROJECTION_OPEN_EVENT_ID, blob_eventsCursor, PROJECTION_OPEN_BLOB_EVENTS); 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$ } final 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 (null != eventsCursor) { eventsCursor.close(); eventsCursor = null; } if (null != blob_eventsCursor) { blob_eventsCursor.close(); blob_eventsCursor = null; } } } if (-1 != closeEventId) { if (Constants.IS_LOGGABLE) { Log.v(Constants.LOG_TAG, "Opening old closed session and reconnecting"); //$NON-NLS-1$ } openClosedSession(closeEventId); } else { Cursor sessionsCursor = null; try { sessionsCursor = mProvider.query(SessionsDbColumns.TABLE_NAME, PROJECTION_OPEN_SESSIONS, null, null, SessionsDbColumns._ID); if (sessionsCursor.moveToLast()) { if (sessionsCursor.getLong(sessionsCursor.getColumnIndexOrThrow( SessionsDbColumns.SESSION_START_WALL_TIME)) >= System.currentTimeMillis() - Constants.SESSION_EXPIRATION) { // reconnect if (Constants.IS_LOGGABLE) { Log.v(Constants.LOG_TAG, "Opening old unclosed session and reconnecting"); //$NON-NLS-1$ } return; } // delete empties Cursor eventsCursor = null; try { final String sessionId = Long.toString(sessionsCursor .getLong(sessionsCursor.getColumnIndexOrThrow(SessionsDbColumns._ID))); final String[] sessionIdSelection = new String[] { sessionId }; eventsCursor = mProvider.query(EventsDbColumns.TABLE_NAME, PROJECTION_OPEN_DELETE_EMPTIES_EVENT_ID, SELECTION_OPEN_DELETE_EMPTIES_EVENTS_SESSION_KEY_REF, sessionIdSelection, null); if (eventsCursor.getCount() == 0) { final List<Long> blobsToDelete = new LinkedList<Long>(); // delete all event history and the upload blob Cursor eventHistory = null; try { eventHistory = mProvider.query(EventHistoryDbColumns.TABLE_NAME, PROJECTION_OPEN_DELETE_EMPTIES_PROCESSED_IN_BLOB, SELECTION_OPEN_DELETE_EMPTIES_EVENT_HISTORY_SESSION_KEY_REF, sessionIdSelection, null); while (eventHistory.moveToNext()) { blobsToDelete.add(Long.valueOf(eventHistory.getLong(eventHistory .getColumnIndexOrThrow(EventHistoryDbColumns.PROCESSED_IN_BLOB)))); } } finally { if (null != eventHistory) { eventHistory.close(); eventHistory = null; } } mProvider.delete(EventHistoryDbColumns.TABLE_NAME, SELECTION_OPEN_DELETE_EMPTIES_EVENT_HISTORY_SESSION_KEY_REF, sessionIdSelection); for (final long blobId : blobsToDelete) { mProvider.delete(UploadBlobsDbColumns.TABLE_NAME, SELECTION_OPEN_DELETE_EMPTIES_UPLOAD_BLOBS_ID, new String[] { Long.toString(blobId) }); } // mProvider.delete(AttributesDbColumns.TABLE_NAME, String.format("%s = ?", // AttributesDbColumns.EVENTS_KEY_REF), selectionArgs) mProvider.delete(SessionsDbColumns.TABLE_NAME, SELECTION_OPEN_DELETE_EMPTIES_SESSIONS_ID, sessionIdSelection); } } 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 && getNumberOfSessions(mProvider) >= 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$ } } else { if (Constants.IS_LOGGABLE) { Log.v(Constants.LOG_TAG, "Opening new session"); //$NON-NLS-1$ } openNewSession(attributes); } } } /** * Opens a new session. This is a helper method to {@link #open(boolean, Map)}. * * @effects Updates the database by creating a new entry in the {@link SessionsDbColumns} table. * @param attributes Attributes to attach to the session. May be null. Cannot contain null or empty keys or values. */ private void openNewSession(final Map<String, String> attributes) { 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 (null == deviceId) { Cursor cursor = null; try { cursor = mProvider.query(ApiKeysDbColumns.TABLE_NAME, null, SELECTION_OPEN_NEW_SESSION, new String[] { mApiKey }, null); 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.DEVICE_WIFI_MAC_HASH, DatapointHelper.getWifiMacHashOrNull(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.put(SessionsDbColumns.LOCALYTICS_INSTALLATION_ID, getInstallationId(mProvider, mApiKey)); 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)); long sessionId = mProvider.insert(SessionsDbColumns.TABLE_NAME, values); if (sessionId == -1) { throw new AssertionError("session insert failed"); //$NON-NLS-1$ } tagEvent(OPEN_EVENT, attributes); /* * This is placed here so that the DatapointHelper has a chance to retrieve the old UUID before it is deleted. */ LocalyticsProvider.deleteOldFiles(mContext); } /** * Projection for getting the installation ID. Used by {@link #getInstallationId(LocalyticsProvider, String)}. */ private static final String[] PROJECTION_GET_INSTALLATION_ID = new String[] { ApiKeysDbColumns.UUID }; /** * Selection for a specific API key ID. Used by {@link #getInstallationId(LocalyticsProvider, String)}. */ private static final String SELECTION_GET_INSTALLATION_ID = String.format("%s = ?", //$NON-NLS-1$ ApiKeysDbColumns.API_KEY); /** * Gets the installation ID of the API key. */ private static String getInstallationId(final LocalyticsProvider provider, final String apiKey) { Cursor cursor = null; try { cursor = provider.query(ApiKeysDbColumns.TABLE_NAME, PROJECTION_GET_INSTALLATION_ID, SELECTION_GET_INSTALLATION_ID, new String[] { apiKey }, null); if (cursor.moveToFirst()) { return cursor.getString(cursor.getColumnIndexOrThrow(ApiKeysDbColumns.UUID)); } } finally { if (null != cursor) { cursor.close(); cursor = null; } } /* * This error case shouldn't normally happen */ if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "Installation ID couldn't be found"); //$NON-NLS-1$ } return null; } /** * Projection for {@link #openClosedSession(long)}. */ private static final String[] PROJECTION_OPEN_CLOSED_SESSION = new String[] { EventsDbColumns.SESSION_KEY_REF }; /** * Selection for {@link #openClosedSession(long)}. */ private static final String SELECTION_OPEN_CLOSED_SESSION = String.format("%s = ?", EventsDbColumns._ID); //$NON-NLS-1$ /** * Selection for {@link #openClosedSession(long)}. */ private static final String SELECTION_OPEN_CLOSED_SESSION_ATTRIBUTES = String.format("%s = ?", //$NON-NLS-1$ AttributesDbColumns.EVENTS_KEY_REF); /** * Reopens a previous session. This is a helper method to {@link #open(boolean, Map)}. * * @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. */ private void openClosedSession(final long closeEventId) { final String[] selectionArgs = new String[] { Long.toString(closeEventId) }; Cursor cursor = null; try { cursor = mProvider.query(EventsDbColumns.TABLE_NAME, PROJECTION_OPEN_CLOSED_SESSION, SELECTION_OPEN_CLOSED_SESSION, selectionArgs, null); if (cursor.moveToFirst()) { mProvider.delete(AttributesDbColumns.TABLE_NAME, SELECTION_OPEN_CLOSED_SESSION_ATTRIBUTES, selectionArgs); mProvider.delete(EventsDbColumns.TABLE_NAME, SELECTION_OPEN_CLOSED_SESSION, selectionArgs); } else { /* * This should never happen */ if (Constants.IS_LOGGABLE) { Log.e(Constants.LOG_TAG, "Event no longer exists"); //$NON-NLS-1$ } openNewSession(null); } } finally { if (null != cursor) { cursor.close(); cursor = null; } } } /** * Projection for {@link #getNumberOfSessions(LocalyticsProvider)}. */ private static final String[] PROJECTION_GET_NUMBER_OF_SESSIONS = new String[] { SessionsDbColumns._ID }; /** * Helper method to get the number of sessions currently in the database. * * @param provider Instance of {@link LocalyticsProvider}. Cannot be null. * @return The number of sessions on disk. */ /* package */static long getNumberOfSessions(final LocalyticsProvider provider) { Cursor cursor = null; try { cursor = provider.query(SessionsDbColumns.TABLE_NAME, PROJECTION_GET_NUMBER_OF_SESSIONS, null, null, null); return cursor.getCount(); } finally { if (null != cursor) { cursor.close(); cursor = null; } } } /** * Close a session. While this method should only be called after {@link #open(boolean, Map)}, nothing bad will happen if * it is called and {@link #open(boolean, Map)} wasn't called. Similarly, nothing bad will happen if close is called * multiple times. * <p> * This method must only be called after {@link #init()} is called. * <p> * 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. * * @param attributes Set of attributes to attach to the close. May be null indicating no attributes. Cannot contain null * or empty keys or values. * @see #MESSAGE_OPEN */ /* package */void close(final Map<String, String> attributes) { if (null == getOpenSessionId(mProvider)) // 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, attributes); } /** * Projection for {@link #tagEvent(String, Map)}. */ private static final String[] PROJECTION_TAG_EVENT = new String[] { SessionsDbColumns.SESSION_START_WALL_TIME }; /** * Selection for {@link #tagEvent(String, Map)}. */ private static final String SELECTION_TAG_EVENT = String.format("%s = ?", SessionsDbColumns._ID); //$NON-NLS-1$ /** * Tag an event in a session. Although this method SHOULD NOT be called unless a session is open, actually doing so will * have no effect. * <p> * This method must only be called after {@link #init()} is called. * <p> * 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. Cannot be null. * @param attributes The collection of attributes for this particular event. May be null. * @see #MESSAGE_TAG_EVENT */ /* package */void tagEvent(final String event, final Map<String, String> attributes) { final Long openSessionId = getOpenSessionId(mProvider); if (null == openSessionId) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "Event not written because a session is not open"); //$NON-NLS-1$ } return; } /* * Insert the event and get the event's database ID */ final long eventId; { final ContentValues values = new ContentValues(); values.put(EventsDbColumns.SESSION_KEY_REF, openSessionId); values.put(EventsDbColumns.UUID, UUID.randomUUID().toString()); values.put(EventsDbColumns.EVENT_NAME, event); values.put(EventsDbColumns.REAL_TIME, Long.valueOf(SystemClock.elapsedRealtime())); values.put(EventsDbColumns.WALL_TIME, Long.valueOf(System.currentTimeMillis())); /* * Special case for open event: keep the start time in sync with the start time put into the sessions table. */ if (OPEN_EVENT.equals(event)) { Cursor cursor = null; try { cursor = mProvider.query(SessionsDbColumns.TABLE_NAME, PROJECTION_TAG_EVENT, SELECTION_TAG_EVENT, new String[] { openSessionId.toString() }, null); if (cursor.moveToFirst()) { values.put(EventsDbColumns.WALL_TIME, Long.valueOf(cursor.getLong( cursor.getColumnIndexOrThrow(SessionsDbColumns.SESSION_START_WALL_TIME)))); } else { // this should never happen throw new AssertionError("During tag of open event, session didn't exist"); //$NON-NLS-1$ } } finally { if (null != cursor) { cursor.close(); cursor = null; } } } eventId = mProvider.insert(EventsDbColumns.TABLE_NAME, values); if (-1 == eventId) { throw new RuntimeException("Inserting event failed"); //$NON-NLS-1$ } } /* * If attributes exist, insert them as well */ if (null != attributes) { // reusable object final ContentValues values = new ContentValues(); final String applicationAttributePrefix = String.format(AttributesDbColumns.ATTRIBUTE_FORMAT, mContext.getPackageName(), ""); //$NON-NLS-1$ int applicationAttributeCount = 0; for (final Entry<String, String> entry : attributes.entrySet()) { /* * Detect excess application events */ if (entry.getKey().startsWith(applicationAttributePrefix)) { applicationAttributeCount++; if (applicationAttributeCount > Constants.MAX_NUM_ATTRIBUTES) { continue; } } values.put(AttributesDbColumns.EVENTS_KEY_REF, Long.valueOf(eventId)); values.put(AttributesDbColumns.ATTRIBUTE_KEY, entry.getKey()); values.put(AttributesDbColumns.ATTRIBUTE_VALUE, entry.getValue()); final long id = mProvider.insert(AttributesDbColumns.TABLE_NAME, values); if (-1 == id) { throw new AssertionError("Inserting attribute failed"); //$NON-NLS-1$ } values.clear(); } } /* * Insert the event into the history, only for application events */ if (!OPEN_EVENT.equals(event) && !CLOSE_EVENT.equals(event) && !OPT_IN_EVENT.equals(event) && !OPT_OUT_EVENT.equals(event) && !FLOW_EVENT.equals(event)) { final ContentValues values = new ContentValues(); values.put(EventHistoryDbColumns.NAME, event.substring(mContext.getPackageName().length() + 1, event.length())); values.put(EventHistoryDbColumns.TYPE, Integer.valueOf(EventHistoryDbColumns.TYPE_EVENT)); values.put(EventHistoryDbColumns.SESSION_KEY_REF, openSessionId); values.putNull(EventHistoryDbColumns.PROCESSED_IN_BLOB); mProvider.insert(EventHistoryDbColumns.TABLE_NAME, values); conditionallyAddFlowEvent(); } } /** * Projection for {@link #tagScreen(String)}. */ private static final String[] PROJECTION_TAG_SCREEN = new String[] { EventHistoryDbColumns.NAME }; /** * Selection for {@link #tagScreen(String)}. */ private static final String SELECTION_TAG_SCREEN = String.format("%s = ? AND %s = ?", //$NON-NLS-1$ EventHistoryDbColumns.TYPE, EventHistoryDbColumns.SESSION_KEY_REF); /** * Sort order for {@link #tagScreen(String)}. */ private static final String SORT_ORDER_TAG_SCREEN = String.format("%s DESC", EventHistoryDbColumns._ID); //$NON-NLS-1$ /** * Tag a screen in a session. While this method shouldn't be called unless {@link #open(boolean, Map)} is called first, * this method will simply do nothing if {@link #open(boolean, Map)} hasn't been called. * <p> * This method performs duplicate suppression, preventing multiple screens with the same value in a row within a given * session. * <p> * This method must only be called after {@link #init()} is called. * <p> * 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) { final Long openSessionId = getOpenSessionId(mProvider); if (null == openSessionId) { 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, PROJECTION_TAG_SCREEN, SELECTION_TAG_SCREEN, new String[] { Integer.toString(EventHistoryDbColumns.TYPE_SCREEN), openSessionId.toString() }, SORT_ORDER_TAG_SCREEN); 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, openSessionId); values.putNull(EventHistoryDbColumns.PROCESSED_IN_BLOB); mProvider.insert(EventHistoryDbColumns.TABLE_NAME, values); conditionallyAddFlowEvent(); } /** * Projection for {@link #conditionallyAddFlowEvent()}. */ private static final String[] PROJECTION_FLOW_EVENTS = new String[] { EventsDbColumns._ID }; /** * Selection for {@link #conditionallyAddFlowEvent()}. */ private static final String SELECTION_FLOW_EVENTS = String.format("%s = ?", EventsDbColumns.EVENT_NAME); //$NON-NLS-1$ /** * Selection arguments for {@link #SELECTION_FLOW_EVENTS} in {@link #conditionallyAddFlowEvent()}. */ private static final String[] SELECTION_ARGS_FLOW_EVENTS = new String[] { FLOW_EVENT }; /** * Projection for {@link #conditionallyAddFlowEvent()}. */ private static final String[] PROJECTION_FLOW_BLOBS = new String[] { UploadBlobEventsDbColumns.EVENTS_KEY_REF }; /** * 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, PROJECTION_FLOW_EVENTS, SELECTION_FLOW_EVENTS, SELECTION_ARGS_FLOW_EVENTS, EVENTS_SORT_ORDER); blob_eventsCursor = mProvider.query(UploadBlobEventsDbColumns.TABLE_NAME, PROJECTION_FLOW_BLOBS, null, null, UPLOAD_BLOBS_EVENTS_SORT_ORDER); final CursorJoiner joiner = new CursorJoiner(eventsCursor, PROJECTION_FLOW_EVENTS, blob_eventsCursor, PROJECTION_FLOW_BLOBS); for (final CursorJoiner.Result joinerResult : joiner) { switch (joinerResult) { case LEFT: { foundUnassociatedFlowEvent = true; break; } case BOTH: break; case RIGHT: break; } } } finally { if (null != eventsCursor) { eventsCursor.close(); eventsCursor = null; } if (null != blob_eventsCursor) { blob_eventsCursor.close(); blob_eventsCursor = null; } } if (!foundUnassociatedFlowEvent) { tagEvent(FLOW_EVENT, null); } } /** * Projection for {@link #preUploadBuildBlobs(LocalyticsProvider)}. */ private static final String[] PROJECTION_UPLOAD_EVENTS = new String[] { EventsDbColumns._ID, EventsDbColumns.EVENT_NAME, EventsDbColumns.WALL_TIME }; /** * Projection for {@link #preUploadBuildBlobs(LocalyticsProvider)}. */ private static final String[] PROJECTION_UPLOAD_BLOBS = new String[] { UploadBlobEventsDbColumns.EVENTS_KEY_REF }; /** * Projection for {@link #preUploadBuildBlobs(LocalyticsProvider)}. */ private static final String SELECTION_UPLOAD_NULL_BLOBS = String.format("%s IS NULL", //$NON-NLS-1$ EventHistoryDbColumns.PROCESSED_IN_BLOB); /** * Columns to join in {@link #preUploadBuildBlobs(LocalyticsProvider)}. */ private static final String[] JOINER_ARG_UPLOAD_EVENTS_COLUMNS = new String[] { EventsDbColumns._ID }; /** * Builds upload blobs for all events. * * @param provider Instance of {@link LocalyticsProvider}. Cannot be null. * @effects Mutates the database by creating a new upload blob for all events that are unassociated at the time this * method is called. */ /* package */static void preUploadBuildBlobs(final LocalyticsProvider provider) { /* * 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<Long> eventIds = new HashSet<Long>(); Cursor eventsCursor = null; Cursor blob_eventsCursor = null; try { eventsCursor = provider.query(EventsDbColumns.TABLE_NAME, PROJECTION_UPLOAD_EVENTS, null, null, EVENTS_SORT_ORDER); blob_eventsCursor = provider.query(UploadBlobEventsDbColumns.TABLE_NAME, PROJECTION_UPLOAD_BLOBS, null, null, UPLOAD_BLOBS_EVENTS_SORT_ORDER); final int idColumn = eventsCursor.getColumnIndexOrThrow(EventsDbColumns._ID); final CursorJoiner joiner = new CursorJoiner(eventsCursor, JOINER_ARG_UPLOAD_EVENTS_COLUMNS, blob_eventsCursor, PROJECTION_UPLOAD_BLOBS); for (final CursorJoiner.Result joinerResult : joiner) { switch (joinerResult) { case LEFT: { if (CLOSE_EVENT.equals(eventsCursor .getString(eventsCursor.getColumnIndexOrThrow(EventsDbColumns.EVENT_NAME)))) { if (System.currentTimeMillis() - eventsCursor.getLong(eventsCursor.getColumnIndexOrThrow( EventsDbColumns.WALL_TIME)) < Constants.SESSION_EXPIRATION) { break; } } eventIds.add(Long.valueOf(eventsCursor.getLong(idColumn))); break; } case BOTH: break; case RIGHT: break; } } } finally { if (null != eventsCursor) { eventsCursor.close(); eventsCursor = null; } if (null != blob_eventsCursor) { blob_eventsCursor.close(); blob_eventsCursor = null; } } if (eventIds.size() > 0) { // reusable object final ContentValues values = new ContentValues(); final Long blobId; { values.put(UploadBlobsDbColumns.UUID, UUID.randomUUID().toString()); blobId = Long.valueOf(provider.insert(UploadBlobsDbColumns.TABLE_NAME, values)); values.clear(); } for (final Long x : eventIds) { values.put(UploadBlobEventsDbColumns.UPLOAD_BLOBS_KEY_REF, blobId); values.put(UploadBlobEventsDbColumns.EVENTS_KEY_REF, x); provider.insert(UploadBlobEventsDbColumns.TABLE_NAME, values); values.clear(); } values.put(EventHistoryDbColumns.PROCESSED_IN_BLOB, blobId); provider.update(EventHistoryDbColumns.TABLE_NAME, values, SELECTION_UPLOAD_NULL_BLOBS, null); values.clear(); } } /** * Initiate upload of all session data currently stored on disk. * <p> * This method must only be called after {@link #init()} is called. The session does not need to be open for an upload to * occur. * <p> * 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 { preUploadBuildBlobs(mProvider); 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 (null != callback) { /* * 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(); } } } /** * Projection for {@link #isOptedOut(LocalyticsProvider, String)}. */ private static final String[] PROJECTION_IS_OPTED_OUT = new String[] { ApiKeysDbColumns.OPT_OUT }; /** * Selection for {@link #isOptedOut(LocalyticsProvider, String)}. * <p> * The selection argument is the {@link ApiKeysDbColumns#API_KEY}. */ private static final String SELECTION_IS_OPTED_OUT = String.format("%s = ?", ApiKeysDbColumns.API_KEY); //$NON-NLS-1$ /** * @param provider Instance of {@link LocalyticsProvider}. Cannot be null. * @param apiKey Api key to test whether it is opted out. Cannot be null. * @return true if data collection has been opted out. Returns false if data collection is opted-in or if {@code apiKey} * doesn't exist in the database. * @throws IllegalArgumentException if {@code provider} is null. * @throws IllegalArgumentException if {@code apiKey} is null. */ /* package */static boolean isOptedOut(final LocalyticsProvider provider, final String apiKey) { if (Constants.IS_PARAMETER_CHECKING_ENABLED) { if (null == provider) { throw new IllegalArgumentException("provider cannot be null"); //$NON-NLS-1$ } if (null == apiKey) { throw new IllegalArgumentException("apiKey cannot be null"); //$NON-NLS-1$ } } Cursor cursor = null; try { cursor = provider.query(ApiKeysDbColumns.TABLE_NAME, PROJECTION_IS_OPTED_OUT, SELECTION_IS_OPTED_OUT, new String[] { apiKey }, null); if (cursor.moveToFirst()) { return cursor.getInt(cursor.getColumnIndexOrThrow(ApiKeysDbColumns.OPT_OUT)) != 0; } } finally { if (null != cursor) { cursor.close(); cursor = null; } } return false; } } /** * 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 * <p> * {@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}. * <p> * {@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 */ protected 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}. * <p> * 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) { try { 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<JSONObject> toUpload = convertDatabaseToJson(mContext, mProvider, mApiKey); if (!toUpload.isEmpty()) { final StringBuilder builder = new StringBuilder(); for (final JSONObject json : toUpload) { builder.append(json.toString()); builder.append('\n'); } if (uploadSessions(String.format(ANALYTICS_URL, mApiKey), builder.toString())) { mProvider.runBatchTransaction(new Runnable() { public void run() { deleteBlobsAndSessions(mProvider); } }); } } } finally { if (null != callback) { /* * Execute the callback on a separate thread, to avoid exposing this thread to the client of the * library */ new Thread(callback, UPLOAD_CALLBACK_THREAD_NAME).start(); } mSessionHandler.sendEmptyMessage(SessionHandler.MESSAGE_UPLOAD_CALLBACK); } break; } case MESSAGE_RETRY_UPLOAD_REQUEST: { if (Constants.IS_LOGGABLE) { Log.d(Constants.LOG_TAG, "Received MESSAGE_RETRY_UPLOAD_REQUEST"); //$NON-NLS-1$ } mSessionHandler .sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_UPLOAD, msg.obj)); break; } default: { /* * This should never happen */ throw new RuntimeException("Fell through switch statement"); //$NON-NLS-1$ } } } catch (final Exception e) { if (Constants.IS_LOGGABLE) { Log.e(Constants.LOG_TAG, "Localytics library threw an uncaught exception", e); //$NON-NLS-1$ } if (!Constants.IS_EXCEPTION_SUPPRESSION_ENABLED) { throw new RuntimeException(e); } } } /** * Uploads the post Body to the webservice * * @param url where {@code body} will be posted to. Cannot be null. * @param body upload body as a string. This should be a plain old string. Cannot be null. * @return True on success, false on failure. */ /* package */static boolean uploadSessions(final String url, final String body) { if (Constants.IS_PARAMETER_CHECKING_ENABLED) { if (null == url) { throw new IllegalArgumentException("url cannot be null"); //$NON-NLS-1$ } if (null == body) { throw new IllegalArgumentException("body cannot be null"); //$NON-NLS-1$ } } if (Constants.IS_LOGGABLE) { Log.v(Constants.LOG_TAG, String.format("Upload body before compression is: %s", body)); //$NON-NLS-1$ } /* * As per Google's documentation, use HttpURLConnection for API 9 and greater and DefaultHttpClient for API 8 and * lower. <http://android-developers.blogspot.com/2011/09/androids-http-clients.html>. HTTP library. * * Note: HTTP GZIP compression is explicitly disabled. Instead, the uploaded data is already GZIPPED before it is put * into the HTTP post. */ if (DatapointHelper.getApiLevel() >= 9) { /* * GZIP the data to upload */ byte[] data; { GZIPOutputStream gos = null; try { final byte[] originalBytes = body.getBytes("UTF-8"); //$NON-NLS-1$ final ByteArrayOutputStream baos = new ByteArrayOutputStream(originalBytes.length); gos = new GZIPOutputStream(baos); gos.write(originalBytes); gos.finish(); gos.flush(); data = baos.toByteArray(); } catch (final UnsupportedEncodingException e) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "UnsupportedEncodingException", e); //$NON-NLS-1$ } return false; } catch (final IOException e) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "IOException", e); //$NON-NLS-1$ } return false; } finally { if (null != gos) { try { gos.close(); gos = null; } catch (final IOException e) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "Caught exception", e); //$NON-NLS-1$ } return false; } } } } HttpURLConnection connection = null; try { connection = (HttpURLConnection) new URL(url).openConnection(); connection.setDoOutput(true); // sets POST method implicitly connection.setRequestProperty("Content-Type", "application/x-gzip"); //$NON-NLS-1$//$NON-NLS-2$ connection.setFixedLengthStreamingMode(data.length); OutputStream stream = null; try { stream = connection.getOutputStream(); stream.write(data); } finally { if (null != stream) { stream.flush(); stream.close(); stream = null; } } final int responseCode = connection.getResponseCode(); if (Constants.IS_LOGGABLE) { Log.v(Constants.LOG_TAG, String.format("Upload complete with status %d", Integer.valueOf(responseCode))); //$NON-NLS-1$ } /* * 5xx status codes indicate a server error, so upload should be reattempted */ if (responseCode >= 500 && responseCode <= 599) { return false; } } catch (final MalformedURLException e) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "ClientProtocolException", e); //$NON-NLS-1$ } return false; } catch (final IOException e) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "ClientProtocolException", e); //$NON-NLS-1$ } return false; } finally { if (null != connection) { connection.disconnect(); connection = null; } } } else { /* * Note: DefaultHttpClient appears to sometimes cause an OutOfMemory error. Although we've seen exceptions from * the wild, it isn't clear whether this is due to a bug in DefaultHttpClient or just a random error that has * occurred once or twice due to buggy devices. */ final DefaultHttpClient client = new DefaultHttpClient(); final HttpPost method = new HttpPost(url); method.addHeader("Content-Type", "application/x-gzip"); //$NON-NLS-1$ //$NON-NLS-2$ GZIPOutputStream gos = null; try { final byte[] originalBytes = body.getBytes("UTF-8"); //$NON-NLS-1$ final ByteArrayOutputStream baos = new ByteArrayOutputStream(originalBytes.length); gos = new GZIPOutputStream(baos); gos.write(originalBytes); gos.finish(); gos.flush(); final ByteArrayEntity postBody = new ByteArrayEntity(baos.toByteArray()); method.setEntity(postBody); final HttpResponse response = client.execute(method); final StatusLine status = response.getStatusLine(); final int statusCode = status.getStatusCode(); if (Constants.IS_LOGGABLE) { Log.v(Constants.LOG_TAG, String.format("Upload complete with status %d", Integer.valueOf(statusCode))); //$NON-NLS-1$ } /* * 5xx status codes indicate a server error, so upload should be reattempted */ if (statusCode >= 500 && statusCode <= 599) { return false; } } catch (final UnsupportedEncodingException e) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "UnsupportedEncodingException", e); //$NON-NLS-1$ } return false; } catch (final ClientProtocolException e) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "ClientProtocolException", e); //$NON-NLS-1$ } return false; } catch (final IOException e) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "IOException", e); //$NON-NLS-1$ } return false; } finally { if (null != gos) { try { gos.close(); gos = null; } catch (final IOException e) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "Caught exception", e); //$NON-NLS-1$ } } } } } return true; } /** * Helper that converts blobs in the database into a JSON representation for upload. * * @return A list of JSON objecs to upload to the server */ /* package */static List<JSONObject> convertDatabaseToJson(final Context context, final LocalyticsProvider provider, final String apiKey) { final List<JSONObject> result = new LinkedList<JSONObject>(); Cursor cursor = null; try { cursor = provider.query(UploadBlobsDbColumns.TABLE_NAME, null, null, null, null); final long creationTime = getApiKeyCreationTime(provider, apiKey); final int idColumn = cursor.getColumnIndexOrThrow(UploadBlobsDbColumns._ID); final int uuidColumn = cursor.getColumnIndexOrThrow(UploadBlobsDbColumns.UUID); while (cursor.moveToNext()) { try { final JSONObject blobHeader = new JSONObject(); blobHeader.put(JsonObjects.BlobHeader.KEY_DATA_TYPE, BlobHeader.VALUE_DATA_TYPE); blobHeader.put(JsonObjects.BlobHeader.KEY_PERSISTENT_STORAGE_CREATION_TIME_SECONDS, creationTime); blobHeader.put(JsonObjects.BlobHeader.KEY_SEQUENCE_NUMBER, cursor.getLong(idColumn)); blobHeader.put(JsonObjects.BlobHeader.KEY_UNIQUE_ID, cursor.getString(uuidColumn)); blobHeader.put(JsonObjects.BlobHeader.KEY_ATTRIBUTES, getAttributesFromSession(provider, apiKey, getSessionIdForBlobId(provider, cursor.getLong(idColumn)))); result.add(blobHeader); Cursor blobEvents = null; try { blobEvents = provider.query(UploadBlobEventsDbColumns.TABLE_NAME, new String[] { UploadBlobEventsDbColumns.EVENTS_KEY_REF }, String.format("%s = ?", UploadBlobEventsDbColumns.UPLOAD_BLOBS_KEY_REF), //$NON-NLS-1$ new String[] { Long.toString(cursor.getLong(idColumn)) }, UploadBlobEventsDbColumns.EVENTS_KEY_REF); final int eventIdColumn = blobEvents .getColumnIndexOrThrow(UploadBlobEventsDbColumns.EVENTS_KEY_REF); while (blobEvents.moveToNext()) { result.add(convertEventToJson(provider, context, blobEvents.getLong(eventIdColumn), cursor.getLong(idColumn), apiKey)); } } finally { if (null != blobEvents) { blobEvents.close(); } } } catch (final JSONException e) { if (Constants.IS_LOGGABLE) { Log.w(Constants.LOG_TAG, "Caught exception", e); //$NON-NLS-1$ } } } } finally { if (cursor != null) { cursor.close(); cursor = null; } } if (Constants.IS_LOGGABLE) { Log.v(Constants.LOG_TAG, String.format("JSON result is %s", result.toString())); //$NON-NLS-1$ } return result; } /** * Deletes all blobs and sessions/events/attributes associated with those blobs. * <p> * 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<Long> sessionsToDelete = new LinkedList<Long>(); final HashSet<Long> blobsToDelete = new HashSet<Long>(); Cursor blobEvents = null; try { blobEvents = provider.query(UploadBlobEventsDbColumns.TABLE_NAME, new String[] { UploadBlobEventsDbColumns._ID, UploadBlobEventsDbColumns.EVENTS_KEY_REF, UploadBlobEventsDbColumns.UPLOAD_BLOBS_KEY_REF }, null, null, null); final int uploadBlobIdColumn = blobEvents .getColumnIndexOrThrow(UploadBlobEventsDbColumns.UPLOAD_BLOBS_KEY_REF); final int blobEventIdColumn = blobEvents.getColumnIndexOrThrow(UploadBlobEventsDbColumns._ID); final int eventIdColumn = blobEvents .getColumnIndexOrThrow(UploadBlobEventsDbColumns.EVENTS_KEY_REF); while (blobEvents.moveToNext()) { final long blobId = blobEvents.getLong(uploadBlobIdColumn); final long blobEventId = blobEvents.getLong(blobEventIdColumn); final long eventId = blobEvents.getLong(eventIdColumn); // delete the blobevent provider.delete(UploadBlobEventsDbColumns.TABLE_NAME, String.format("%s = ?", UploadBlobEventsDbColumns._ID), //$NON-NLS-1$ new String[] { Long.toString(blobEventId) }); /* * Add the blob to the list of blobs to be deleted */ blobsToDelete.add(Long.valueOf(blobId)); // delete all attributes for the event provider.delete(AttributesDbColumns.TABLE_NAME, String.format("%s = ?", AttributesDbColumns.EVENTS_KEY_REF), //$NON-NLS-1$ new String[] { Long.toString(eventId) }); /* * Check to see if the event is a close event, indicating that the session is complete and can also be deleted */ Cursor eventCursor = null; try { eventCursor = provider.query(EventsDbColumns.TABLE_NAME, new String[] { EventsDbColumns.SESSION_KEY_REF }, String.format("%s = ? AND %s = ?", EventsDbColumns._ID, EventsDbColumns.EVENT_NAME), //$NON-NLS-1$ new String[] { Long.toString(eventId), CLOSE_EVENT }, null); if (eventCursor.moveToFirst()) { final long sessionId = eventCursor .getLong(eventCursor.getColumnIndexOrThrow(EventsDbColumns.SESSION_KEY_REF)); provider.delete(EventHistoryDbColumns.TABLE_NAME, String.format("%s = ?", EventHistoryDbColumns.SESSION_KEY_REF), new String[] //$NON-NLS-1$ { Long.toString(sessionId) }); sessionsToDelete.add(Long.valueOf(eventCursor .getLong(eventCursor.getColumnIndexOrThrow(EventsDbColumns.SESSION_KEY_REF)))); } } finally { if (null != eventCursor) { eventCursor.close(); eventCursor = null; } } // delete the event provider.delete(EventsDbColumns.TABLE_NAME, String.format("%s = ?", EventsDbColumns._ID), //$NON-NLS-1$ new String[] { Long.toString(eventId) }); } } finally { if (null != blobEvents) { blobEvents.close(); blobEvents = null; } } // delete blobs for (final long x : blobsToDelete) { provider.delete(UploadBlobsDbColumns.TABLE_NAME, String.format("%s = ?", UploadBlobsDbColumns._ID), //$NON-NLS-1$ new String[] { Long.toString(x) }); } // delete sessions for (final long x : sessionsToDelete) { provider.delete(SessionsDbColumns.TABLE_NAME, String.format("%s = ?", SessionsDbColumns._ID), //$NON-NLS-1$ new String[] { Long.toString(x) }); } } /** * Gets the creation time for an API key. * * @param provider Localytics database provider. Cannot be null. * @param key Localytics API key. Cannot be null. * @return The time in seconds since the Unix Epoch when the API key entry was created in the database. * @throws RuntimeException if the API key entry doesn't exist in the database. */ /* package */static long getApiKeyCreationTime(final LocalyticsProvider provider, final String key) { Cursor cursor = null; try { cursor = provider.query(ApiKeysDbColumns.TABLE_NAME, null, String.format("%s = ?", ApiKeysDbColumns.API_KEY), new String[] { key }, null); //$NON-NLS-1$ if (cursor.moveToFirst()) { return Math.round( (float) cursor.getLong(cursor.getColumnIndexOrThrow(ApiKeysDbColumns.CREATED_TIME)) / DateUtils.SECOND_IN_MILLIS); } /* * This should never happen */ throw new RuntimeException("API key entry couldn't be found"); //$NON-NLS-1$ } finally { if (null != cursor) { cursor.close(); cursor = null; } } } /** * Helper method to generate the attributes object for a session * * @param provider Instance of the Localytics database provider. Cannot be null. * @param apiKey Localytics API key. Cannot be null. * @param sessionId The {@link SessionsDbColumns#_ID} of the session. * @return a JSONObject representation of the session attributes * @throws JSONException if a problem occurred converting the element to JSON. */ /* package */static JSONObject getAttributesFromSession(final LocalyticsProvider provider, final String apiKey, final long sessionId) throws JSONException { Cursor cursor = null; try { cursor = provider.query(SessionsDbColumns.TABLE_NAME, null, String.format("%s = ?", SessionsDbColumns._ID), new String[] { Long.toString(sessionId) }, //$NON-NLS-1$ null); if (cursor.moveToFirst()) { final JSONObject result = new JSONObject(); result.put(JsonObjects.BlobHeader.Attributes.KEY_CLIENT_APP_VERSION, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.APP_VERSION))); result.put(JsonObjects.BlobHeader.Attributes.KEY_DATA_CONNECTION, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.NETWORK_TYPE))); result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_ANDROID_ID_HASH, cursor .getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_ANDROID_ID_HASH))); result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_COUNTRY, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_COUNTRY))); result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_MANUFACTURER, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_MANUFACTURER))); result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_MODEL, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_MODEL))); result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_OS_VERSION, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.ANDROID_VERSION))); result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_PLATFORM, JsonObjects.BlobHeader.Attributes.VALUE_PLATFORM); result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_SERIAL_HASH, cursor.isNull(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_SERIAL_NUMBER_HASH)) ? JSONObject.NULL : cursor.getString(cursor .getColumnIndexOrThrow(SessionsDbColumns.DEVICE_SERIAL_NUMBER_HASH))); result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_SDK_LEVEL, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.ANDROID_SDK))); if (Constants.IS_DEVICE_IDENTIFIER_UPLOADED) { result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_TELEPHONY_ID, cursor.isNull(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_TELEPHONY_ID)) ? JSONObject.NULL : cursor.getString(cursor .getColumnIndexOrThrow(SessionsDbColumns.DEVICE_TELEPHONY_ID))); } else { result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_TELEPHONY_ID_HASH, cursor.isNull( cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_TELEPHONY_ID_HASH)) ? JSONObject.NULL : cursor.getString(cursor.getColumnIndexOrThrow( SessionsDbColumns.DEVICE_TELEPHONY_ID_HASH))); } result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_WIFI_MAC_HASH, cursor.isNull(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_WIFI_MAC_HASH)) ? JSONObject.NULL : cursor.getString( cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_WIFI_MAC_HASH))); result.put(JsonObjects.BlobHeader.Attributes.KEY_LOCALYTICS_API_KEY, apiKey); result.put(JsonObjects.BlobHeader.Attributes.KEY_LOCALYTICS_CLIENT_LIBRARY_VERSION, cursor .getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.LOCALYTICS_LIBRARY_VERSION))); result.put(JsonObjects.BlobHeader.Attributes.KEY_LOCALYTICS_DATA_TYPE, JsonObjects.BlobHeader.Attributes.VALUE_DATA_TYPE); // This would only be null after an upgrade from an earlier version of the Localytics library final String installationID = cursor .getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.LOCALYTICS_INSTALLATION_ID)); if (null != installationID) { result.put(JsonObjects.BlobHeader.Attributes.KEY_LOCALYTICS_INSTALLATION_ID, installationID); } result.put(JsonObjects.BlobHeader.Attributes.KEY_LOCALE_COUNTRY, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.LOCALE_COUNTRY))); result.put(JsonObjects.BlobHeader.Attributes.KEY_LOCALE_LANGUAGE, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.LOCALE_LANGUAGE))); result.put(JsonObjects.BlobHeader.Attributes.KEY_NETWORK_CARRIER, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.NETWORK_CARRIER))); result.put(JsonObjects.BlobHeader.Attributes.KEY_NETWORK_COUNTRY, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.NETWORK_COUNTRY))); return result; } throw new RuntimeException("No session exists"); //$NON-NLS-1$ } finally { if (null != cursor) { cursor.close(); cursor = null; } } } /** * Converts an event into a JSON object. * <p> * 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); /* * Get the custom dimensions from the attributes table */ Cursor attributesCursor = null; try { attributesCursor = provider.query(AttributesDbColumns.TABLE_NAME, null, String.format("%s = ?", AttributesDbColumns.EVENTS_KEY_REF), //$NON-NLS-1$ new String[] { Long.toString(eventId) }, null); final int keyColumn = attributesCursor .getColumnIndexOrThrow(AttributesDbColumns.ATTRIBUTE_KEY); final int valueColumn = attributesCursor .getColumnIndexOrThrow(AttributesDbColumns.ATTRIBUTE_VALUE); while (attributesCursor.moveToNext()) { final String key = attributesCursor.getString(keyColumn); final String value = attributesCursor.getString(valueColumn); if (AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_1.equals(key)) { result.put(JsonObjects.SessionOpen.KEY_CUSTOM_DIMENSION_1, value); } else if (AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_2.equals(key)) { result.put(JsonObjects.SessionOpen.KEY_CUSTOM_DIMENSION_2, value); } else if (AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_3.equals(key)) { result.put(JsonObjects.SessionOpen.KEY_CUSTOM_DIMENSION_3, value); } else if (AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_4.equals(key)) { result.put(JsonObjects.SessionOpen.KEY_CUSTOM_DIMENSION_4, value); } } } finally { if (null != attributesCursor) { attributesCursor.close(); attributesCursor = null; } } } 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), //$NON-NLS-1$ new String[] { Long.toString(cursor.getLong( cursor.getColumnIndexOrThrow(EventsDbColumns.SESSION_KEY_REF))) }, null); 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, //$NON-NLS-1$ EventHistoryDbColumns.TYPE), new String[] { Long.toString(sessionId), Integer.toString(EventHistoryDbColumns.TYPE_SCREEN) }, EventHistoryDbColumns._ID); 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; } } /* * Get the custom dimensions from the attributes table */ Cursor attributesCursor = null; try { attributesCursor = provider.query(AttributesDbColumns.TABLE_NAME, null, String.format("%s = ?", AttributesDbColumns.EVENTS_KEY_REF), //$NON-NLS-1$ new String[] { Long.toString(eventId) }, null); final int keyColumn = attributesCursor .getColumnIndexOrThrow(AttributesDbColumns.ATTRIBUTE_KEY); final int valueColumn = attributesCursor .getColumnIndexOrThrow(AttributesDbColumns.ATTRIBUTE_VALUE); while (attributesCursor.moveToNext()) { final String key = attributesCursor.getString(keyColumn); final String value = attributesCursor.getString(valueColumn); if (AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_1.equals(key)) { result.put(JsonObjects.SessionOpen.KEY_CUSTOM_DIMENSION_1, value); } else if (AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_2.equals(key)) { result.put(JsonObjects.SessionOpen.KEY_CUSTOM_DIMENSION_2, value); } else if (AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_3.equals(key)) { result.put(JsonObjects.SessionOpen.KEY_CUSTOM_DIMENSION_3, value); } else if (AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_4.equals(key)) { result.put(JsonObjects.SessionOpen.KEY_CUSTOM_DIMENSION_4, value); } } } finally { if (null != attributesCursor) { attributesCursor.close(); attributesCursor = 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, //$NON-NLS-1$ EventHistoryDbColumns.PROCESSED_IN_BLOB), new String[] { Long.toString(sessionId), Long.toString(blobId) }, EventHistoryDbColumns._ID); 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())); /* * Get the custom dimensions from the attributes table */ Cursor attributesCursor = null; try { attributesCursor = provider.query(AttributesDbColumns.TABLE_NAME, null, String.format("%s = ?", AttributesDbColumns.EVENTS_KEY_REF), //$NON-NLS-1$ new String[] { Long.toString(eventId) }, null); final int keyColumn = attributesCursor .getColumnIndexOrThrow(AttributesDbColumns.ATTRIBUTE_KEY); final int valueColumn = attributesCursor .getColumnIndexOrThrow(AttributesDbColumns.ATTRIBUTE_VALUE); while (attributesCursor.moveToNext()) { final String key = attributesCursor.getString(keyColumn); final String value = attributesCursor.getString(valueColumn); if (AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_1.equals(key)) { result.put(JsonObjects.SessionOpen.KEY_CUSTOM_DIMENSION_1, value); } else if (AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_2.equals(key)) { result.put(JsonObjects.SessionOpen.KEY_CUSTOM_DIMENSION_2, value); } else if (AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_3.equals(key)) { result.put(JsonObjects.SessionOpen.KEY_CUSTOM_DIMENSION_3, value); } else if (AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_4.equals(key)) { result.put(JsonObjects.SessionOpen.KEY_CUSTOM_DIMENSION_4, value); } } } finally { if (null != attributesCursor) { attributesCursor.close(); attributesCursor = null; } } final JSONObject attributes = convertAttributesToJson(provider, context, 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(); cursor = null; } } 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) }, //$NON-NLS-1$ null); 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(); cursor = null; } } } /** * 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) }, //$NON-NLS-1$ null); if (cursor.moveToFirst()) { return cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.UUID)); } /* * This should never happen */ throw new RuntimeException(); } finally { if (null != cursor) { cursor.close(); cursor = null; } } } /** * 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) }, //$NON-NLS-1$ null); 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(); cursor = null; } } } /** * Private helper to convert an event's attributes into a {@link JSONObject} representation. * * @param provider Localytics database instance. Cannot be null. * @param context Application context. 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 Context context, final long eventId) throws JSONException { Cursor cursor = null; try { cursor = provider.query(AttributesDbColumns.TABLE_NAME, null, String.format("%s = ? AND %s != ? AND %s != ? AND %s != ? AND %s != ?", //$NON-NLS-1$ AttributesDbColumns.EVENTS_KEY_REF, AttributesDbColumns.ATTRIBUTE_KEY, AttributesDbColumns.ATTRIBUTE_KEY, AttributesDbColumns.ATTRIBUTE_KEY, AttributesDbColumns.ATTRIBUTE_KEY), new String[] { Long.toString(eventId), AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_1, AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_2, AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_3, AttributesDbColumns.ATTRIBUTE_CUSTOM_DIMENSION_4 }, null); 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()) { final String key = cursor.getString(keyColumn); final String value = cursor.getString(valueColumn); attributes.put(key.substring(context.getPackageName().length() + 1, key.length()), value); } return attributes; } finally { if (null != cursor) { cursor.close(); cursor = null; } } } /** * 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 */static long getSessionIdForBlobId(final LocalyticsProvider provider, final long blobId) { /* * This implementation needs to walk up the tree of database elements. */ long eventId; { Cursor cursor = null; try { cursor = provider.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(); cursor = null; } } } long sessionId; { Cursor cursor = null; try { cursor = provider.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(); cursor = null; } } } 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<F, S> { public final F first; public final S second; public Pair(final F first, final S second) { this.first = first; this.second = second; } } }