diff --git a/astrid/common-src/com/localytics/android/Constants.java b/astrid/common-src/com/localytics/android/Constants.java new file mode 100644 index 000000000..0f97701cd --- /dev/null +++ b/astrid/common-src/com/localytics/android/Constants.java @@ -0,0 +1,84 @@ +package com.localytics.android; + +import android.text.format.DateUtils; + +/** + * Build constants for the Localytics library. + *
+ * This is not a public API. + */ +/* package */final class Constants +{ + + /** + * Version number of this library. This number is primarily important in terms of changes to the upload format. + */ + //@formatter:off + /* + * Version history: + * + * 1.6: Fixed network type reporting. Added reporting of app signature, device SDK level, device manufacturer, serial number. + * 2.0: New upload format. + */ + //@formatter:on + public static final String LOCALYTICS_CLIENT_LIBRARY_VERSION = "2.0"; //$NON-NLS-1$ + + /** + * The package name of the Localytics library. + */ + /* + * Note: This value cannot be changed without significant consequences to the data in the database. + */ + public static final String LOCALYTICS_PACKAGE_NAME = "com.localytics.android"; //$NON-NLS-1$ + + /** + * Maximum number of sessions to store on disk. + */ + public static final int MAX_NUM_SESSIONS = 10; + + /** + * Maximum number of attributes per event session. + */ + public static final int MAX_NUM_ATTRIBUTES = 10; + + /** + * Maximum characters in an event name or attribute key/value. + */ + public static final int MAX_NAME_LENGTH = 128; + + /** + * Milliseconds after which a session is considered closed and cannot be reattached to. + *
+ * For example, if the user opens an app, presses home, and opens the app again in less than {@link #SESSION_EXPIRATION} + * milliseconds, that will count as one session rather than two sessions. + */ + public static long SESSION_EXPIRATION = 15 * DateUtils.SECOND_IN_MILLIS; + + /** + * logcat log tag + */ + public static final String LOG_TAG = "Localytics"; //$NON-NLS-1$ + + /** + * Boolean indicating whether logcat messages are enabled. + *
+ * Before releasing a production version of an app, this should be set to false for privacy and performance reasons. When + * logging is enabled, sensitive information such as the device ID may be printed to the log. + */ + public static boolean IS_LOGGABLE = false; + + /** + * Flag indicating whether runtime method parameter checking is performed. + */ + public static boolean ENABLE_PARAMETER_CHECKING = true; + + /** + * Private constructor prevents instantiation + * + * @throws UnsupportedOperationException because this class cannot be instantiated. + */ + private Constants() + { + throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$ + } +} diff --git a/astrid/common-src/com/localytics/android/DatapointHelper.java b/astrid/common-src/com/localytics/android/DatapointHelper.java old mode 100644 new mode 100755 index 22c8c0606..d3c9ff011 --- a/astrid/common-src/com/localytics/android/DatapointHelper.java +++ b/astrid/common-src/com/localytics/android/DatapointHelper.java @@ -1,324 +1,353 @@ +//@formatter:off /** - * DatapointHelper.java - * Copyright (C) 2009 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. + * DatapointHelper.java Copyright (C) 2011 Char Software Inc., DBA Localytics This code is provided under the Localytics Modified + * BSD License. A copy of this license has been distributed in a file called LICENSE with this source code. Please visit + * www.localytics.com for more information. */ +//@formatter:on package com.localytics.android; -import java.math.BigInteger; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.TimeZone; - +import android.Manifest.permission; import android.content.Context; import android.content.pm.PackageManager; -import android.net.wifi.WifiManager; -import android.provider.Settings.System; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Build; import android.telephony.TelephonyManager; import android.util.Log; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + /** - * Provides a number of static functions to aid in the collection and formatting - * of datapoints. - * @author Localytics + * Provides a number of static functions to aid in the collection and formatting of datapoints. + *
+ * Note: this is not a public API. */ -@SuppressWarnings("nls") -public final class DatapointHelper +/* package */final class DatapointHelper { - // This class should never be instantiated - private DatapointHelper() { /**/ } - - private static final String LOG_PREFIX = "(DatapointHelper) "; - private static final String DROID2_ANDROID_ID = "9774d56d682e549c"; - - // Each YAML entry either goes to the session controller, the event controller - // or the optin controller - public static final String CONTROLLER_SESSION = "- c: se\n"; - public static final String CONTROLLER_EVENT = "- c: ev\n"; - public static final String CONTROLLER_OPT = "- c: optin\n"; - - // Each entry is either a create action, or an update action - public static final String ACTION_CREATE = " a: c\n"; - public static final String ACTION_UPDATE = " a: u\n"; - public static final String ACTION_OPTIN = " a: optin\n"; - - // The target object for the data being set up. - public static final String OBJECT_SESSION_DP = " se:\n"; - public static final String OBJECT_EVENT_DP = " ev:\n"; - public static final String OBJECT_OPT = " optin:\n"; - - // Events can have attributes - public static final String EVENT_ATTRIBUTE = " attrs:\n"; - - /******************************* - * WEBSERVICE PARAMENTER NAMES * - * ***************************** + /** + * AndroidID known to be duplicated across many devices due to manufacturer bugs. */ + private static final String INVALID_ANDROID_ID = "9774d56d682e549c"; //$NON-NLS-1$ - // Every object has a UUID - public static final String PARAM_UUID = "u"; - - // The identifier for this application, generated by the user on the webservice - public static final String PARAM_APP_UUID = "au"; - - // The version of this application, taken from the application's manifest. - public static final String PARAM_APP_VERSION = "av"; - - // A session's UUID as previously created. - public static final String PARAM_SESSION_UUID = "su"; - - // A hashed identifier unique to this device - public static final String PARAM_DEVICE_UUID = "du"; - - // android, iphone, blackberry, windowsmobile - public static final String PARAM_DEVICE_PLATFORM = "dp"; - - // maker of this device (currently not supported by Android) - public static final String PARAM_DEVICE_MAKE = "dma"; - - // model of the device - public static final String PARAM_DEVICE_MODEL = "dmo"; - - // version of the OS on this device - public static final String PARAM_OS_VERSION = "dov"; - - // country device is from (obtained by querying the SIM card) - public static final String PARAM_DEVICE_COUNTRY = "dc"; - - // country the current locale is set to - public static final String PARAM_LOCALE_COUNTRY = "dlc"; - - // country the language is set to - public static final String PARAM_LOCALE_LANGUAGE = "dll"; - - // Locale as a language_country string. (Not collected because this info - // is already provided by LOCALE_LANGUAGE and LOCALE_COUNTRY. - public static final String PARAM_LOCALE = "dl"; - - // Country the user is currently in (comes from Sim card) - public static final String PARAM_NETWORK_COUNTRY = "nc"; + /** + * The path to the device_id file in previous versions of the Localytics library + */ + private static final String LEGACY_DEVICE_ID_FILE = "/localytics/device_id"; //$NON-NLS-1$ - // Current carrier (comes from sim card) - public static final String PARAM_NETWORK_CARRIER = "nca"; + /** + * Private constructor prevents instantiation + * + * @throws UnsupportedOperationException because this class cannot be instantiated. + */ + private DatapointHelper() + { + throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$ + } - // Current mobile network code (comes from sim card) - public static final String PARAM_NETWORK_MNC = "mnc"; + /** + * Gets a 1-way hashed value of the device's Android ID. This value is encoded using a SHA-256 one way hash and therefore + * cannot be used to determine what device this data came from. + * + * @param context The context used to access the settings resolver + * @return An 1-way hashed version of the {@link android.provider.Settings.Secure#ANDROID_ID}. May return null if an Android + * ID or the hashing algorithm is not available. + */ + public static String getAndroidIdHashOrNull(final Context context) + { + // Make sure a legacy version of the SDK didn't leave behind a device ID. + // If it did, this ID must be used to keep user counts accurate + final File fp = new File(context.getFilesDir() + LEGACY_DEVICE_ID_FILE); + if (fp.exists() && fp.length() > 0) + { + try + { + BufferedReader reader = null; + try + { + final char[] buf = new char[100]; + int numRead; + reader = new BufferedReader(new FileReader(fp), 128); + numRead = reader.read(buf); + final String deviceId = String.copyValueOf(buf, 0, numRead); + reader.close(); + return deviceId; + } + catch (final FileNotFoundException e) + { + } + finally + { + if (null != reader) + { + reader.close(); + } + } + } + catch (final IOException e) + { + } + } - // current mobile country code (comes from sim card) - public static final String PARAM_NETWORK_MCC = "mcc"; + final String androidId = android.provider.Settings.Secure.getString(context.getContentResolver(), android.provider.Settings.Secure.ANDROID_ID); + if (androidId == null || androidId.toLowerCase().equals(INVALID_ANDROID_ID)) + { + return null; + } - // type of data connection (wifi, umts, gprs, evdo, ...) - public static final String PARAM_DATA_CONNECTION = "dac"; + return getSha256(androidId); + } - // the version of this Localytics client library - public static final String PARAM_LIBRARY_VERSION = "lv"; + /** + * Gets a 1-way hashed value of the device's unique serial number. This value is encoded using a SHA-256 one way hash and + * therefore cannot be used to determine what device this data came from. + *
+ * Note: {@link android.os.Build#SERIAL} was introduced in SDK 9. For older SDKs, this method will return null. + * + * @return An 1-way hashed version of the {@link android.os.Build#SERIAL}. May return null if a serial or the hashing + * algorithm is not available. + */ + /* + * Suppress JavaDoc warnings because the {@link android.os.Build#SERIAL} fails when built with SDK 4. + */ + @SuppressWarnings("javadoc") + public static String getSerialNumberHashOrNull() + { + /* + * Obtain the device serial number using reflection, since serial number was added in SDK 9 + */ + String serialNumber = null; + if (Build.VERSION.SDK_INT >= 9) + { + try + { + serialNumber = (String) Build.class.getField("SERIAL").get(null); //$NON-NLS-1$ + } + catch (final Exception e) + { + /* + * This should never happen, as SERIAL is a public field added in SDK 9. + */ + throw new RuntimeException(e); + } + } - // The source where the location came from - public static final String PARAM_LOCATION_SOURCE = "ls"; + if (serialNumber == null) + { + return null; + } - // the latitude returned by the location provider - public static final String PARAM_LOCATION_LAT = "lat"; + return getSha256(serialNumber); + } - // the longitude from the location provider - public static final String PARAM_LOCATION_LNG = "lng"; + /** + * Gets the device's telephony ID (e.g. IMEI/MEID). + *
+ * Note: this method will return null if {@link permission#READ_PHONE_STATE} is not available. This method will also return + * null on devices that do not have telephony. + * + * @param context The context used to access the phone state. + * @return An the {@link TelephonyManager#getDeviceId()}. Null if an ID is not available, or if + * {@link permission#READ_PHONE_STATE} is not available. + */ + public static String getTelephonyDeviceIdOrNull(final Context context) + { + if (Build.VERSION.SDK_INT >= 8) + { + final boolean hasTelephony = ReflectionUtils.tryInvokeInstance(context.getPackageManager(), "hasSystemFeature", new Class>[] { String.class }, new Object[] { "android.hardware.telephony" }); //$NON-NLS-1$//$NON-NLS-2$ - // the current time on the user's device - public static final String PARAM_CLIENT_TIME = "ct"; + if (!hasTelephony) + { + if (Constants.IS_LOGGABLE) + { + Log.i(Constants.LOG_TAG, "Device does not have telephony; cannot read telephony id"); //$NON-NLS-1$ + } - // sent at closing time, the current time on the users's device - public static final String PARAM_CLIENT_CLOSED_TIME = "ctc"; + return null; + } + } - // The name an event that occured - public static final String PARAM_EVENT_NAME = "n"; + /* + * Note: Sometimes Android will deny a package READ_PHONE_STATE permissions, even if the package has the permission. It + * appears to be a race condition that occurs during installation. + */ + String id = null; + if (context.getPackageManager().checkPermission(permission.READ_PHONE_STATE, context.getPackageName()) == PackageManager.PERMISSION_GRANTED) + { + final TelephonyManager manager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + id = manager.getDeviceId(); + } + else + { + if (Constants.IS_LOGGABLE) + { + Log.w(Constants.LOG_TAG, "Application does not have permission READ_PHONE_STATE; determining device id is not possible. Please consider requesting READ_PHONE_STATE in the AndroidManifest"); //$NON-NLS-1$ + } + } - // the optin value sent in if a user opts in or out. - public static final String PARAM_OPT_VALUE = "optin"; + return id; + } /** - * Returns the given key/value pair as a YAML string. This string is intended to be - * used to define values for the first level of data in the YAML file. This is - * different from the datapoints which belong another level in. - * @param paramName The name of the parameter - * @param paramValue The value of the parameter - * @param paramIndent The indent level of the parameter - * @return a YAML string which can be dumped to the YAML file + * Gets a 1-way hashed value of the device's IMEI/MEID ID. This value is encoded using a SHA-256 one way hash and cannot be + * used to determine what device this data came from. + *
+ * Note: this method will return null if this is a non-telephony device. + *
+ * Note: this method will return null if {@link permission#READ_PHONE_STATE} is not available. + * + * @param context The context used to access the phone state. + * @return An 1-way hashed version of the {@link TelephonyManager#getDeviceId()}. Null if an ID or the hashing algorithm is + * not available, or if {@link permission#READ_PHONE_STATE} is not available. */ - public static String formatYAMLLine(String paramName,String paramValue, int paramIndent) + public static String getTelephonyDeviceIdHashOrNull(final Context context) { - if (paramName.length() > LocalyticsSession.MAX_NAME_LENGTH) + if (Build.VERSION.SDK_INT >= 8) { - Log.v(DatapointHelper.LOG_PREFIX, "Parameter name exceeds " - + LocalyticsSession.MAX_NAME_LENGTH + " character limit. Truncating."); - paramName = paramName.substring(0, LocalyticsSession.MAX_NAME_LENGTH); + final boolean hasTelephony = ReflectionUtils.tryInvokeInstance(context.getPackageManager(), "hasSystemFeature", new Class>[] { String.class }, new Object[] { "android.hardware.telephony" }); //$NON-NLS-1$//$NON-NLS-2$ + + if (!hasTelephony) + { + if (Constants.IS_LOGGABLE) + { + Log.i(Constants.LOG_TAG, "Device does not have telephony; cannot read telephony id"); //$NON-NLS-1$ + } + + return null; + } } - if (paramValue.length() > LocalyticsSession.MAX_NAME_LENGTH) + + /* + * Note: Sometimes Android will deny a package READ_PHONE_STATE permissions, even if the package has the permission. It + * appears to be a race condition that primarily occurs during installation. + */ + String id = null; + if (context.getPackageManager().checkPermission(permission.READ_PHONE_STATE, context.getPackageName()) == PackageManager.PERMISSION_GRANTED) { - Log.v(DatapointHelper.LOG_PREFIX, "Parameter value exceeds " - + LocalyticsSession.MAX_NAME_LENGTH + " character limit. Truncating."); - paramValue = paramValue.substring(0, LocalyticsSession.MAX_NAME_LENGTH); + final TelephonyManager manager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + id = manager.getDeviceId(); } - // The params are stored in the second tier of the YAML data. - // so with spacing, the expected result is: " paramname:paramvalue\n" - StringBuffer formattedString = new StringBuffer(); - for (int currentIndent = 0; currentIndent < paramIndent; currentIndent++) + else { - formattedString.append(" "); + if (Constants.IS_LOGGABLE) + { + Log.w(Constants.LOG_TAG, "Application does not have permission READ_PHONE_STATE; determining device id is not possible. Please consider requesting READ_PHONE_STATE in the AndroidManifest"); //$NON-NLS-1$ + } } - formattedString.append(escapeString(paramName)); - formattedString.append(": "); - - // Escape the string. - formattedString.append(escapeString(paramValue)); - - formattedString.append("\n"); - - return formattedString.toString(); - } + if (id == null) + { + return null; + } - /** - * Gets a 1-way hashed value of the device's unique ID. This value is encoded using a SHA-256 - * one way hash and cannot be used to determine what device this data came from. - * @param appContext The context used to access the settings resolver - * @return An 1-way hashed identifier unique to this device or null if an ID, or the hashing - * algorithm is not available. - */ - public static String getGlobalDeviceId(final Context appContext) - { - String systemId = System.getString(appContext.getContentResolver(), System.ANDROID_ID); - if(systemId == null || systemId.toLowerCase().equals(DROID2_ANDROID_ID)) - { - return null; - } - - try - { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] digest = md.digest(systemId.getBytes()); - BigInteger hashedNumber = new BigInteger(1, digest); - return new String(hashedNumber.toString(16)); - - } - catch(NoSuchAlgorithmException e) - { - return null; - } + return getSha256(id); } /** * Determines the type of network this device is connected to. - * @param appContext the context used to access the device's WIFI + * + * @param context the context used to access the device's WIFI * @param telephonyManager The manager used to access telephony info * @return The type of network, or unknown if the information is unavailable */ - public static String getNetworkType( - final Context appContext, - TelephonyManager telephonyManager) + public static String getNetworkType(final Context context, final TelephonyManager telephonyManager) { - WifiManager wifiManager = (WifiManager)appContext.getSystemService(Context.WIFI_SERVICE); - - // this will only work for apps which already have wifi permissions. - try - { - if(wifiManager.isWifiEnabled()) - { - return "wifi"; - } - } - catch (Exception e) { /**/ } - - switch (telephonyManager.getNetworkType()) + if (context.getPackageManager().checkPermission(permission.ACCESS_WIFI_STATE, context.getPackageName()) == PackageManager.PERMISSION_GRANTED) { - case TelephonyManager.NETWORK_TYPE_EDGE : return "edge"; - case TelephonyManager.NETWORK_TYPE_GPRS : return "GPRS"; - case TelephonyManager.NETWORK_TYPE_UMTS : return "UMTS"; - case TelephonyManager.NETWORK_TYPE_UNKNOWN : return "unknown"; + final NetworkInfo wifiInfo = ((ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)).getNetworkInfo(ConnectivityManager.TYPE_WIFI); + if (wifiInfo != null && wifiInfo.isConnectedOrConnecting()) + { + return "wifi"; //$NON-NLS-1$ + } + } + else + { + if (Constants.IS_LOGGABLE) + { + Log.w(Constants.LOG_TAG, "Application does not have permission ACCESS_WIFI_STATE; determining Wi-Fi connectivity is unavailable"); //$NON-NLS-1$ + } } - return "none"; - } - - /** - * Gets the pretty string for this application's version. - * @param appContext The context used to examine packages - * @return The application's version as a pretty string - */ - public static String getAppVersion(final Context appContext) - { - PackageManager pm = appContext.getPackageManager(); - - try - { - return pm.getPackageInfo(appContext.getPackageName(), 0).versionName; - } - catch (PackageManager.NameNotFoundException e) - { - return "unknown"; - } + return "android_network_type_" + telephonyManager.getNetworkType(); //$NON-NLS-1$ } /** - * Gets the current time, along with local timezone, formatted as a DateTime for the webservice. - * @return a DateTime of the current local time and timezone. + * Gets the versionName of the application. + * + * @param context {@link Context}. Cannot be null. + * @return The application's version */ - public static String getTimeAsDatetime() + public static String getAppVersion(final Context context) { - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss-00:00"); - sdf.setTimeZone(TimeZone.getTimeZone("UTC")); - return sdf.format(new Date()); - } + final PackageManager pm = context.getPackageManager(); - /*************************** - * Private Helper Functions * - ***************************/ + try + { + final String versionName = pm.getPackageInfo(context.getPackageName(), 0).versionName; - /** - * Escapes strings for YAML parser - * @param rawString The string we want to escape. - * @return An escaped string ready for YAML - */ - private static String escapeString(String rawString) - { - StringBuffer parseString = new StringBuffer("\""); + /* + * If there is no versionName in the Android Manifest, the versionName will be null. + */ + if (versionName == null) + { + if (Constants.IS_LOGGABLE) + { + Log.w(Constants.LOG_TAG, "versionName was null--is a versionName attribute set in the Android Manifest?"); //$NON-NLS-1$ + } - int startRead = 0; // Index to start reading at - int stopRead = 0; // Index characters get read from and where the substring ends - int bufferLength = rawString == null ? 0 : rawString.length(); + return "unknown"; //$NON-NLS-1$ + } - if (rawString == null) + return versionName; + } + catch (final PackageManager.NameNotFoundException e) { - return ""; + /* + * This should never occur--our own package must exist for this code to be running + */ + throw new RuntimeException(e); } + } - // Every time we come across a " or \, append what we have so far, append a \, - // then manage our indexes to continue where we left off. - while (stopRead < bufferLength) + /** + * Helper method to generate a SHA-256 hash of a given String + * + * @param string String to hash. Cannot be null. + * @return hashed version of the string using SHA-256. + */ + /* package */static String getSha256(final String string) + { + if (Constants.ENABLE_PARAMETER_CHECKING) { - if (rawString.charAt(stopRead) == '\"' || rawString.charAt(stopRead) == '\\') - { - parseString.append(rawString.substring(startRead, stopRead)); - parseString.append('\\'); - startRead = stopRead; - } - // Skip null characters. - else if (rawString.charAt(stopRead) == '\0') + if (null == string) { - parseString.append(rawString.substring(startRead, stopRead)); - startRead = stopRead + 1; + throw new IllegalArgumentException("string cannot be null"); //$NON-NLS-1$ } - stopRead++; } - // Append whatever is left after parsing - parseString.append(rawString.substring(startRead, stopRead)); - // and finish with a closing " - parseString.append('\"'); - return parseString.toString(); + + try + { + final MessageDigest md = MessageDigest.getInstance("SHA-256"); //$NON-NLS-1$ + final byte[] digest = md.digest(string.getBytes("UTF-8")); //$NON-NLS-1$ + final BigInteger hashedNumber = new BigInteger(1, digest); + return hashedNumber.toString(16); + } + catch (final NoSuchAlgorithmException e) + { + throw new RuntimeException(e); + } + catch (final UnsupportedEncodingException e) + { + throw new RuntimeException(e); + } } -} +} \ No newline at end of file diff --git a/astrid/common-src/com/localytics/android/ExceptionHandler.java b/astrid/common-src/com/localytics/android/ExceptionHandler.java new file mode 100644 index 000000000..1a55ecd4e --- /dev/null +++ b/astrid/common-src/com/localytics/android/ExceptionHandler.java @@ -0,0 +1,36 @@ +package com.localytics.android; + +import android.util.Log; + +/** + * Exception handler for background threads used by the Localytics library. + *
+ * Analytics are secondary to any other functions performed by an app, which means that analytics should never cause an app to + * crash. This handler therefore suppresses all uncaught exceptions from the Localytics library. + */ +/* package */final class ExceptionHandler implements Thread.UncaughtExceptionHandler +{ + public void uncaughtException(final Thread thread, final Throwable throwable) + { + /* + * Wrap all the work done by the exception handler in a try-catch. It would be ironic if this exception handler itself + * caused the parent process to crash. + */ + try + { + if (Constants.IS_LOGGABLE) + { + Log.e(Constants.LOG_TAG, "Localytics library threw an uncaught exception", throwable); //$NON-NLS-1$ + } + + // TODO: Upload uncaught exceptions so that we can fix them + } + catch (final Exception e) + { + if (Constants.IS_LOGGABLE) + { + Log.e(Constants.LOG_TAG, "Exception handler threw an exception", e); //$NON-NLS-1$ + } + } + } +} \ No newline at end of file diff --git a/astrid/common-src/com/localytics/android/JsonObjects.java b/astrid/common-src/com/localytics/android/JsonObjects.java new file mode 100644 index 000000000..e7d7bf6ce --- /dev/null +++ b/astrid/common-src/com/localytics/android/JsonObjects.java @@ -0,0 +1,569 @@ +package com.localytics.android; + +import android.Manifest.permission; + +import org.json.JSONArray; + +/** + * Set of constants for building JSON objects that get sent to the Localytics web service. + */ +/* package */final class JsonObjects +{ + /** + * Private constructor prevents instantiation + * + * @throws UnsupportedOperationException because this class cannot be instantiated. + */ + private JsonObjects() + { + throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$ + } + + /** + * Set of constants for the blob header JSON object. + */ + public static final class BlobHeader + { + /** + * Private constructor prevents instantiation + * + * @throws UnsupportedOperationException because this class cannot be instantiated. + */ + private BlobHeader() + { + throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$ + } + + /** + * Data type for the JSON object. + * + * @see #VALUE_DATA_TYPE + */ + public static final String KEY_DATA_TYPE = "dt"; //$NON-NLS-1$ + + /** + * @see #KEY_DATA_TYPE + */ + public static final String VALUE_DATA_TYPE = "h"; //$NON-NLS-1$ + + /** + * Timestamp when the app was first launched and the persistent storage was created. Represented as seconds since the Unix + * Epoch. (Note: This is SECONDS and not milliseconds. This requires care, because Android represents time as + * milliseconds). + */ + public static final String KEY_PERSISTENT_STORAGE_CREATION_TIME_SECONDS = "pa"; //$NON-NLS-1$ + + /** + * Sequence number. A monotonically increasing count for each new blob. + */ + public static final String KEY_SEQUENCE_NUMBER = "seq"; //$NON-NLS-1$ + + /** + * A UUID for the blob. + */ + public static final String KEY_UNIQUE_ID = "u"; //$NON-NLS-1$ + + /** + * A JSON Object for attributes for the session. + */ + public static final String KEY_ATTRIBUTES = "attrs"; //$NON-NLS-1$ + + /** + * Attributes under {@link BlobHeader#KEY_ATTRIBUTES} + */ + public static final class Attributes + { + /** + * Private constructor prevents instantiation + * + * @throws UnsupportedOperationException because this class cannot be instantiated. + */ + private Attributes() + { + throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$ + } + + /** + * Type: {@code String} + *
+ * Data connection type. + */ + public static final String KEY_DATA_CONNECTION = "dac"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * Version name of the application, taken from the Android Manifest. + */ + public static final String KEY_CLIENT_APP_VERSION = "av"; //$NON-NLS-1$ + + /** + * Key which maps to the SHA-256 of the device's {@link android.provider.Settings.Secure#ANDROID_ID}. + */ + public static final String KEY_DEVICE_ANDROID_ID_HASH = "du"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ */ + public static final String KEY_DEVICE_COUNTRY = "dc"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * Manufacturer of the device (e.g. HTC, Samsung, Motorola, Kyocera, etc.) + */ + public static final String KEY_DEVICE_MANUFACTURER = "dma"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * Model of the device (e.g. dream, + */ + public static final String KEY_DEVICE_MODEL = "dmo"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * Android version (e.g. 1.6 or 2.3.4). + */ + public static final String KEY_DEVICE_OS_VERSION = "dov"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * Telephony ID of the device, if the device has telephony and the app has {@link permission#READ_PHONE_STATE}. + * Otherwise null. + */ + public static final String KEY_DEVICE_TELEPHONY_ID = "tdid"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * Platform of the device. For Android devices, this is always "android" + */ + public static final String KEY_DEVICE_PLATFORM = "dp"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * SHA-256 hash of the device's serial number. Only reported for Android 2.3 or later. Otherwise null. + */ + public static final String KEY_DEVICE_SERIAL_HASH = "dms"; //$NON-NLS-1$ + + /** + * Type: {@code int} + *
+ * SDK compatibility level of the device. + * + * @see android.os.Build.VERSION#SDK_INT + */ + public static final String KEY_DEVICE_SDK_LEVEL = "dsdk"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * SHA-256 hash of the device's Telephony ID, if the device has telephony and the app has + * {@link permission#READ_PHONE_STATE}. Otherwise null. + */ + public static final String KEY_DEVICE_TELEPHONY_ID_HASH = "dtidh"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * Country for the device's current locale settings + */ + public static final String KEY_LOCALE_COUNTRY = "dlc"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * Language for the device's current locale settings + */ + public static final String KEY_LOCALE_LANGUAGE = "dll"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * Api key + */ + public static final String KEY_LOCALYTICS_API_KEY = "au"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * Localytics library version + * + * @see Constants#LOCALYTICS_CLIENT_LIBRARY_VERSION + */ + public static final String KEY_LOCALYTICS_CLIENT_LIBRARY_VERSION = "lv"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * Data type for the JSON object. + * + * @see #VALUE_DATA_TYPE + */ + public static final String KEY_LOCALYTICS_DATA_TYPE = "dt"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * Network carrier of the device + */ + public static final String KEY_NETWORK_CARRIER = "nca"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ */ + public static final String KEY_NETWORK_COUNTRY = "nc"; //$NON-NLS-1$ + + /** + * @see #KEY_LOCALYTICS_DATA_TYPE + */ + @SuppressWarnings("hiding") + public static final String VALUE_DATA_TYPE = "a"; //$NON-NLS-1$ + + /** + * Value for the platform. + * + * @see #KEY_DEVICE_PLATFORM + */ + public static final String VALUE_PLATFORM = "Android"; //$NON-NLS-1$ + } + } + + /** + * Set of constants for the session open event. + */ + /* package */static final class SessionOpen + { + /** + * Private constructor prevents instantiation + * + * @throws UnsupportedOperationException because this class cannot be instantiated. + */ + private SessionOpen() + { + throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$ + } + + /** + * Type: {@code String} + *
+ * Data type for the JSON object. + * + * @see #VALUE_DATA_TYPE + */ + public static final String KEY_DATA_TYPE = "dt"; //$NON-NLS-1$ + + /** + * @see #KEY_DATA_TYPE + */ + public static final String VALUE_DATA_TYPE = "s"; //$NON-NLS-1$ + + /** + * Type: {@code long} + *
+ * Epoch timestamp when the session was started in seconds. + */ + public static final String KEY_WALL_TIME_SECONDS = "ct"; //$NON-NLS-1$ + + /** + * Type: {@code long} + *
+ * UUID of the event, which is the same thing as the session UUID + */ + public static final String KEY_EVENT_UUID = "u"; //$NON-NLS-1$ + + /** + * Type: {@code long} + *
+ * Count for the number of sessions + */ + public static final String KEY_COUNT = "nth"; //$NON-NLS-1$ + } + + /** + * Set of constants for the session close event. + */ + /* package */static final class SessionClose + { + /** + * Private constructor prevents instantiation + * + * @throws UnsupportedOperationException because this class cannot be instantiated. + */ + private SessionClose() + { + throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$ + } + + /** + * Type: {@code String} + *
+ * Data type for the JSON object. + * + * @see #VALUE_DATA_TYPE + */ + public static final String KEY_DATA_TYPE = "dt"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * UUID of the event. + */ + public static final String KEY_EVENT_UUID = "u"; //$NON-NLS-1$ + + /** + * Type: {@code String[]} (technically, a JSON array of strings) + *
+ * Ordered list of flow events that occurred + */ + public static final String KEY_FLOW_ARRAY = "fl"; //$NON-NLS-1$ + + /** + * Type: {@code long} + *
+ * Epoch timestamp when the session was started + */ + public static final String KEY_SESSION_LENGTH_SECONDS = "ctl"; //$NON-NLS-1$ + + /** + * Type: {@code long} + *
+ * Start time of the parent session + */ + public static final String KEY_SESSION_START_TIME = "ss"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * UUID of the session. + */ + public static final String KEY_SESSION_UUID = "su"; //$NON-NLS-1$ + + /** + * Type: {@code long} + *
+ * Epoch timestamp when the session was started in seconds. + */ + public static final String KEY_WALL_TIME_SECONDS = "ct"; //$NON-NLS-1$ + + /** + * Data type for close events. + * + * @see #KEY_DATA_TYPE + */ + public static final String VALUE_DATA_TYPE = "c"; //$NON-NLS-1$ + } + + /** + * Set of constants for the session event event. + */ + /* package */static final class SessionEvent + { + /** + * Private constructor prevents instantiation + * + * @throws UnsupportedOperationException because this class cannot be instantiated. + */ + private SessionEvent() + { + throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$ + } + + /** + * Type: {@code String} + *
+ * Data type for the JSON object. + * + * @see #VALUE_DATA_TYPE + */ + public static final String KEY_DATA_TYPE = "dt"; //$NON-NLS-1$ + + /** + * Data type for application events. + * + * @see #KEY_DATA_TYPE + */ + public static final String VALUE_DATA_TYPE = "e"; //$NON-NLS-1$ + + /** + * Type: {@code long} + *
+ * Epoch timestamp when the session was started in seconds. + */ + public static final String KEY_WALL_TIME_SECONDS = "ct"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * UUID of the session. + */ + public static final String KEY_SESSION_UUID = "su"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * UUID of the event. + */ + public static final String KEY_EVENT_UUID = "u"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * Name of the event. + */ + public static final String KEY_NAME = "n"; //$NON-NLS-1$ + + /** + * Type: {@code JSONObject}. + *
+ * Maps to the attributes of the event. + *
+ * Note that this key is optional. If it is present, it will point to a non-null value representing the attributes of the + * event. Otherwise the key will not exist, indicating the event had no attributes. + */ + public static final String KEY_ATTRIBUTES = "attrs"; //$NON-NLS-1$ + } + + /** + * Set of constants for the session opt in/out event + */ + /* package */static final class OptEvent + { + /** + * Private constructor prevents instantiation + * + * @throws UnsupportedOperationException because this class cannot be instantiated. + */ + private OptEvent() + { + throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$ + } + + /** + * Type: {@code String} + *
+ * Data type for the JSON object. + * + * @see #VALUE_DATA_TYPE + */ + public static final String KEY_DATA_TYPE = "dt"; //$NON-NLS-1$ + + /** + * Data type for opt in/out events. + * + * @see #KEY_DATA_TYPE + */ + public static final String VALUE_DATA_TYPE = "o"; //$NON-NLS-1$ + + /** + * Type: {@code long} + *
+ * Epoch timestamp when the session was started in seconds. + */ + public static final String KEY_WALL_TIME_SECONDS = "ct"; //$NON-NLS-1$ + + /** + * Type: {@code String} + *
+ * API key + */ + public static final String KEY_API_KEY = "u"; //$NON-NLS-1$ + + /** + * Type: {@code boolean} + *
+ * True to opt-out. False to opt-in + */ + public static final String KEY_OPT = "out"; //$NON-NLS-1$ + } + + /** + * Set of constants for the session flow event. + */ + /* package */static final class EventFlow + { + /** + * Private constructor prevents instantiation + * + * @throws UnsupportedOperationException because this class cannot be instantiated. + */ + private EventFlow() + { + throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$ + } + + /** + * Type: {@code String} + *
+ * Data type for the JSON object. + * + * @see #VALUE_DATA_TYPE + */ + public static final String KEY_DATA_TYPE = "dt"; //$NON-NLS-1$ + + /** + * Type: {@code long} + *
+ * UUID of the event, which is the same thing as the session UUID + */ + public static final String KEY_EVENT_UUID = "u"; //$NON-NLS-1$ + + /** + * Type: {@code long} + *
+ * Start time of the parents session. + */ + public static final String KEY_SESSION_START_TIME = "ss"; //$NON-NLS-1$ + + /** + * Type: {@code Element[]} (technically a {@link JSONArray} of {@link Element} objects) + *
+ * Ordered set of new flow elements that occurred since the last upload for this session. + */ + public static final String KEY_FLOW_NEW = "nw"; //$NON-NLS-1$ + + /** + * Type: {@code Element[]} (technically a {@link JSONArray} of {@link Element} objects) + *
+ * Ordered set of old flow elements that occurred during all previous uploads for this session. + */ + public static final String KEY_FLOW_OLD = "od"; //$NON-NLS-1$ + + /** + * @see #KEY_DATA_TYPE + */ + public static final String VALUE_DATA_TYPE = "f"; //$NON-NLS-1$ + + /** + * Flow event element that indicates the type and name of the flow event. + */ + /* package */static final class Element + { + /** + * Private constructor prevents instantiation + * + * @throws UnsupportedOperationException because this class cannot be instantiated. + */ + private Element() + { + throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$ + } + + /** + * A flow event that was due to an {@link SessionEvent}. + */ + public static final String TYPE_EVENT = "e"; //$NON-NLS-1$ + + /** + * A flow event that was due to a screen event. + */ + public static final String TYPE_SCREEN = "s"; //$NON-NLS-1$ + } + } +} diff --git a/astrid/common-src/com/localytics/android/LocalyticsProvider.java b/astrid/common-src/com/localytics/android/LocalyticsProvider.java new file mode 100644 index 000000000..0d6f12a8d --- /dev/null +++ b/astrid/common-src/com/localytics/android/LocalyticsProvider.java @@ -0,0 +1,1102 @@ +package com.localytics.android; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteQueryBuilder; +import android.provider.BaseColumns; +import android.util.Log; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Implements the storage mechanism for the Localytics library. The interface and implementation are similar to a ContentProvider + * but modified to be better suited to a library. The interface is table-oriented, rather than Uri-oriented. + *
+ * This is not a public API. + */ +/* package */final class LocalyticsProvider +{ + /** + * Name of the Localytics database, stored in the host application's {@link Context#getDatabasePath(String)}. + *
+ * This is not a public API. + */ + /* + * This field is made package-accessible for unit testing. While the exact file name is arbitrary, this name was chosen to + * avoid collisions with app developers because it is sufficiently long and uses the Localytics package namespace. + */ + /* package */static final String DATABASE_FILE = "com.localytics.android.%s.sqlite"; //$NON-NLS-1$ + + /** + * Version of the database. + *
+ * Version history: + *
+ * Note: if {@code context} is an instance of {@link android.test.RenamingDelegatingContext}, then a new object will be + * returned every time. This is not a "public" API, but is documented here as it aids unit testing. + * + * @param context Application context. Cannot be null. + * @param apiKey TODO + * @return An instance of {@link LocalyticsProvider}. + * @throws IllegalArgumentException if {@code context} is null + */ + public static LocalyticsProvider getInstance(final Context context, String apiKey) + { + /* + * Note: Don't call getApplicationContext() on the context, as that would return a different context and defeat useful + * contexts such as RenamingDelegatingContext. + */ + + if (Constants.ENABLE_PARAMETER_CHECKING) + { + if (null == context) + { + throw new IllegalArgumentException("context cannot be null"); //$NON-NLS-1$ + } + } + + /* + * 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. + */ + if (context.getClass().getName().equals("android.test.RenamingDelegatingContext")) //$NON-NLS-1$ + { + return new LocalyticsProvider(context, apiKey); + } + + synchronized (sLocalyticsProviderIntrinsicLock) + { + LocalyticsProvider provider = sLocalyticsProviderMap.get(apiKey); + + if (null == provider) + { + provider = new LocalyticsProvider(context, apiKey); + sLocalyticsProviderMap.put(apiKey, provider); + } + + return provider; + } + } + + /** + * Constructs a new Localytics Provider. + *
+ * Note: this method may perform disk operations. + * + * @param context application context. Cannot be null. + */ + private LocalyticsProvider(final Context context, final String apiKey) + { + /* + * Rather than use the API key directly in the file name, it is put through SHA-256. The main reason for doing that is to + * decouple the requirements of the Android file system from the possible values of the API key string. There is a very, + * very small risk of a collision with the SHA-256 algorithm, but most clients will only have a single API key. Those with + * multiple keys may have 2 or 3, so the risk of a collision there is also very low. + */ + + mDb = new DatabaseHelper(context, String.format(DATABASE_FILE, DatapointHelper.getSha256(apiKey)), DATABASE_VERSION).getWritableDatabase(); + } + + /** + * Inserts a new record. + *
+ * Note: this method may perform disk operations. + * + * @param tableName name of the table operate on. Must be one of the recognized tables. Cannot be null. + * @param values ContentValues to insert. Cannot be null. + * @return the {@link BaseColumns#_ID} of the inserted row or -1 if an error occurred. + * @throws IllegalArgumentException if tableName is null or not a valid table name. + * @throws IllegalArgumentException if values are null. + */ + public long insert(final String tableName, final ContentValues values) + { + if (Constants.ENABLE_PARAMETER_CHECKING) + { + if (!isValidTable(tableName)) + { + throw new IllegalArgumentException(String.format("tableName %s is invalid", tableName)); //$NON-NLS-1$ + } + + if (null == values) + { + throw new IllegalArgumentException("values cannot be null"); //$NON-NLS-1$ + } + } + + if (Constants.IS_LOGGABLE) + { + Log.v(Constants.LOG_TAG, String.format("Insert table: %s, values: %s", tableName, values.toString())); //$NON-NLS-1$ + } + + final long result = mDb.insertOrThrow(tableName, null, values); + + if (Constants.IS_LOGGABLE) + { + Log.v(Constants.LOG_TAG, String.format("Inserted row with new id %d", Long.valueOf(result))); //$NON-NLS-1$ + } + + return result; + } + + /** + * Performs a query. + *
+ * Note: this method may perform disk operations. + * + * @param tableName name of the table operate on. Must be one of the recognized tables. Cannot be null. + * @param projection The list of columns to include. If null, then all columns are included by default. + * @param selection A filter to apply to all rows, like the SQLite WHERE clause. Passing null will query all rows. This param + * may contain ? symbols, which will be replaced by values from the {@code selectionArgs} param. + * @param selectionArgs An optional string array of replacements for ? symbols in {@code selection}. May be null. + * @param sortOrder How the rows in the cursor should be sorted. If null, then the sort order is undefined. + * @return Cursor for the query. To the receiver: Don't forget to call .close() on the cursor when finished with it. + * @throws IllegalArgumentException if tableName is null or not a valid table name. + */ + public Cursor query(final String tableName, final String[] projection, final String selection, final String[] selectionArgs, final String sortOrder) + { + if (Constants.ENABLE_PARAMETER_CHECKING) + { + if (!isValidTable(tableName)) + { + throw new IllegalArgumentException(String.format("tableName %s is invalid", tableName)); //$NON-NLS-1$ + } + } + + if (Constants.IS_LOGGABLE) + { + Log.v(Constants.LOG_TAG, String.format("Query table: %s, projection: %s, selection: %s, selectionArgs: %s", tableName, Arrays.toString(projection), selection, Arrays.toString(selectionArgs))); //$NON-NLS-1$ + } + + final SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); + qb.setTables(tableName); + + final Cursor result = qb.query(mDb, projection, selection, selectionArgs, null, null, sortOrder); + + if (Constants.IS_LOGGABLE) + { + Log.v(Constants.LOG_TAG, "Query result is: " + DatabaseUtils.dumpCursorToString(result)); //$NON-NLS-1$ + } + + return result; + } + + /** + * Updates row(s). + *
+ * Note: this method may perform disk operations. + * + * @param tableName name of the table operate on. Must be one of the recognized tables. Cannot be null. + * @param values A ContentValues mapping from column names (see the associated BaseColumns class for the table) to new column + * values. + * @param selection A filter to limit which rows are updated, like the SQLite WHERE clause. Passing null implies all rows. + * This param may contain ? symbols, which will be replaced by values from the {@code selectionArgs} param. + * @param selectionArgs An optional string array of replacements for ? symbols in {@code selection}. May be null. + * @return int representing the number of rows modified, which is in the range from 0 to the number of items in the table. + * @throws IllegalArgumentException if tableName is null or not a valid table name. + */ + public int update(final String tableName, final ContentValues values, final String selection, final String[] selectionArgs) + { + if (Constants.ENABLE_PARAMETER_CHECKING) + { + if (!isValidTable(tableName)) + { + throw new IllegalArgumentException(String.format("tableName %s is invalid", tableName)); //$NON-NLS-1$ + } + } + + if (Constants.IS_LOGGABLE) + { + Log.v(Constants.LOG_TAG, String.format("Update table: %s, values: %s, selection: %s, selectionArgs: %s", tableName, values.toString(), selection, Arrays.toString(selectionArgs))); //$NON-NLS-1$ + } + + return mDb.update(tableName, values, selection, selectionArgs); + } + + /** + * Deletes row(s). + *
+ * Note: this method may perform disk operations.
+ *
+ * @param tableName name of the table operate on. Must be one of the recognized tables. Cannot be null.
+ * @param selection A filter to limit which rows are deleted, like the SQLite WHERE clause. Passing null implies all rows.
+ * This param may contain ? symbols, which will be replaced by values from the {@code selectionArgs} param.
+ * @param selectionArgs An optional string array of replacements for ? symbols in {@code selection}. May be null.
+ * @return The number of rows affected, which is in the range from 0 to the number of items in the table.
+ * @throws IllegalArgumentException if tableName is null or not a valid table name.
+ */
+ public int delete(final String tableName, final String selection, final String[] selectionArgs)
+ {
+ if (Constants.ENABLE_PARAMETER_CHECKING)
+ {
+ if (!isValidTable(tableName))
+ {
+ throw new IllegalArgumentException(String.format("tableName %s is invalid", tableName)); //$NON-NLS-1$
+ }
+ }
+
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.v(Constants.LOG_TAG, String.format("Delete table: %s, selection: %s, selectionArgs: %s", tableName, selection, Arrays.toString(selectionArgs))); //$NON-NLS-1$
+ }
+
+ final int count;
+ if (null == selection)
+ {
+ count = mDb.delete(tableName, "1", null); //$NON-NLS-1$
+ }
+ else
+ {
+ count = mDb.delete(tableName, selection, selectionArgs);
+ }
+
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.v(Constants.LOG_TAG, String.format("Deleted %d rows", Integer.valueOf(count))); //$NON-NLS-1$
+ }
+
+ return count;
+ }
+
+ /**
+ * Executes an arbitrary runnable with exclusive access to the database, essentially allowing an atomic transaction.
+ *
+ * @param runnable Runnable to execute. Cannot be null.
+ * @throws IllegalArgumentException if {@code runnable} is null
+ */
+ /*
+ * This implementation is sort of a hack. In the future, it would be better model this after applyBatch() with a list of
+ * ContentProviderOperation objects. But that API isn't available until Android 2.0.
+ *
+ * An alternative implementation would have been to expose the begin/end transaction methods on the Provider object. While
+ * that would work, it makes it harder to transition to a ContentProviderOperation model in the future.
+ */
+ public void runBatchTransaction(final Runnable runnable)
+ {
+ if (Constants.ENABLE_PARAMETER_CHECKING)
+ {
+ if (null == runnable)
+ {
+ throw new IllegalArgumentException("runnable cannot be null"); //$NON-NLS-1$
+ }
+ }
+
+ mDb.beginTransaction();
+ try
+ {
+ runnable.run();
+
+ mDb.setTransactionSuccessful();
+ }
+ finally
+ {
+ mDb.endTransaction();
+ }
+ }
+
+ /**
+ * Private helper to test whether a given table name is valid
+ *
+ * @param table name of a table to check. This param may be null.
+ * @return true if the table is valid, false if the table is invalid. If {@code table} is null, returns false.
+ */
+ private static boolean isValidTable(final String table)
+ {
+ if (null == table)
+ {
+ return false;
+ }
+
+ if (!sValidTables.contains(table))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Private helper that knows all the tables that {@link LocalyticsProvider} can operate on.
+ *
+ * @return returns a set of the valid tables.
+ */
+ private static Set
+ * Note: This is a private method that is only made package-accessible for unit testing.
+ *
+ * @param context application context
+ * @throws IllegalArgumentException if {@code context} is null
+ */
+ /* package */static void deleteOldFiles(final Context context)
+ {
+ if (Constants.ENABLE_PARAMETER_CHECKING)
+ {
+ if (null == context)
+ {
+ throw new IllegalArgumentException("context cannot be null"); //$NON-NLS-1$
+ }
+ }
+
+ deleteDirectory(new File(context.getFilesDir(), "localytics")); //$NON-NLS-1$
+ }
+
+ /**
+ * Private helper to delete a directory, regardless of whether the directory is empty.
+ *
+ * @param directory Directory or file to delete. Cannot be null.
+ * @return true if deletion was successful. False if deletion failed.
+ */
+ private static boolean deleteDirectory(final File directory)
+ {
+ if (directory.exists() && directory.isDirectory())
+ {
+ for (final String child : directory.list())
+ {
+ final boolean success = deleteDirectory(new File(directory, child));
+ if (!success)
+ {
+ return false;
+ }
+ }
+ }
+
+ // The directory is now empty so delete it
+ return directory.delete();
+ }
+
+ /**
+ * A private helper class to open and create the Localytics SQLite database.
+ */
+ private static final class DatabaseHelper extends SQLiteOpenHelper
+ {
+ /**
+ * Constant representing the SQLite value for true
+ */
+ private static final String SQLITE_BOOLEAN_TRUE = "1"; //$NON-NLS-1$
+
+ /**
+ * Constant representing the SQLite value for false
+ */
+ private static final String SQLITE_BOOLEAN_FALSE = "0"; //$NON-NLS-1$
+
+ /**
+ * @param context Application context. Cannot be null.
+ * @param name File name of the database. Cannot be null or empty. A database with this name will be opened in
+ * {@link Context#getDatabasePath(String)}.
+ * @param version version of the database.
+ */
+ public DatabaseHelper(final Context context, final String name, final int version)
+ {
+ super(context, name, null, version);
+ }
+
+ /**
+ * Initializes the tables of the database.
+ *
+ * If an error occurs during initialization and an exception is thrown, {@link SQLiteDatabase#close()} will not be called
+ * by this method. That responsibility is left to the caller.
+ *
+ * @param db The database to perform post-creation processing on. db cannot not be null
+ * @throws IllegalArgumentException if db is null
+ */
+ @Override
+ public void onCreate(final SQLiteDatabase db)
+ {
+ if (null == db)
+ {
+ throw new IllegalArgumentException("db cannot be null"); //$NON-NLS-1$
+ }
+
+ // api_keys table
+ db.execSQL(String.format("CREATE TABLE %s (%s INTEGER PRIMARY KEY AUTOINCREMENT, %s TEXT UNIQUE NOT NULL, %s TEXT UNIQUE NOT NULL, %s INTEGER NOT NULL CHECK (%s >= 0), %s INTEGER NOT NULL CHECK(%s IN (%s, %s)));", ApiKeysDbColumns.TABLE_NAME, ApiKeysDbColumns._ID, ApiKeysDbColumns.API_KEY, ApiKeysDbColumns.UUID, ApiKeysDbColumns.CREATED_TIME, ApiKeysDbColumns.CREATED_TIME, ApiKeysDbColumns.OPT_OUT, ApiKeysDbColumns.OPT_OUT, SQLITE_BOOLEAN_FALSE, SQLITE_BOOLEAN_TRUE)); //$NON-NLS-1$
+
+ // sessions table
+ db.execSQL(String.format("CREATE TABLE %s (%s INTEGER PRIMARY KEY AUTOINCREMENT, %s INTEGER REFERENCES %s(%s) NOT NULL, %s TEXT UNIQUE NOT NULL, %s INTEGER NOT NULL CHECK (%s >= 0), %s TEXT NOT NULL, %s TEXT NOT NULL, %s INTEGER NOT NULL, %s TEXT NOT NULL, %s TEXT NOT NULL, %s TEXT NOT NULL, %s TEXT NOT NULL, %s TEXT, %s TEXT, %s TEXT, %s TEXT NOT NULL, %s TEXT NOT NULL, %s TEXT, %s TEXT, %s TEXT, %s TEXT, %s TEXT, %s TEXT);", SessionsDbColumns.TABLE_NAME, SessionsDbColumns._ID, SessionsDbColumns.API_KEY_REF, ApiKeysDbColumns.TABLE_NAME, ApiKeysDbColumns._ID, SessionsDbColumns.UUID, SessionsDbColumns.SESSION_START_WALL_TIME, SessionsDbColumns.SESSION_START_WALL_TIME, SessionsDbColumns.LOCALYTICS_LIBRARY_VERSION, SessionsDbColumns.APP_VERSION, SessionsDbColumns.ANDROID_VERSION, SessionsDbColumns.ANDROID_SDK, SessionsDbColumns.DEVICE_MODEL, SessionsDbColumns.DEVICE_MANUFACTURER, SessionsDbColumns.DEVICE_ANDROID_ID_HASH, SessionsDbColumns.DEVICE_TELEPHONY_ID, SessionsDbColumns.DEVICE_TELEPHONY_ID_HASH, SessionsDbColumns.DEVICE_SERIAL_NUMBER_HASH, SessionsDbColumns.LOCALE_LANGUAGE, SessionsDbColumns.LOCALE_COUNTRY, SessionsDbColumns.NETWORK_CARRIER, SessionsDbColumns.NETWORK_COUNTRY, SessionsDbColumns.NETWORK_TYPE, SessionsDbColumns.DEVICE_COUNTRY, SessionsDbColumns.LATITUDE, SessionsDbColumns.LONGITUDE)); //$NON-NLS-1$
+
+ // events table
+ db.execSQL(String.format("CREATE TABLE %s (%s INTEGER PRIMARY KEY AUTOINCREMENT, %s INTEGER REFERENCES %s(%s) NOT NULL, %s TEXT UNIQUE NOT NULL, %s TEXT NOT NULL, %s INTEGER NOT NULL CHECK (%s >= 0), %s INTEGER NOT NULL CHECK (%s >= 0));", EventsDbColumns.TABLE_NAME, EventsDbColumns._ID, EventsDbColumns.SESSION_KEY_REF, SessionsDbColumns.TABLE_NAME, SessionsDbColumns._ID, EventsDbColumns.UUID, EventsDbColumns.EVENT_NAME, EventsDbColumns.REAL_TIME, EventsDbColumns.REAL_TIME, EventsDbColumns.WALL_TIME, EventsDbColumns.WALL_TIME)); //$NON-NLS-1$
+
+ // event_history table
+ /*
+ * Note: the events history should be using foreign key constrains on the upload blobs table, but that is currently
+ * disabled to simplify the implementation of the upload processing.
+ */
+ db.execSQL(String.format("CREATE TABLE %s (%s INTEGER PRIMARY KEY AUTOINCREMENT, %s INTEGER REFERENCES %s(%s) NOT NULL, %s TEXT NOT NULL CHECK(%s IN (%s, %s)), %s TEXT NOT NULL, %s INTEGER);", EventHistoryDbColumns.TABLE_NAME, EventHistoryDbColumns._ID, EventHistoryDbColumns.SESSION_KEY_REF, SessionsDbColumns.TABLE_NAME, SessionsDbColumns._ID, EventHistoryDbColumns.TYPE, EventHistoryDbColumns.TYPE, Integer.valueOf(EventHistoryDbColumns.TYPE_EVENT), Integer.valueOf(EventHistoryDbColumns.TYPE_SCREEN), EventHistoryDbColumns.NAME, EventHistoryDbColumns.PROCESSED_IN_BLOB)); //$NON-NLS-1$
+ //db.execSQL(String.format("CREATE TABLE %s (%s INTEGER PRIMARY KEY AUTOINCREMENT, %s INTEGER REFERENCES %s(%s) NOT NULL, %s TEXT NOT NULL CHECK(%s IN (%s, %s)), %s TEXT NOT NULL, %s INTEGER REFERENCES %s(%s));", EventHistoryDbColumns.TABLE_NAME, EventHistoryDbColumns._ID, EventHistoryDbColumns.SESSION_KEY_REF, SessionsDbColumns.TABLE_NAME, SessionsDbColumns._ID, EventHistoryDbColumns.TYPE, EventHistoryDbColumns.TYPE, Integer.valueOf(EventHistoryDbColumns.TYPE_EVENT), Integer.valueOf(EventHistoryDbColumns.TYPE_SCREEN), EventHistoryDbColumns.NAME, EventHistoryDbColumns.PROCESSED_IN_BLOB, UploadBlobsDbColumns.TABLE_NAME, UploadBlobsDbColumns._ID)); //$NON-NLS-1$
+
+ // attributes table
+ db.execSQL(String.format("CREATE TABLE %s (%s INTEGER PRIMARY KEY AUTOINCREMENT, %s INTEGER REFERENCES %s(%s) NOT NULL, %s TEXT NOT NULL, %s TEXT NOT NULL);", AttributesDbColumns.TABLE_NAME, AttributesDbColumns._ID, AttributesDbColumns.EVENTS_KEY_REF, EventsDbColumns.TABLE_NAME, EventsDbColumns._ID, AttributesDbColumns.ATTRIBUTE_KEY, AttributesDbColumns.ATTRIBUTE_VALUE)); //$NON-NLS-1$
+
+ // upload blobs
+ db.execSQL(String.format("CREATE TABLE %s (%s INTEGER PRIMARY KEY AUTOINCREMENT, %s TEXT UNIQUE NOT NULL);", UploadBlobsDbColumns.TABLE_NAME, UploadBlobsDbColumns._ID, UploadBlobsDbColumns.UUID)); //$NON-NLS-1$
+
+ // upload events
+ db.execSQL(String.format("CREATE TABLE %s (%s INTEGER PRIMARY KEY AUTOINCREMENT, %s INTEGER REFERENCES %s(%s) NOT NULL, %s INTEGER REFERENCES %s(%s) NOT NULL);", UploadBlobEventsDbColumns.TABLE_NAME, UploadBlobEventsDbColumns._ID, UploadBlobEventsDbColumns.UPLOAD_BLOBS_KEY_REF, UploadBlobsDbColumns.TABLE_NAME, UploadBlobsDbColumns._ID, UploadBlobEventsDbColumns.EVENTS_KEY_REF, EventsDbColumns.TABLE_NAME, EventsDbColumns._ID)); //$NON-NLS-1$
+ }
+
+ @Override
+ public void onOpen(final SQLiteDatabase db)
+ {
+ super.onOpen(db);
+
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.v(Constants.LOG_TAG, String.format("SQLite library version is: %s", DatabaseUtils.stringForQuery(db, "select sqlite_version()", null))); //$NON-NLS-1$//$NON-NLS-2$
+ }
+
+ if (!db.isReadOnly())
+ {
+ /*
+ * Enable foreign key support
+ */
+ db.execSQL("PRAGMA foreign_keys = ON;"); //$NON-NLS-1$
+
+ // if (Constants.IS_LOGGABLE)
+ // {
+ // try
+ // {
+ // final String result1 = DatabaseUtils.stringForQuery(db, "PRAGMA foreign_keys;", null); //$NON-NLS-1$
+ // Log.v(Constants.LOG_TAG, String.format("Foreign keys support result was: %s", result1)); //$NON-NLS-1$
+ // }
+ // catch (final SQLiteDoneException e)
+ // {
+ // Log.w(Constants.LOG_TAG, e);
+ // }
+ // }
+ }
+ }
+
+ @Override
+ public void onUpgrade(final SQLiteDatabase arg0, final int oldVersion, final int newVersion)
+ {
+ // initial version; no upgrades needed at this time
+ }
+
+ // @Override
+ // public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion)
+ // {
+ // // initial version; no downgrades needed at this time
+ // }
+ }
+
+ /**
+ * Table for the API keys used and the opt-out preferences for each API key.
+ *
+ * This is not a public API.
+ */
+ public final class ApiKeysDbColumns implements BaseColumns
+ {
+ /**
+ * Private constructor prevents instantiation
+ *
+ * @throws UnsupportedOperationException because this class cannot be instantiated.
+ */
+ private ApiKeysDbColumns()
+ {
+ throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$
+ }
+
+ /**
+ * SQLite table name
+ */
+ public static final String TABLE_NAME = "api_keys"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * The Localytics API key.
+ *
+ * Constraints: This column is unique and cannot be null.
+ */
+ public static final String API_KEY = "api_key"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * A UUID for the installation.
+ *
+ * Constraints: This column is unique and cannot be null.
+ */
+ public static final String UUID = "uuid"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code boolean}
+ *
+ * A flag indicating whether the user has opted out of data collection.
+ *
+ * Constraints: This column must be in the set {0, 1} and cannot be null.
+ */
+ public static final String OPT_OUT = "opt_out"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code long}
+ *
+ * A long representing the {@link System#currentTimeMillis()} when the row was created. Once created, this row will not be
+ * modified.
+ *
+ * Constraints: This column must be >=0. This column cannot be null.
+ */
+ public static final String CREATED_TIME = "created_time"; //$NON-NLS-1$
+ }
+
+ /**
+ * Database table for the session attributes. There is a one-to-many relationship between one event in the
+ * {@link EventsDbColumns} table and the many attributes associated with that event.
+ *
+ * This is not a public API.
+ */
+ public static final class AttributesDbColumns implements BaseColumns
+ {
+ /**
+ * Private constructor prevents instantiation
+ *
+ * @throws UnsupportedOperationException because this class cannot be instantiated.
+ */
+ private AttributesDbColumns()
+ {
+ throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$
+ }
+
+ /**
+ * SQLite table name
+ */
+ public static final String TABLE_NAME = "attributes"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code long}
+ *
+ * A one-to-many relationship with {@link EventsDbColumns#_ID}.
+ *
+ * Constraints: This is a foreign key with the {@link EventsDbColumns#_ID} column. This cannot be null.
+ */
+ public static final String EVENTS_KEY_REF = "events_key_ref"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * String representing the key name of the attribute.
+ *
+ * Constraints: This cannot be null.
+ */
+ public static final String ATTRIBUTE_KEY = "attribute_key"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * String representing the value of the attribute.
+ *
+ * Constraints: This cannot be null.
+ */
+ public static final String ATTRIBUTE_VALUE = "attribute_value"; //$NON-NLS-1$
+
+ }
+
+ /**
+ * Database table for the session events. There is a one-to-many relationship between one session data entry in the
+ * {@link SessionsDbColumns} table and the many events associated with that session.
+ *
+ * This is not a public API.
+ */
+ public static final class EventsDbColumns implements BaseColumns
+ {
+ /**
+ * Private constructor prevents instantiation
+ *
+ * @throws UnsupportedOperationException because this class cannot be instantiated.
+ */
+ private EventsDbColumns()
+ {
+ throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$
+ }
+
+ /**
+ * SQLite table name
+ */
+ public static final String TABLE_NAME = "events"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code long}
+ *
+ * A one-to-many relationship with {@link SessionsDbColumns#_ID}.
+ *
+ * Constraints: This is a foreign key with the {@link SessionsDbColumns#_ID} column. This cannot be null.
+ */
+ public static final String SESSION_KEY_REF = "session_key_ref"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * Unique ID of the event, as generated from {@link java.util.UUID}.
+ *
+ * Constraints: This is unique and cannot be null.
+ */
+ public static final String UUID = "uuid"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * String representing the name of the event.
+ *
+ * Constraints: This cannot be null.
+ */
+ public static final String EVENT_NAME = "event_name"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code long}
+ *
+ * A long representing the {@link android.os.SystemClock#elapsedRealtime()} when the event occurred.
+ *
+ * Constraints: This column must be >=0. This column cannot be null.
+ */
+ public static final String REAL_TIME = "real_time"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code long}
+ *
+ * A long representing the {@link System#currentTimeMillis()} when the event occurred.
+ *
+ * Constraints: This column must be >=0. This column cannot be null.
+ */
+ public static final String WALL_TIME = "wall_time"; //$NON-NLS-1$
+
+ }
+
+ /**
+ * Database table for tracking the history of events and screens. There is a one-to-many relationship between one session data
+ * entry in the {@link SessionsDbColumns} table and the many historical events associated with that session.
+ *
+ * This is not a public API.
+ */
+ public static final class EventHistoryDbColumns implements BaseColumns
+ {
+ /**
+ * Private constructor prevents instantiation
+ *
+ * @throws UnsupportedOperationException because this class cannot be instantiated.
+ */
+ private EventHistoryDbColumns()
+ {
+ throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$
+ }
+
+ /**
+ * SQLite table name
+ */
+ public static final String TABLE_NAME = "event_history"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code long}
+ *
+ * A one-to-many relationship with {@link SessionsDbColumns#_ID}.
+ *
+ * Constraints: This is a foreign key with the {@link SessionsDbColumns#_ID} column. This cannot be null.
+ */
+ public static final String SESSION_KEY_REF = "session_key_ref"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * Unique ID of the event, as generated from {@link java.util.UUID}.
+ *
+ * Constraints: This is unique and cannot be null.
+ */
+ public static final String TYPE = "type"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * String representing the name of the screen or event.
+ *
+ * Constraints: This cannot be null.
+ */
+ public static final String NAME = "name"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code boolean}
+ *
+ * Foreign key to the upload blob that this event was processed in. May be null indicating that this event wasn't
+ * processed yet.
+ */
+ public static final String PROCESSED_IN_BLOB = "processed_in_blob"; //$NON-NLS-1$
+
+ /**
+ * Type value for {@link #TYPE} indicates an event event.
+ */
+ public static final int TYPE_EVENT = 0;
+
+ /**
+ * Type value for {@link #TYPE} that indicates a screen event.
+ */
+ public static final int TYPE_SCREEN = 1;
+ }
+
+ /**
+ * Database table for the session data. There is a one-to-many relationship between one API key entry in the
+ * {@link ApiKeysDbColumns} table and many sessions for that API key.
+ *
+ * This is not a public API.
+ */
+ public static final class SessionsDbColumns implements BaseColumns
+ {
+ /**
+ * Private constructor prevents instantiation
+ *
+ * @throws UnsupportedOperationException because this class cannot be instantiated.
+ */
+ private SessionsDbColumns()
+ {
+ throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$
+ }
+
+ /**
+ * SQLite table name
+ */
+ public static final String TABLE_NAME = "sessions"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code long}
+ *
+ * A one-to-one relationship with {@link ApiKeysDbColumns#_ID}.
+ *
+ * Constraints: This is a foreign key with the {@link ApiKeysDbColumns#_ID} column. This cannot be null.
+ */
+ public static final String API_KEY_REF = "api_key_ref"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * Unique ID of the event, as generated from {@link java.util.UUID}.
+ *
+ * Constraints: This is unique and cannot be null.
+ */
+ public static final String UUID = "uuid"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code long}
+ *
+ * The wall time when the session started.
+ *
+ * Constraints: This column must be >=0. This column cannot be null.
+ */
+ /*
+ * Note: While this same information is encoded in {@link EventsDbColumns#WALL_TIME} for the session open event, that row
+ * may not be available when an upload occurs and the upload needs to compute the duration of the session.
+ */
+ public static final String SESSION_START_WALL_TIME = "session_start_wall_time"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * Version of the Localytics client library.
+ *
+ * @see Constants#LOCALYTICS_CLIENT_LIBRARY_VERSION
+ */
+ public static final String LOCALYTICS_LIBRARY_VERSION = "localytics_library_version"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * String representing the app's versionName
+ *
+ * Constraints: This cannot be null.
+ */
+ public static final String APP_VERSION = "app_version"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * String representing the version of Android
+ *
+ * Constraints: This cannot be null.
+ */
+ public static final String ANDROID_VERSION = "android_version"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code int}
+ *
+ * Integer the Android SDK
+ *
+ * Constraints: Must be an integer and cannot be null.
+ *
+ * @see android.os.Build.VERSION#SDK_INT
+ */
+ public static final String ANDROID_SDK = "android_sdk"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * String representing the device model
+ *
+ * Constraints: None
+ *
+ * @see android.os.Build#MODEL
+ */
+ public static final String DEVICE_MODEL = "device_model"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * String representing the device manufacturer
+ *
+ * Constraints: None
+ *
+ * @see android.os.Build#MANUFACTURER
+ */
+ public static final String DEVICE_MANUFACTURER = "device_manufacturer"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * String representing a hash of the device Android ID
+ *
+ * Constraints: None
+ *
+ * @see android.provider.Settings.Secure#ANDROID_ID
+ */
+ public static final String DEVICE_ANDROID_ID_HASH = "device_android_id_hash"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * String representing the telephony ID of the device. May be null for non-telephony devices. May also be null if the
+ * parent application doesn't have {@link android.Manifest.permission#READ_PHONE_STATE}.
+ *
+ * Constraints: None
+ *
+ * @see android.telephony.TelephonyManager#getDeviceId()
+ */
+ public static final String DEVICE_TELEPHONY_ID = "device_telephony_id"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * String representing a hash of the telephony ID of the device. May be null for non-telephony devices. May also be null
+ * if the parent application doesn't have {@link android.Manifest.permission#READ_PHONE_STATE}.
+ *
+ * Constraints: None
+ *
+ * @see android.telephony.TelephonyManager#getDeviceId()
+ */
+ public static final String DEVICE_TELEPHONY_ID_HASH = "device_telephony_id_hash"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * String representing a hash of the the serial number of the device. May be null for some telephony devices.
+ *
+ * Constraints: None
+ */
+ public static final String DEVICE_SERIAL_NUMBER_HASH = "device_serial_number_hash"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * Represents the locale language of the device.
+ *
+ * Constraints: Cannot be null.
+ */
+ public static final String LOCALE_LANGUAGE = "locale_language"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * Represents the locale country of the device.
+ *
+ * Constraints: Cannot be null.
+ */
+ public static final String LOCALE_COUNTRY = "locale_country"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * Represents the locale country of the device, according to the SIM card.
+ *
+ * Constraints: Cannot be null.
+ */
+ public static final String DEVICE_COUNTRY = "device_country"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * Represents the network carrier of the device. May be null for non-telephony devices.
+ *
+ * Constraints: None
+ */
+ public static final String NETWORK_CARRIER = "network_carrier"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * Represents the network country of the device. May be null for non-telephony devices.
+ *
+ * Constraints: None
+ */
+ public static final String NETWORK_COUNTRY = "network_country"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * Represents the primary network connection type for the device. This could be any type, including Wi-Fi, various cell
+ * networks, Ethernet, etc.
+ *
+ * Constraints: None
+ *
+ * @see android.telephony.TelephonyManager
+ */
+ public static final String NETWORK_TYPE = "network_type"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code double}
+ *
+ * Represents the latitude of the device. May be null if no longitude is known.
+ *
+ * Constraints: None
+ */
+ public static final String LATITUDE = "latitude"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code double}
+ *
+ * Represents the longitude of the device. May be null if no longitude is known.
+ *
+ * Constraints: None
+ */
+ public static final String LONGITUDE = "longitude"; //$NON-NLS-1$
+
+ }
+
+ /**
+ * Database table for the events associated with a given upload blob. There is a one-to-many relationship between one upload
+ * blob in the {@link UploadBlobsDbColumns} table and the blob events. There is a one-to-one relationship between each blob
+ * event entry and the actual events in the {@link EventsDbColumns} table. *
+ *
+ * This is not a public API.
+ */
+ public static final class UploadBlobEventsDbColumns implements BaseColumns
+ {
+ /**
+ * Private constructor prevents instantiation
+ *
+ * @throws UnsupportedOperationException because this class cannot be instantiated.
+ */
+ private UploadBlobEventsDbColumns()
+ {
+ throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$
+ }
+
+ /**
+ * SQLite table name
+ */
+ public static final String TABLE_NAME = "upload_blob_events"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * A one-to-many relationship with {@link UploadBlobsDbColumns#_ID}.
+ *
+ * Constraints: This is a foreign key with the {@link UploadBlobsDbColumns#_ID} column. This cannot be null.
+ */
+ public static final String UPLOAD_BLOBS_KEY_REF = "upload_blobs_key_ref"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code long}
+ *
+ * A one-to-one relationship with {@link EventsDbColumns#_ID}.
+ *
+ * Constraints: This is a foreign key with the {@link EventsDbColumns#_ID} column. This cannot be null.
+ */
+ public static final String EVENTS_KEY_REF = "events_key_ref"; //$NON-NLS-1$
+ }
+
+ /**
+ * Database table for the upload blobs. Logically, a blob owns many events. In terms of the implementation, some indirection
+ * is introduced by a blob having a one-to-many relationship with {@link UploadBlobsDbColumns} and
+ * {@link UploadBlobsDbColumns} having a one-to-one relationship with {@link EventsDbColumns}
+ *
+ * This is not a public API.
+ */
+ public static final class UploadBlobsDbColumns implements BaseColumns
+ {
+ /**
+ * Private constructor prevents instantiation
+ *
+ * @throws UnsupportedOperationException because this class cannot be instantiated.
+ */
+ private UploadBlobsDbColumns()
+ {
+ throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$
+ }
+
+ /**
+ * SQLite table name
+ */
+ public static final String TABLE_NAME = "upload_blobs"; //$NON-NLS-1$
+
+ /**
+ * TYPE: {@code String}
+ *
+ * Unique ID of the upload blob, as generated from {@link java.util.UUID}.
+ *
+ * Constraints: This is unique and cannot be null.
+ */
+ public static final String UUID = "uuid"; //$NON-NLS-1$
+
+ }
+}
diff --git a/astrid/common-src/com/localytics/android/LocalyticsSession.java b/astrid/common-src/com/localytics/android/LocalyticsSession.java
old mode 100644
new mode 100755
index 1947c9602..d0bea045a
--- a/astrid/common-src/com/localytics/android/LocalyticsSession.java
+++ b/astrid/common-src/com/localytics/android/LocalyticsSession.java
@@ -1,425 +1,356 @@
-/**
- * LocalyticsSession.java
- * Copyright (C) 2009 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:off
+/*
+ * LocalyticsSession.java Copyright (C) 2011 Char Software Inc., DBA Localytics This code is provided under the Localytics
+ * Modified BSD License. A copy of this license has been distributed in a file called LICENSE with this source code. Please visit
+ * www.localytics.com for more information.
*/
+// @formatter:on
package com.localytics.android;
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
-import java.io.FileReader;
-import java.io.FileWriter;
-import java.io.FilenameFilter;
+import android.Manifest.permission;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.CursorJoiner;
+import android.os.Build;
+import android.os.Build.VERSION;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.os.SystemClock;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+import android.util.Log;
+
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
-import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
import java.util.Arrays;
-import java.util.Iterator;
+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 android.content.Context;
-import android.os.Build;
-import android.telephony.TelephonyManager;
-import android.util.Log;
+import java.util.zip.GZIPOutputStream;
+
+import org.apache.http.HttpResponse;
+import org.apache.http.StatusLine;
+import org.apache.http.client.ClientProtocolException;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ByteArrayEntity;
+import org.apache.http.impl.client.DefaultHttpClient;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import com.localytics.android.JsonObjects.BlobHeader;
+import com.localytics.android.LocalyticsProvider.ApiKeysDbColumns;
+import com.localytics.android.LocalyticsProvider.AttributesDbColumns;
+import com.localytics.android.LocalyticsProvider.EventHistoryDbColumns;
+import com.localytics.android.LocalyticsProvider.EventsDbColumns;
+import com.localytics.android.LocalyticsProvider.SessionsDbColumns;
+import com.localytics.android.LocalyticsProvider.UploadBlobEventsDbColumns;
+import com.localytics.android.LocalyticsProvider.UploadBlobsDbColumns;
/**
- * The class which manages creating, collecting, & uploading a Localytics session.
- * Please see the following guides for information on how to best use this
- * library, sample code, and other useful information:
+ * This class manages creating, collecting, and uploading a Localytics session. Please see the following guides for information on
+ * how to best use this library, sample code, and other useful information:
*
- * Permissions required or recommended for this class:
+ * Permissions required:
+ *
+ * This library will create a database called "com.android.localytics.sqlite" within the host application's
+ * {@link Context#getDatabasePath(String)} directory. For security, this file directory will be created
+ * {@link Context#MODE_PRIVATE}. The host application must not modify this database file. If the host application implements a
+ * backup/restore mechanism, such as {@code android.app.backup.BackupManager}, the host application should not worry about backing
+ * up the data in the Localytics database.
+ *
+ * This library is thread-safe but is not multi-process safe. Unless the application explicitly uses different process attributes
+ * in the Android Manifest, this is not an issue.
+ *
+ * This class is thread-safe.
+ *
+ * @version 2.0
*/
-@SuppressWarnings("nls")
public final class LocalyticsSession
{
- ////////////////////////////////////////
- // Member Variables ////////////////////
- ////////////////////////////////////////
- private String _localyticsDirPath; // Path for this app's Localytics Files
- private String _sessionFilename = null; // Filename for this session
- private String _closeFilename = null; // Filename for this session's close events
- private String _sessionUUID; // Unique identifier for this session.
- private String _applicationKey; // Unique identifier for the instrumented application
-
- private Context _appContext; // The context used to access device resources
-
- private boolean _isSessionOpen = false; // Whether or not this session has been opened.
-
- private static boolean _isUploading = false; // Only allow one instance of the app to upload at once.
- private static boolean _isOptedIn = false; // Optin/out needs to be shared by all instances of this class.
-
- ////////////////////////////////////////
- // Constants ///////////////////////////
- ////////////////////////////////////////
- private static final String CLIENT_VERSION = "1.5"; // The version of this library.
- private static final int MAX_NUM_SESSIONS = 10; // Number of sessions to store on the disk
- private static final int MAX_NUM_ATTRIBUTES = 10; // Maximum attributes per event session
- protected static final int MAX_NAME_LENGTH = 128; // Maximum characters in an event name or attribute key/value
-
- // Filename and directory constants.
- private static final String LOCALYTICS_DIR = "localytics";
- private static final String SESSION_FILE_PREFIX = "s_";
- private static final String UPLOADER_FILE_PREFIX = "u_";
- private static final String CLOSE_FILE_PREFIX = "c_";
- private static final String OPTOUT_FILNAME = "opted_out";
- private static final String DEVICE_ID_FILENAME = "device_id";
- private static final String SESSION_ID_FILENAME = "last_session_id";
-
- // All session opt-in / opt-out events need to be written to same place to gaurantee ordering on the server.
- private static final String OPT_SESSION = LocalyticsSession.SESSION_FILE_PREFIX + "opt_session";
-
- // The tag used for identifying Localytics Log messages.
- private static final String LOG_TAG = "Localytics_Session";
-
- // The number of milliseconds after which a session is considered closed and can't be reattached to
- // 15 seconds * 1000ms
- private static int SESSION_EXPIRATION = 15 * 1000;
-
- ////////////////////////////////////////
- // Public Methods //////////////////////
- ////////////////////////////////////////
+ /*
+ * 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.
+ */
+
/**
- * Creates the Localytics Object. If Localytics is opted out at the time
- * this object is created, no data will be collected for the lifetime of
- * this session.
- * @param appContext The context used to access resources on behalf of the app.
- * It is recommended to use
+ * This Handler is the key thread synchronization point for all work inside the LocalyticsSession.
+ *
+ * This handler runs on {@link #sSessionHandlerThread}.
+ */
+ private final Handler mSessionHandler;
- // It isn't necessary to have each session live in its own file because every event
- // has the session_id in it. However, this makes maintaining a queue much simpler,
- // and it simplifies multithreading because different instances write to different files
- fp = getOrCreateFileWithDefaultPath(this._sessionFilename);
- if(fp == null)
- {
- this._isSessionOpen = false;
- return;
- }
+ /**
+ * Application context
+ */
+ private final Context mContext;
- // If the file already exists then an open event has already been written.
- else if(fp.length() != 0)
- {
- Log.v(LocalyticsSession.LOG_TAG, "Session already opened");
- return;
- }
+ /**
+ * Localytics application key
+ */
+ private final String mLocalyticsKey;
- appendDataToFile(fp, getOpenSessionString());
- Log.v(LocalyticsSession.LOG_TAG, "Session opened");
- }
+ /**
+ * Keeps track of which Localytics clients are currently uploading, in order to allow only one upload for a given key at a
+ * time.
+ *
+ * This field can only be read/written to from the {@link #sSessionHandlerThread}. This invariant is maintained by only
+ * accessing this field from within the {@link #mSessionHandler}.
+ */
+ private static Map
+ * If for any reason this is called more than once without an intervening call to {@link #close()}, subsequent calls to open
+ * will be ignored.
+ *
+ * For applications with multiple Activities, every Activity should call
+ * This message must be sent before any other messages.
+ */
+ public static final int MESSAGE_INIT = 0;
+
+ /**
+ * Empty handler message to open a localytics session
+ */
+ public static final int MESSAGE_OPEN = 1;
+
+ /**
+ * Empty handler message to close a localytics session
+ */
+ public static final int MESSAGE_CLOSE = 2;
+
+ /**
+ * Handler message to tag an event.
+ *
+ * {@link Message#obj} is a {@link Pair} instance. This object cannot be null.
+ */
+ public static final int MESSAGE_TAG_EVENT = 3;
+
+ /**
+ * Handler message to upload all data collected so far
+ *
+ * {@link Message#obj} is a {@code Runnable} to execute when upload is complete. The thread that this runnable will
+ * executed on is undefined.
+ */
+ public static final int MESSAGE_UPLOAD = 4;
+
+ /**
+ * Empty Handler message indicating that a previous upload attempt was completed.
+ */
+ public static final int MESSAGE_UPLOAD_COMPLETE = 5;
+
+ /**
+ * Handler message indicating an opt-out choice.
+ *
+ * {@link Message#arg1} == 1 for true (opt out). 0 means opt-in.
+ */
+ public static final int MESSAGE_OPT_OUT = 6;
+
+ /**
+ * Handler message indicating a tag screen event
+ *
+ * {@link Message#obj} is a string representing the screen visited.
+ */
+ public static final int MESSAGE_TAG_SCREEN = 7;
+
+ /**
+ * Application context
+ */
+ private final Context mContext;
+
+ /**
+ * Localytics database
+ */
+ private LocalyticsProvider mProvider;
+
+ /**
+ * The Localytics API key for the session.
+ */
+ private final String mApiKey;
+
+ /**
+ * {@link ApiKeysDbColumns#_ID} for the {@link LocalyticsSession#mLocalyticsKey}.
+ */
+ private long mApiKeyId;
+
+ /**
+ * {@link SessionsDbColumns#_ID} for the session.
+ */
+ private long mSessionId;
+
+ /**
+ * Flag variable indicating whether {@link #MESSAGE_OPEN} has been received yet.
+ */
+ private boolean mIsSessionOpen = false;
+
+ /**
+ * Flag variable indicating whether the user has opted out of data collection.
+ */
+ private boolean mIsOptedOut = false;
+
+ /**
+ * Handler object where all upload of this instance of LocalyticsSession are handed off to.
+ *
+ * This handler runs on {@link #sUploadHandlerThread}.
+ */
+ private Handler mUploadHandler;
+
+ /**
+ * Constructs a new Handler that runs on the given looper.
+ *
+ * @param context The context used to access resources on behalf of the app. It is recommended to use
+ * {@link Context#getApplicationContext()} to avoid the potential memory leak incurred by maintaining
+ * references to {@code Activity} instances. Cannot be null.
+ * @param key The key unique for each application generated at www.localytics.com. Cannot be null or empty.
+ * @param looper to run the Handler on. Cannot be null.
+ * @throws IllegalArgumentException if {@code context} is null
+ * @throws IllegalArgumentException if {@code key} is null or empty
+ */
+ public SessionHandler(final Context context, final String key, final Looper looper)
+ {
+ super(looper);
+
+ if (Constants.ENABLE_PARAMETER_CHECKING)
{
- return;
+ 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$
+ }
}
- LocalyticsSession._isUploading = true;
+ mContext = context;
+ mApiKey = key;
}
- try
- {
- File fp = new File(this._localyticsDirPath);
- UploaderThread uploader = new UploaderThread(
- fp,
- LocalyticsSession.SESSION_FILE_PREFIX,
- LocalyticsSession.UPLOADER_FILE_PREFIX,
- LocalyticsSession.CLOSE_FILE_PREFIX,
- this.uploadComplete);
-
- uploader.start();
- }
- catch (Exception e)
+ @Override
+ public void handleMessage(final Message msg)
{
- Log.v(LocalyticsSession.LOG_TAG, "Swallowing exception: " + e.getMessage());
- }
- }
+ switch (msg.what)
+ {
+ case MESSAGE_INIT:
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.v(Constants.LOG_TAG, "Handler received MESSAGE_INIT"); //$NON-NLS-1$
+ }
- ////////////////////////////////////////
- // Private Methods /////////////////////
- ////////////////////////////////////////
- /**
- * Gets a file from the application storage, or creates if it isn't there.
- * The file is created inside this session's default storage location which
- * makes it specific to the app key the user selects.
- * @param path relative path to create the file in. should not be seperator_terminated
- * @param filename the file to create
- * @param path to create the file in
- * @return a File object, or null if something goes wrong
- */
- private File getOrCreateFileWithDefaultPath(final String filename)
- {
- return getOrCreateFile(filename, this._localyticsDirPath);
- }
+ init();
- /**
- * Gets a file from the application storage, or creates if it isn't there.
- * @param path relative path to create the file in. should not be seperator_terminated
- * @param filename the file to create
- * @param path to create the file in
- * @return a File object, or null if something goes wrong
- */
- private File getOrCreateFile(final String filename, final String path)
- {
- // Get the file if it already exists
- File fp = new File(path + filename);
- if(fp.exists())
- {
- return fp;
- }
+ break;
+ }
+ case MESSAGE_OPT_OUT:
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.v(Constants.LOG_TAG, "Handler received MESSAGE_OPT_OUT"); //$NON-NLS-1$
+ }
- // Otherwise, create any necessary directories, and the file itself.
- new File(path).mkdirs();
- try
- {
- if(fp.createNewFile())
- {
- return fp;
- }
- }
- catch (IOException e)
- {
- Log.v(LocalyticsSession.LOG_TAG,
- "Unable to get or create file: " + filename
- + " in path: " + path);
- }
+ final boolean isOptingOut = msg.arg1 == 0 ? false : true;
- return null;
- }
+ SessionHandler.this.optOut(isOptingOut);
- /**
- * Uses an OutputStreamWriter to write and flush a string to the end of a text file.
- * @param file Text file to append data to.
- * @param data String to be appended
- */
- private static void appendDataToFile(final File file, final String data)
- {
- try
- {
- if(file != null)
- {
- // Only allow one append to happen at a time. This gaurantees files don't get corrupted by
- // multiple threads in the same app writing at the same time, and it gaurantees app-wide
- // like device_id don't get broken by multiple instance apps.
- synchronized(LocalyticsSession.class)
- {
- OutputStream out = new FileOutputStream(file, true);
- out.write(data.getBytes("UTF8"));
- out.close();
+ break;
}
- }
- }
- catch(IOException e)
- {
- Log.v(LocalyticsSession.LOG_TAG, "AppendDataToFile failed with IO Exception: " + e.getMessage());
- }
- }
+ case MESSAGE_OPEN:
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.v(Constants.LOG_TAG, "Handler received MESSAGE_OPEN"); //$NON-NLS-1$
+ }
- /**
- * Overwrites a given file with new contents
- * @param file file to overwrite
- * @param contents contents to store in the file
- */
- private static void overwriteFile(final File file, final String contents)
- {
- if(file != null)
- {
- try
- {
- FileWriter writer = new FileWriter(file);
- writer.write(contents);
- writer.flush();
- writer.close();
- }
- catch(IOException e)
- {
- Log.v(LocalyticsSession.LOG_TAG, "Ovewriting file failed with IO Exception: " + e.getMessage());
- }
- }
- }
+ SessionHandler.this.open(false);
- /**
- * Creates the YAML string for the open session event.
- * Collects all the basic session datapoints and writes them out as a YAML string.
- * @return The YAML blob for the open session event.
- */
- private String getOpenSessionString()
- {
- StringBuffer openString = new StringBuffer();
- TelephonyManager telephonyManager = (TelephonyManager)this._appContext.getSystemService(Context.TELEPHONY_SERVICE);
- Locale defaultLocale = Locale.getDefault();
-
- openString.append(DatapointHelper.CONTROLLER_SESSION);
- openString.append(DatapointHelper.ACTION_CREATE);
- openString.append(DatapointHelper.OBJECT_SESSION_DP);
-
- // Application and session information
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_UUID, this._sessionUUID, 3));
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_APP_UUID, this._applicationKey, 3));
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_APP_VERSION, DatapointHelper.getAppVersion(this._appContext), 3));
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_LIBRARY_VERSION, LocalyticsSession.CLIENT_VERSION, 3));
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_CLIENT_TIME, DatapointHelper.getTimeAsDatetime(), 3));
-
- // Other device information
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_DEVICE_UUID, getDeviceId(), 3));
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_DEVICE_PLATFORM, "Android", 3));
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_OS_VERSION, Build.ID, 3));
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_DEVICE_MODEL, Build.MODEL, 3));
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_LOCALE_LANGUAGE, defaultLocale.getLanguage(), 3));
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_LOCALE_COUNTRY, defaultLocale.getCountry(), 3));
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_DEVICE_COUNTRY, telephonyManager.getSimCountryIso(), 3));
-
- // Network information
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_NETWORK_CARRIER, telephonyManager.getNetworkOperatorName(), 3));
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_NETWORK_COUNTRY, telephonyManager.getNetworkCountryIso(), 3));
- openString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_DATA_CONNECTION, DatapointHelper.getNetworkType(this._appContext, telephonyManager), 3));
-
- return openString.toString();
- }
+ break;
+ }
+ case MESSAGE_CLOSE:
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.d(Constants.LOG_TAG, "Handler received MESSAGE_CLOSE"); //$NON-NLS-1$
+ }
- /**
- * Gets an identifier which is unique to this machine, but generated randomly
- * so it can't be traced.
- * @return Returns the deviceID as a string
- */
- private String getDeviceId()
- {
- // Try and get the global device ID. If that fails, maintain an id
- // local to this application. This way it is still possible to tell things like
- // 'new vs returning users' on the webservice.
- String deviceId = DatapointHelper.getGlobalDeviceId(this._appContext);
- if(deviceId == null)
- {
- deviceId = getLocalDeviceId();
- }
+ SessionHandler.this.close();
- return deviceId;
- }
+ break;
+ }
+ case MESSAGE_TAG_EVENT:
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.d(Constants.LOG_TAG, "Handler received MESSAGE_TAG"); //$NON-NLS-1$
+ }
- /**
- * Gets an identifier unique to this application on this device. If one is not currently available,
- * a new one is generated and stored.
- * @return An identifier unique to this application this device.
- */
- private String getLocalDeviceId()
- {
- String deviceId = null;
- final int bufferSize = 100;
+ @SuppressWarnings("unchecked")
+ final Pair
+ * This method must only be called once.
+ *
+ * Note: This method is a private implementation detail. It is only made public for unit testing purposes. The public
+ * interface is to send {@link #MESSAGE_INIT} to the Handler.
+ *
+ * @see #MESSAGE_INIT
+ */
+ public void init()
{
+ mProvider = LocalyticsProvider.getInstance(mContext, mApiKey);
+
+ /*
+ * Check whether this session key is opted out
+ */
+ Cursor cursor = null;
+ try
+ {
+ cursor = mProvider.query(ApiKeysDbColumns.TABLE_NAME, new String[]
+ {
+ ApiKeysDbColumns._ID,
+ ApiKeysDbColumns.OPT_OUT }, String.format("%s = ?", ApiKeysDbColumns.API_KEY), new String[] //$NON-NLS-1$
+ { mApiKey }, null);
+
+ if (cursor.moveToFirst())
+ {
+ // API key was previously created
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.v(Constants.LOG_TAG, String.format("Loading details for API key %s", mApiKey)); //$NON-NLS-1$
+ }
+
+ mApiKeyId = cursor.getLong(cursor.getColumnIndexOrThrow(ApiKeysDbColumns._ID));
+ mIsOptedOut = cursor.getInt(cursor.getColumnIndexOrThrow(ApiKeysDbColumns.OPT_OUT)) != 0;
+ }
+ else
+ {
+ // perform first-time initialization of API key
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.v(Constants.LOG_TAG, String.format("Performing first-time initialization for new API key %s", mApiKey)); //$NON-NLS-1$
+ }
+
+ final ContentValues values = new ContentValues();
+ values.put(ApiKeysDbColumns.API_KEY, mApiKey);
+ values.put(ApiKeysDbColumns.UUID, UUID.randomUUID().toString());
+ values.put(ApiKeysDbColumns.OPT_OUT, Boolean.FALSE);
+ values.put(ApiKeysDbColumns.CREATED_TIME, Long.valueOf(System.currentTimeMillis()));
+
+ mApiKeyId = mProvider.insert(ApiKeysDbColumns.TABLE_NAME, values);
+ }
+ }
+ finally
+ {
+ if (cursor != null)
+ {
+ cursor.close();
+ cursor = null;
+ }
+ }
+
+ if (!sIsUploadingMap.containsKey(mApiKey))
+ {
+ sIsUploadingMap.put(mApiKey, Boolean.FALSE);
+ }
+
+ /*
+ * Perform lazy initialization of the UploadHandler
+ */
+ mUploadHandler = new UploadHandler(mContext, this, mApiKey, sUploadHandlerThread.getLooper());
+ }
+
+ /**
+ * Set the opt-in/out-out state for all sessions using the current API key.
+ *
+ * This method must only be called after {@link #init()} is called.
+ *
+ * Note: This method is a private implementation detail. It is only made package accessible for unit testing purposes. The
+ * public interface is to send {@link #MESSAGE_OPT_OUT} to the Handler.
+ *
+ * @param isOptingOut true if the user is opting out. False if the user is opting back in.
+ * @see #MESSAGE_OPT_OUT
+ */
+ /* package */void optOut(final boolean isOptingOut)
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.v(Constants.LOG_TAG, String.format("Prior opt-out state is %b, requested opt-out state is %b", Boolean.valueOf(mIsOptedOut), Boolean.valueOf(isOptingOut))); //$NON-NLS-1$
+ }
+
+ // Do nothing if opt-out is unchanged
+ if (mIsOptedOut == isOptingOut)
+ {
+ return;
+ }
+
+ /*
+ * Update the database. Note that there is a possible data loss condition: if the OPT_OUT flag is written in the API
+ * keys table but the process terminates before the opt-out event is written, then the client will stop collecting new
+ * data but the server won't ever receive the opt-out message which will cause data to be deleted. This is not
+ * expected to be likely, and generally still meets user expectations because no new data will be uploaded.
+ */
+ final ContentValues values = new ContentValues();
+ values.put(ApiKeysDbColumns.OPT_OUT, Boolean.valueOf(isOptingOut));
+ mProvider.update(ApiKeysDbColumns.TABLE_NAME, values, String.format("%s = ?", ApiKeysDbColumns._ID), new String[] { Long.toString(mApiKeyId) }); //$NON-NLS-1$
+
+ if (!mIsSessionOpen)
+ {
+ /*
+ * Force a session to contain the opt event
+ */
+ open(true);
+ tagEvent(isOptingOut ? OPT_OUT_EVENT : OPT_IN_EVENT, null);
+ close();
+ }
+ else
+ {
+ tagEvent(isOptingOut ? OPT_OUT_EVENT : OPT_IN_EVENT, null);
+ }
+
+ /*
+ * Update the in-memory representation. It is important for the in-memory representation to be updated after the
+ * on-disk representation, just in case the database update fails.
+ */
+ mIsOptedOut = isOptingOut;
+ }
+
+ /**
+ * Open a session. While this method should only be called once without an intervening call to {@link #close()}, nothing
+ * bad will happen if it is called multiple times.
+ *
+ * This method must only be called after {@link #init()} is called.
+ *
+ * Note: This method is a private implementation detail. It is only made public for unit testing purposes. The public
+ * interface is to send {@link #MESSAGE_OPEN} to the Handler.
+ *
+ * @param ignoreLimits true to ignore limits on the number of sessions. False to enforce limits.
+ * @see #MESSAGE_OPEN
+ */
+ public void open(final boolean ignoreLimits)
+ {
+ if (mIsSessionOpen)
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.w(Constants.LOG_TAG, "Session was already open"); //$NON-NLS-1$
+ }
+
+ return;
+ }
+
+ if (mIsOptedOut)
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.d(Constants.LOG_TAG, "Data collection is opted out"); //$NON-NLS-1$
+ }
+ return;
+ }
+
+ /*
+ * Check that the maximum number of sessions hasn't been exceeded
+ */
+ if (!ignoreLimits)
+ {
+ Cursor cursor = null;
+ try
+ {
+ cursor = mProvider.query(SessionsDbColumns.TABLE_NAME, new String[]
+ { SessionsDbColumns._ID }, null, null, null);
+
+ if (cursor.getCount() >= Constants.MAX_NUM_SESSIONS)
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.w(Constants.LOG_TAG, "Maximum number of sessions are already on disk--not writing any new sessions until old sessions are cleared out. Try calling upload() to store more sessions."); //$NON-NLS-1$
+ }
+ return;
+ }
+ }
+ finally
+ {
+ if (cursor != null)
+ {
+ cursor.close();
+ cursor = null;
+ }
+ }
+ }
+
+ mIsSessionOpen = true;
+
+ /*
+ * There are two cases: 1. New session and 2. Re-connect to old session. The way to test whether reconnecting to an
+ * old session should occur is by the age of the last close event
+ */
+
+ long closeEventId = -1; // sentinel value
+
+ {
+ Cursor eventsCursor = null;
+ Cursor blob_eventsCursor = null;
+ try
+ {
+ eventsCursor = mProvider.query(EventsDbColumns.TABLE_NAME, new String[]
+ {
+ EventsDbColumns._ID,
+ EventsDbColumns.WALL_TIME }, String.format("%s = ?", EventsDbColumns.EVENT_NAME), new String[] { CLOSE_EVENT }, EventsDbColumns._ID); //$NON-NLS-1$
+ blob_eventsCursor = mProvider.query(UploadBlobEventsDbColumns.TABLE_NAME, new String[]
+ { UploadBlobEventsDbColumns.EVENTS_KEY_REF }, null, null, UploadBlobEventsDbColumns.EVENTS_KEY_REF);
+
+ final int idColumn = eventsCursor.getColumnIndexOrThrow(EventsDbColumns._ID);
+ final CursorJoiner joiner = new CursorJoiner(eventsCursor, new String[]
+ { EventsDbColumns._ID }, blob_eventsCursor, new String[]
+ { UploadBlobEventsDbColumns.EVENTS_KEY_REF });
+
+ for (final CursorJoiner.Result joinerResult : joiner)
+ {
+ switch (joinerResult)
+ {
+ case LEFT:
+ {
+ if (System.currentTimeMillis() - eventsCursor.getLong(eventsCursor.getColumnIndexOrThrow(EventsDbColumns.WALL_TIME)) < Constants.SESSION_EXPIRATION)
+ {
+ if (closeEventId != -1)
+ {
+ /*
+ * This should never happen
+ */
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.w(Constants.LOG_TAG, "There were multiple close events within SESSION_EXPIRATION"); //$NON-NLS-1$
+ }
+ }
+ closeEventId = eventsCursor.getLong(idColumn);
+ break;
+ }
+
+ break;
+ }
+ case BOTH:
+ break;
+ case RIGHT:
+ break;
+ }
+ }
+ /*
+ * Verify that the session hasn't already been flagged for upload. That could happen if
+ */
+ }
+ finally
+ {
+ if (eventsCursor != null)
+ {
+ eventsCursor.close();
+ }
+ if (blob_eventsCursor != null)
+ {
+ blob_eventsCursor.close();
+ }
+ }
+ }
+
+ if (closeEventId == -1)
+ {
+ Log.v(Constants.LOG_TAG, "Opening new session"); //$NON-NLS-1$
+ openNewSession();
+ }
+ else
+ {
+ Log.v(Constants.LOG_TAG, "Opening old session and reconnecting"); //$NON-NLS-1$
+ openOldSession(closeEventId);
+ }
+ }
+
+ /**
+ * Opens a new session. This is a helper method to {@link #open(boolean)}.
+ *
+ * @effects Updates the database by creating a new entry in the {@link SessionsDbColumns} table.
+ */
+ private void openNewSession()
+ {
+ // first insert the session
+ {
+
+ 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(VERSION.SDK_INT));
+ values.put(SessionsDbColumns.ANDROID_VERSION, VERSION.RELEASE);
+
+ // Try and get the deviceId. If it is unavailable (or invalid) use the installation ID instead.
+ String deviceId = DatapointHelper.getAndroidIdHashOrNull(mContext);
+ if (deviceId == null)
+ {
+ Cursor cursor = null;
+ try
+ {
+ cursor = mProvider.query(ApiKeysDbColumns.TABLE_NAME, null, String.format("%s = ?", ApiKeysDbColumns.API_KEY), new String[] { mApiKey }, null); //$NON-NLS-1$
+ if (cursor.moveToFirst())
+ {
+ deviceId = cursor.getString(cursor.getColumnIndexOrThrow(ApiKeysDbColumns.UUID));
+ }
+ }
+ finally
+ {
+ if (null != cursor)
+ {
+ cursor.close();
+ cursor = null;
+ }
+ }
+ }
+
+ values.put(SessionsDbColumns.DEVICE_ANDROID_ID_HASH, deviceId);
+ values.put(SessionsDbColumns.DEVICE_COUNTRY, telephonyManager.getSimCountryIso());
+ values.put(SessionsDbColumns.DEVICE_MANUFACTURER, Build.MANUFACTURER);
+ values.put(SessionsDbColumns.DEVICE_MODEL, Build.MODEL);
+ values.put(SessionsDbColumns.DEVICE_SERIAL_NUMBER_HASH, DatapointHelper.getSerialNumberHashOrNull());
+ values.put(SessionsDbColumns.DEVICE_TELEPHONY_ID, DatapointHelper.getTelephonyDeviceIdOrNull(mContext));
+ values.put(SessionsDbColumns.DEVICE_TELEPHONY_ID_HASH, DatapointHelper.getTelephonyDeviceIdHashOrNull(mContext));
+ values.put(SessionsDbColumns.LOCALE_COUNTRY, Locale.getDefault().getCountry());
+ values.put(SessionsDbColumns.LOCALE_LANGUAGE, Locale.getDefault().getLanguage());
+ values.put(SessionsDbColumns.LOCALYTICS_LIBRARY_VERSION, Constants.LOCALYTICS_CLIENT_LIBRARY_VERSION);
+
+ values.putNull(SessionsDbColumns.LATITUDE);
+ values.putNull(SessionsDbColumns.LONGITUDE);
+ values.put(SessionsDbColumns.NETWORK_CARRIER, telephonyManager.getNetworkOperatorName());
+ values.put(SessionsDbColumns.NETWORK_COUNTRY, telephonyManager.getNetworkCountryIso());
+ values.put(SessionsDbColumns.NETWORK_TYPE, DatapointHelper.getNetworkType(mContext, telephonyManager));
+
+ mSessionId = mProvider.insert(SessionsDbColumns.TABLE_NAME, values);
+
+ if (mSessionId == -1)
+ {
+ throw new RuntimeException("session insert failed"); //$NON-NLS-1$
+ }
+ }
+
+ tagEvent(OPEN_EVENT, null);
+
+ /*
+ * This is placed here so that the DatapointHelper has a chance to retrieve the old UUID before it is deleted.
+ */
+ LocalyticsProvider.deleteOldFiles(mContext);
+ }
+
+ /**
+ * Reopens a previous session. This is a helper method to {@link #open(boolean)}.
+ *
+ * @param closeEventId The last close event which is to be deleted so that the old session can be reopened
+ * @effects Updates the database by deleting the last close event and sets {@link #mSessionId} to the session id of the
+ * last close event
+ */
+ private void openOldSession(final long closeEventId)
+ {
+ // reconnect old session
+
+ Cursor eventCursor = null;
+ try
+ {
+ eventCursor = mProvider.query(EventsDbColumns.TABLE_NAME, new String[]
+ { EventsDbColumns.SESSION_KEY_REF }, String.format("%s = ?", EventsDbColumns._ID), new String[] { Long.toString(closeEventId) }, null); //$NON-NLS-1$
+
+ if (eventCursor.moveToFirst())
+ {
+ mSessionId = eventCursor.getLong(eventCursor.getColumnIndexOrThrow(EventsDbColumns.SESSION_KEY_REF));
+
+ mProvider.delete(EventsDbColumns.TABLE_NAME, String.format("%s = ?", EventsDbColumns._ID), new String[] { Long.toString(closeEventId) }); //$NON-NLS-1$
+ }
+ else
+ {
+ /*
+ * This should never happen
+ */
+
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.e(Constants.LOG_TAG, "Event no longer exists"); //$NON-NLS-1$
+ }
+
+ openNewSession();
+ }
+ }
+ finally
+ {
+ if (eventCursor != null)
+ {
+ eventCursor.close();
+ }
+ }
+ }
+
+ /**
+ * Close a session. While this method should only be called after {@link #open(boolean)}, nothing bad will happen if it is
+ * called and {@link #open(boolean)} wasn't called. Similarly, nothing bad will happen if close is called multiple times.
+ *
+ * This method must only be called after {@link #init()} is called.
+ *
+ * Note: This method is a private implementation detail. It is only made public for unit testing purposes. The public
+ * interface is to send {@link #MESSAGE_CLOSE} to the Handler.
+ *
+ * @see #MESSAGE_OPEN
+ */
+ public void close()
+ {
+ if (mIsSessionOpen == false) // do nothing if session is not open
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.w(Constants.LOG_TAG, "Session was not open, so close is not possible."); //$NON-NLS-1$
+ }
+ return;
+ }
+
+ tagEvent(CLOSE_EVENT, null);
+
+ mIsSessionOpen = false;
+ }
+
+ /**
+ * Tag an event in a session. While this method shouldn't be called unless {@link #open(boolean)} is called first, this
+ * method will simply do nothing if {@link #open(boolean)} hasn't been called.
+ *
+ * This method must only be called after {@link #init()} is called.
+ *
+ * Note: This method is a private implementation detail. It is only made public for unit testing purposes. The public
+ * interface is to send {@link #MESSAGE_TAG_EVENT} to the Handler.
+ *
+ * @param event The name of the event which occurred.
+ * @param attributes The collection of attributes for this particular event. If this parameter is null, then calling this
+ * method has the same effect as calling {@link #tagEvent(String)}.
+ * @see #MESSAGE_TAG_EVENT
+ */
+ public void tagEvent(final String event, final Map
+ * This method performs duplicate suppression, preventing multiple screens with the same value in a row within a given
+ * session.
+ *
+ * This method must only be called after {@link #init()} is called.
+ *
+ * Note: This method is a private implementation detail. It is only made public for unit testing purposes. The public
+ * interface is to send {@link #MESSAGE_TAG_SCREEN} to the Handler.
+ *
+ * @param screen The name of the screen which occurred. Cannot be null or empty.
+ * @see #MESSAGE_TAG_SCREEN
+ */
+ /* package */void tagScreen(final String screen)
+ {
+ if (!mIsSessionOpen)
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.w(Constants.LOG_TAG, "Tag not written because the session was not open"); //$NON-NLS-1$
+ }
+ return;
+ }
+
+ /*
+ * Do duplicate suppression
+ */
+ Cursor cursor = null;
+ try
+ {
+ cursor = mProvider.query(EventHistoryDbColumns.TABLE_NAME, new String[]
+ { EventHistoryDbColumns.NAME }, String.format("%s = ? AND %s = ?", EventHistoryDbColumns.TYPE, EventHistoryDbColumns.SESSION_KEY_REF), new String[] { Integer.toString(EventHistoryDbColumns.TYPE_SCREEN), Long.toString(mSessionId) }, String.format("%s DESC", EventHistoryDbColumns._ID)); //$NON-NLS-1$ //$NON-NLS-2$
+
+ if (cursor.moveToFirst())
+ {
+ if (cursor.moveToFirst())
+ {
+ if (screen.equals(cursor.getString(cursor.getColumnIndexOrThrow(EventHistoryDbColumns.NAME))))
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.v(Constants.LOG_TAG, String.format("Suppressed duplicate screen %s", screen)); //$NON-NLS-1$
+ }
+ return;
+ }
+ }
+ }
+ }
+ finally
+ {
+ if (null != cursor)
+ {
+ cursor.close();
+ cursor = null;
+ }
+ }
+
+ /*
+ * Write the screen to the database
+ */
+ final ContentValues values = new ContentValues();
+ values.put(EventHistoryDbColumns.NAME, screen);
+ values.put(EventHistoryDbColumns.TYPE, Integer.valueOf(EventHistoryDbColumns.TYPE_SCREEN));
+ values.put(EventHistoryDbColumns.SESSION_KEY_REF, Long.valueOf(mSessionId));
+ values.putNull(EventHistoryDbColumns.PROCESSED_IN_BLOB);
+ mProvider.insert(EventHistoryDbColumns.TABLE_NAME, values);
+
+ conditionallyAddFlowEvent();
+ }
+
+ /**
+ * Conditionally adds a flow event if no flow event exists in the current upload blob.
+ */
+ /* package */void conditionallyAddFlowEvent()
+ {
+ /*
+ * Creating a flow "event" is required to act as a placeholder so that the uploader will know that an upload needs to
+ * occur. A flow event should only be created if there isn't already a flow event that hasn't been associated with an
+ * upload blob.
+ */
+ boolean foundUnassociatedFlowEvent = false;
+
+ Cursor eventsCursor = null;
+ Cursor blob_eventsCursor = null;
+ try
+ {
+ eventsCursor = mProvider.query(EventsDbColumns.TABLE_NAME, new String[]
+ { EventsDbColumns._ID }, String.format("%s = ?", EventsDbColumns.EVENT_NAME), new String[] { FLOW_EVENT }, EventsDbColumns._ID); //$NON-NLS-1$
+
+ blob_eventsCursor = mProvider.query(UploadBlobEventsDbColumns.TABLE_NAME, new String[]
+ { UploadBlobEventsDbColumns.EVENTS_KEY_REF }, null, null, UploadBlobEventsDbColumns.EVENTS_KEY_REF);
+
+ final CursorJoiner joiner = new CursorJoiner(eventsCursor, new String[]
+ { EventsDbColumns._ID }, blob_eventsCursor, new String[]
+ { UploadBlobEventsDbColumns.EVENTS_KEY_REF });
+ for (final CursorJoiner.Result joinerResult : joiner)
+ {
+ switch (joinerResult)
+ {
+ case LEFT:
+ {
+ foundUnassociatedFlowEvent = true;
+ break;
+ }
+ case BOTH:
+ break;
+ case RIGHT:
+ break;
+ }
+ }
+ }
+ finally
+ {
+ if (eventsCursor != null)
+ {
+ eventsCursor.close();
+ eventsCursor = null;
+ }
+
+ if (blob_eventsCursor != null)
+ {
+ blob_eventsCursor.close();
+ blob_eventsCursor = null;
+ }
+ }
+
+ if (!foundUnassociatedFlowEvent)
+ {
+ tagEvent(FLOW_EVENT, null);
+ }
+ }
+
+ /**
+ * Builds upload blobs for all events.
+ *
+ * @effects Mutates the database by creating a new upload blob for all events that are unassociated at the time this
+ * method is called.
+ */
+ public void preUploadBuildBlobs()
+ {
+ /*
+ * Group all events that aren't part of an upload blob into a new blob. While this process is a linear algorithm that
+ * requires scanning two database tables, the performance won't be a problem for two reasons: 1. This process happens
+ * frequently so the number of events to group will always be low. 2. There is a maximum number of events, keeping the
+ * overall size low. Note that close events that are younger than SESSION_EXPIRATION will be skipped to allow session
+ * reconnects.
+ */
+
+ // temporary set of event ids that aren't in a blob
+ final Set
+ * This method must only be called after {@link #init()} is called. The session does not need to be open for an upload to
+ * occur.
+ *
+ * Note: This method is a private implementation detail. It is only made public 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
+ */
+ public void upload(final Runnable callback)
+ {
+ if (sIsUploadingMap.get(mApiKey).booleanValue())
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.d(Constants.LOG_TAG, "Already uploading"); //$NON-NLS-1$
+ }
+
+ mUploadHandler.sendMessage(mUploadHandler.obtainMessage(UploadHandler.MESSAGE_RETRY_UPLOAD_REQUEST, callback));
+ return;
+ }
+
+ try
+ {
+ mProvider.runBatchTransaction(new Runnable()
+ {
+ @Override
+ public void run()
+ {
+ preUploadBuildBlobs();
+ }
+ });
+
+ sIsUploadingMap.put(mApiKey, Boolean.TRUE);
+ mUploadHandler.sendMessage(mUploadHandler.obtainMessage(UploadHandler.MESSAGE_UPLOAD, callback));
+ }
+ catch (final Exception e)
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.w(Constants.LOG_TAG, "Error occurred during upload", e); //$NON-NLS-1$
+ }
+
+ sIsUploadingMap.put(mApiKey, Boolean.FALSE);
+
+ // Notify the caller the upload is "complete"
+ if (callback != null)
+ {
+ /*
+ * Note that a new thread is created for the callback. This ensures that client code can't affect the
+ * performance of the SessionHandler's thread.
+ */
+ new Thread(callback).start();
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper object to the {@link SessionHandler} which helps process upload requests.
+ */
+ /* package */static final class UploadHandler extends Handler
+ {
+
+ /**
+ * Localytics upload URL, as a format string that contains a format for the API key.
+ */
+ private final static String ANALYTICS_URL = "http://analytics.localytics.com/api/v2/applications/%s/uploads"; //$NON-NLS-1$
+
+ /**
+ * Handler message to upload all data collected so far
+ *
+ * {@link Message#obj} is a {@code Runnable} to execute when upload is complete. The thread that this runnable will
+ * executed on is undefined.
+ */
+ public static final int MESSAGE_UPLOAD = 1;
+
+ /**
+ * Handler message indicating that there is a queued upload request. When this message is processed, this handler simply
+ * forwards the request back to {@link LocalyticsSession#mSessionHandler} with {@link SessionHandler#MESSAGE_UPLOAD}.
+ *
+ * {@link Message#obj} is a {@code Runnable} to execute when upload is complete. The thread that this runnable will
+ * executed on is undefined.
+ */
+ public static final int MESSAGE_RETRY_UPLOAD_REQUEST = 2;
+
+ /**
+ * Reference to the Localytics database
+ */
+ private final LocalyticsProvider mProvider;
+
+ /**
+ * Application context
+ */
+ private final Context mContext;
+
+ /**
+ * The Localytics API key
+ */
+ private final String mApiKey;
+
+ /**
+ * Parent session handler to notify when an upload completes.
+ */
+ private final Handler mSessionHandler;
+
+ /**
+ * Constructs a new Handler that runs on {@code looper}.
+ *
+ * Note: This constructor may perform disk access.
+ *
+ * @param context Application context. Cannot be null.
+ * @param sessionHandler Parent {@link SessionHandler} object to notify when uploads are completed. Cannot be null.
+ * @param apiKey Localytics API key. Cannot be null.
+ * @param looper to run the Handler on. Cannot be null.
+ */
+ public UploadHandler(final Context context, final Handler sessionHandler, final String apiKey, final Looper looper)
+ {
+ super(looper);
+
+ mContext = context;
+ mProvider = LocalyticsProvider.getInstance(context, apiKey);
+ mSessionHandler = sessionHandler;
+ mApiKey = apiKey;
+ }
+
+ @Override
+ public void handleMessage(final Message msg)
+ {
+ super.handleMessage(msg);
+
+ switch (msg.what)
+ {
+ case MESSAGE_UPLOAD:
+ {
+ if (Constants.IS_LOGGABLE)
+ {
+ Log.d(Constants.LOG_TAG, "UploadHandler Received MESSAGE_UPLOAD"); //$NON-NLS-1$
+ }
+
+ /*
+ * Note that callback may be null
+ */
+ final Runnable callback = (Runnable) msg.obj;
+
+ try
+ {
+ final List
+ * This should be called after a successful upload completes.
+ *
+ * @param provider Localytics database provider. Cannot be null.
+ */
+ /* package */static void deleteBlobsAndSessions(final LocalyticsProvider provider)
{
- // Create the session close blob
- StringBuffer optString = new StringBuffer();
- optString.append(DatapointHelper.CONTROLLER_OPT);
- optString.append(DatapointHelper.ACTION_OPTIN);
- optString.append(DatapointHelper.OBJECT_OPT);
+ /*
+ * Deletion needs to occur in a specific order due to database constraints. Specifically, blobevents need to be
+ * deleted first. Then blobs themselves can be deleted. Then attributes need to be deleted first. Then events. Then
+ * sessions.
+ */
+
+ final LinkedList
+ * There are three types of events: open, close, and application. Open and close events are Localytics events, while
+ * application events are generated by the app. The return value of this method will vary based on the type of event that
+ * is being converted.
+ *
+ * @param provider Localytics database instance. Cannot be null.
+ * @param context Application context. Cannot be null.
+ * @param eventId {@link EventsDbColumns#_ID} of the event to convert.
+ * @param blobId {@link UploadBlobEventsDbColumns#_ID} of the upload blob that contains this event.
+ * @param apiKey the Localytics API key. Cannot be null.
+ * @return JSON representation of the event.
+ * @throws JSONException if a problem occurred converting the element to JSON.
+ */
+ /* package */static JSONObject convertEventToJson(final LocalyticsProvider provider, final Context context, final long eventId, final long blobId, final String apiKey)
+ throws JSONException
+ {
+ final JSONObject result = new JSONObject();
+
+ Cursor cursor = null;
+
try
{
- BufferedReader reader = new BufferedReader(new FileReader(fp), bufferSize);
- String storedId = reader.readLine();
- String timeStamp = reader.readLine();
- reader.close();
+ cursor = provider.query(EventsDbColumns.TABLE_NAME, null, String.format("%s = ?", EventsDbColumns._ID), new String[] //$NON-NLS-1$
+ { Long.toString(eventId) }, EventsDbColumns._ID);
- if(timeStamp != null)
+ if (cursor.moveToFirst())
{
- // Check if the session happened recently enough
- long timeSinceSession = System.currentTimeMillis() - Long.parseLong(timeStamp);
- if(SESSION_EXPIRATION > timeSinceSession)
+ final String eventName = cursor.getString(cursor.getColumnIndexOrThrow(EventsDbColumns.EVENT_NAME));
+ final long sessionId = getSessionIdForEventId(provider, eventId);
+ final String sessionUuid = getSessionUuid(provider, sessionId);
+ final long sessionStartTime = getSessionStartTime(provider, sessionId);
+
+ if (OPEN_EVENT.equals(eventName))
+ {
+ result.put(JsonObjects.SessionOpen.KEY_DATA_TYPE, JsonObjects.SessionOpen.VALUE_DATA_TYPE);
+ result.put(JsonObjects.SessionOpen.KEY_WALL_TIME_SECONDS, Math.round((double) cursor.getLong(cursor.getColumnIndex(EventsDbColumns.WALL_TIME))
+ / DateUtils.SECOND_IN_MILLIS));
+ result.put(JsonObjects.SessionOpen.KEY_EVENT_UUID, sessionUuid);
+
+ /*
+ * Both the database and the web service use 1-based indexing.
+ */
+ result.put(JsonObjects.SessionOpen.KEY_COUNT, sessionId);
+ }
+ else if (CLOSE_EVENT.equals(eventName))
+ {
+ result.put(JsonObjects.SessionClose.KEY_DATA_TYPE, JsonObjects.SessionClose.VALUE_DATA_TYPE);
+ result.put(JsonObjects.SessionClose.KEY_EVENT_UUID, cursor.getString(cursor.getColumnIndexOrThrow(EventsDbColumns.UUID)));
+ result.put(JsonObjects.SessionClose.KEY_SESSION_UUID, sessionUuid);
+ result.put(JsonObjects.SessionClose.KEY_SESSION_START_TIME, Math.round((double) sessionStartTime / DateUtils.SECOND_IN_MILLIS));
+ result.put(JsonObjects.SessionClose.KEY_WALL_TIME_SECONDS, Math.round((double) cursor.getLong(cursor.getColumnIndex(EventsDbColumns.WALL_TIME))
+ / DateUtils.SECOND_IN_MILLIS));
+
+ /*
+ * length is a special case, as it depends on the start time embedded in the session table
+ */
+ Cursor sessionCursor = null;
+ try
+ {
+ sessionCursor = provider.query(SessionsDbColumns.TABLE_NAME, new String[]
+ { SessionsDbColumns.SESSION_START_WALL_TIME }, String.format("%s = ?", SessionsDbColumns._ID), new String[] { Long.toString(cursor.getLong(cursor.getColumnIndexOrThrow(EventsDbColumns.SESSION_KEY_REF))) }, null); //$NON-NLS-1$
+
+ if (sessionCursor.moveToFirst())
+ {
+ result.put(JsonObjects.SessionClose.KEY_SESSION_LENGTH_SECONDS, Math.round((double) cursor.getLong(cursor.getColumnIndex(EventsDbColumns.WALL_TIME))
+ / DateUtils.SECOND_IN_MILLIS)
+ - Math.round((double) sessionCursor.getLong(sessionCursor.getColumnIndexOrThrow(SessionsDbColumns.SESSION_START_WALL_TIME))
+ / DateUtils.SECOND_IN_MILLIS));
+ }
+ else
+ {
+ // this should never happen
+ throw new RuntimeException("Session didn't exist"); //$NON-NLS-1$
+ }
+ }
+ finally
+ {
+ if (null != sessionCursor)
+ {
+ sessionCursor.close();
+ sessionCursor = null;
+ }
+ }
+
+ /*
+ * The close also contains a special case element for the screens history
+ */
+ Cursor eventHistoryCursor = null;
+ try
+ {
+ eventHistoryCursor = provider.query(EventHistoryDbColumns.TABLE_NAME, new String[]
+ { EventHistoryDbColumns.NAME }, String.format("%s = ? AND %s = ?", EventHistoryDbColumns.SESSION_KEY_REF, EventHistoryDbColumns.TYPE), new String[] { Long.toString(sessionId), Integer.toString(EventHistoryDbColumns.TYPE_SCREEN) }, EventHistoryDbColumns._ID); //$NON-NLS-1$
+
+ final JSONArray screens = new JSONArray();
+ while (eventHistoryCursor.moveToNext())
+ {
+ screens.put(eventHistoryCursor.getString(eventHistoryCursor.getColumnIndexOrThrow(EventHistoryDbColumns.NAME)));
+ }
+
+ if (screens.length() > 0)
+ {
+ result.put(JsonObjects.SessionClose.KEY_FLOW_ARRAY, screens);
+ }
+ }
+ finally
+ {
+ if (null != eventHistoryCursor)
+ {
+ eventHistoryCursor.close();
+ eventHistoryCursor = null;
+ }
+ }
+ }
+ else if (OPT_IN_EVENT.equals(eventName) || OPT_OUT_EVENT.equals(eventName))
+ {
+ result.put(JsonObjects.OptEvent.KEY_DATA_TYPE, JsonObjects.OptEvent.VALUE_DATA_TYPE);
+ result.put(JsonObjects.OptEvent.KEY_API_KEY, apiKey);
+ result.put(JsonObjects.OptEvent.KEY_OPT, OPT_OUT_EVENT.equals(eventName) ? Boolean.TRUE.toString() : Boolean.FALSE.toString());
+ result.put(JsonObjects.OptEvent.KEY_WALL_TIME_SECONDS, Math.round((double) cursor.getLong(cursor.getColumnIndex(EventsDbColumns.WALL_TIME))
+ / DateUtils.SECOND_IN_MILLIS));
+ }
+ else if (FLOW_EVENT.equals(eventName))
{
- return storedId;
+ result.put(JsonObjects.EventFlow.KEY_DATA_TYPE, JsonObjects.EventFlow.VALUE_DATA_TYPE);
+ result.put(JsonObjects.EventFlow.KEY_EVENT_UUID, cursor.getString(cursor.getColumnIndexOrThrow(EventsDbColumns.UUID)));
+ result.put(JsonObjects.EventFlow.KEY_SESSION_START_TIME, Math.round((double) sessionStartTime / DateUtils.SECOND_IN_MILLIS));
+
+ /*
+ * Need to generate two objects: the old flow events and the new flow events
+ */
+
+ /*
+ * Default sort order is ascending by _ID, so these will be sorted chronologically.
+ */
+ Cursor eventHistoryCursor = null;
+ try
+ {
+ eventHistoryCursor = provider.query(EventHistoryDbColumns.TABLE_NAME, new String[]
+ {
+ EventHistoryDbColumns.TYPE,
+ EventHistoryDbColumns.PROCESSED_IN_BLOB,
+ EventHistoryDbColumns.NAME }, String.format("%s = ? AND %s <= ?", EventHistoryDbColumns.SESSION_KEY_REF, EventHistoryDbColumns.PROCESSED_IN_BLOB), new String[] { Long.toString(sessionId), Long.toString(blobId) }, EventHistoryDbColumns._ID); //$NON-NLS-1$
+
+ final JSONArray newScreens = new JSONArray();
+ final JSONArray oldScreens = new JSONArray();
+ while (eventHistoryCursor.moveToNext())
+ {
+ final String name = eventHistoryCursor.getString(eventHistoryCursor.getColumnIndexOrThrow(EventHistoryDbColumns.NAME));
+ final String type;
+ if (EventHistoryDbColumns.TYPE_EVENT == eventHistoryCursor.getInt(eventHistoryCursor.getColumnIndexOrThrow(EventHistoryDbColumns.TYPE)))
+ {
+ type = JsonObjects.EventFlow.Element.TYPE_EVENT;
+ }
+ else
+ {
+ type = JsonObjects.EventFlow.Element.TYPE_SCREEN;
+ }
+
+ if (blobId == eventHistoryCursor.getLong(eventHistoryCursor.getColumnIndexOrThrow(EventHistoryDbColumns.PROCESSED_IN_BLOB)))
+ {
+ newScreens.put(new JSONObject().put(type, name));
+ }
+ else
+ {
+ oldScreens.put(new JSONObject().put(type, name));
+ }
+ }
+
+ result.put(JsonObjects.EventFlow.KEY_FLOW_NEW, newScreens);
+ result.put(JsonObjects.EventFlow.KEY_FLOW_OLD, oldScreens);
+ }
+ finally
+ {
+ if (null != eventHistoryCursor)
+ {
+ eventHistoryCursor.close();
+ eventHistoryCursor = null;
+ }
+ }
+ }
+ else
+ {
+ /*
+ * This is a normal application event
+ */
+
+ result.put(JsonObjects.SessionEvent.KEY_DATA_TYPE, JsonObjects.SessionEvent.VALUE_DATA_TYPE);
+ result.put(JsonObjects.SessionEvent.KEY_WALL_TIME_SECONDS, Math.round((double) cursor.getLong(cursor.getColumnIndex(EventsDbColumns.WALL_TIME))
+ / DateUtils.SECOND_IN_MILLIS));
+ result.put(JsonObjects.SessionEvent.KEY_EVENT_UUID, cursor.getString(cursor.getColumnIndexOrThrow(EventsDbColumns.UUID)));
+ result.put(JsonObjects.SessionEvent.KEY_SESSION_UUID, sessionUuid);
+ result.put(JsonObjects.SessionEvent.KEY_NAME, eventName.substring(context.getPackageName().length() + 1, eventName.length()));
+
+ final JSONObject attributes = convertAttributesToJson(provider, eventId);
+
+ if (null != attributes)
+ {
+ result.put(JsonObjects.SessionEvent.KEY_ATTRIBUTES, attributes);
+ }
}
}
+ else
+ {
+ /*
+ * This should never happen
+ */
+ throw new RuntimeException();
+ }
+ }
+ finally
+ {
+ if (null != cursor)
+ {
+ cursor.close();
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Private helper to get the {@link SessionsDbColumns#_ID} for a given {@link EventsDbColumns#_ID}.
+ *
+ * @param provider Localytics database instance. Cannot be null.
+ * @param eventId {@link EventsDbColumns#_ID} of the event to look up
+ * @return The {@link SessionsDbColumns#_ID} of the session that owns the event.
+ */
+ /* package */static long getSessionIdForEventId(final LocalyticsProvider provider, final long eventId)
+ {
+ Cursor cursor = null;
+ try
+ {
+ cursor = provider.query(EventsDbColumns.TABLE_NAME, new String[]
+ { EventsDbColumns.SESSION_KEY_REF }, String.format("%s = ?", EventsDbColumns._ID), new String[] { Long.toString(eventId) }, null); //$NON-NLS-1$
+
+ if (cursor.moveToFirst())
+ {
+ return cursor.getLong(cursor.getColumnIndexOrThrow(EventsDbColumns.SESSION_KEY_REF));
+ }
+
+ /*
+ * This should never happen
+ */
+ throw new RuntimeException();
+ }
+ finally
+ {
+ if (null != cursor)
+ {
+ cursor.close();
+ }
+ }
+ }
+
+ /**
+ * Private helper to get the {@link SessionsDbColumns#UUID} for a given {@link SessionsDbColumns#_ID}.
+ *
+ * @param provider Localytics database instance. Cannot be null.
+ * @param sessionId {@link SessionsDbColumns#_ID} of the event to look up
+ * @return The {@link SessionsDbColumns#UUID} of the session.
+ */
+ /* package */static String getSessionUuid(final LocalyticsProvider provider, final long sessionId)
+ {
+ Cursor cursor = null;
+ try
+ {
+ cursor = provider.query(SessionsDbColumns.TABLE_NAME, new String[]
+ { SessionsDbColumns.UUID }, String.format("%s = ?", SessionsDbColumns._ID), new String[] { Long.toString(sessionId) }, null); //$NON-NLS-1$
+
+ if (cursor.moveToFirst())
+ {
+ return cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.UUID));
+ }
+
+ /*
+ * This should never happen
+ */
+ throw new RuntimeException();
+ }
+ finally
+ {
+ if (null != cursor)
+ {
+ cursor.close();
+ }
+ }
+ }
+
+ /**
+ * Private helper to get the {@link SessionsDbColumns#SESSION_START_WALL_TIME} for a given {@link SessionsDbColumns#_ID}.
+ *
+ * @param provider Localytics database instance. Cannot be null.
+ * @param sessionId {@link SessionsDbColumns#_ID} of the event to look up
+ * @return The {@link SessionsDbColumns#SESSION_START_WALL_TIME} of the session.
+ */
+ /* package */static long getSessionStartTime(final LocalyticsProvider provider, final long sessionId)
+ {
+ Cursor cursor = null;
+ try
+ {
+ cursor = provider.query(SessionsDbColumns.TABLE_NAME, new String[]
+ { SessionsDbColumns.SESSION_START_WALL_TIME }, String.format("%s = ?", SessionsDbColumns._ID), new String[] { Long.toString(sessionId) }, null); //$NON-NLS-1$
+
+ if (cursor.moveToFirst())
+ {
+ return cursor.getLong(cursor.getColumnIndexOrThrow(SessionsDbColumns.SESSION_START_WALL_TIME));
+ }
+
+ /*
+ * This should never happen
+ */
+ throw new RuntimeException();
+ }
+ finally
+ {
+ if (null != cursor)
+ {
+ cursor.close();
+ }
+ }
+ }
+
+ /**
+ * Private helper to convert an event's attributes into a {@link JSONObject} representation.
+ *
+ * @param provider Localytics database instance. Cannot be null.
+ * @param eventId {@link EventsDbColumns#_ID} of the event whose attributes are to be loaded.
+ * @return {@link JSONObject} representing the attributes of the event. The order of attributes is undefined and may
+ * change from call to call of this method. If the event has no attributes, returns null.
+ * @throws JSONException if an error occurs converting the attributes to JSON
+ */
+ /* package */static JSONObject convertAttributesToJson(final LocalyticsProvider provider, final long eventId) throws JSONException
+ {
+ Cursor cursor = null;
+ try
+ {
+ cursor = provider.query(AttributesDbColumns.TABLE_NAME, null, String.format("%s = ?", AttributesDbColumns.EVENTS_KEY_REF), new String[] { Long.toString(eventId) }, null); //$NON-NLS-1$
+
+ if (cursor.getCount() == 0)
+ {
+ return null;
+ }
+
+ final JSONObject attributes = new JSONObject();
+
+ final int keyColumn = cursor.getColumnIndexOrThrow(AttributesDbColumns.ATTRIBUTE_KEY);
+ final int valueColumn = cursor.getColumnIndexOrThrow(AttributesDbColumns.ATTRIBUTE_VALUE);
+ while (cursor.moveToNext())
+ {
+ attributes.put(cursor.getString(keyColumn), cursor.getString(valueColumn));
+ }
+
+ return attributes;
+ }
+ finally
+ {
+ if (null != cursor)
+ {
+ cursor.close();
+ }
}
- catch (FileNotFoundException e)
+ }
+
+ /**
+ * Given an id of an upload blob, get the session id associated with that blob.
+ *
+ * @param blobId {@link UploadBlobsDbColumns#_ID} of the upload blob.
+ * @return id of the parent session.
+ */
+ /* package */long getSessionIdForBlobId(final long blobId)
+ {
+ /*
+ * This implementation needs to walk up the tree of database elements.
+ */
+
+ long eventId;
{
- Log.v(LocalyticsSession.LOG_TAG, "File Not Found opening stored session");
- return null;
+ Cursor cursor = null;
+ try
+ {
+ cursor = mProvider.query(UploadBlobEventsDbColumns.TABLE_NAME, new String[]
+ { UploadBlobEventsDbColumns.EVENTS_KEY_REF }, String.format("%s = ?", UploadBlobEventsDbColumns.UPLOAD_BLOBS_KEY_REF), new String[] //$NON-NLS-1$
+ { Long.toString(blobId) }, null);
+
+ if (cursor.moveToFirst())
+ {
+ eventId = cursor.getLong(cursor.getColumnIndexOrThrow(UploadBlobEventsDbColumns.EVENTS_KEY_REF));
+ }
+ else
+ {
+ /*
+ * This should never happen
+ */
+ throw new RuntimeException("No events associated with blob"); //$NON-NLS-1$
+ }
+ }
+ finally
+ {
+ if (null != cursor)
+ {
+ cursor.close();
+ }
+ }
}
- catch (IOException e)
+
+ long sessionId;
{
- Log.v(LocalyticsSession.LOG_TAG, "IO Exception getting stored session: " + e.getMessage());
- return null;
+ Cursor cursor = null;
+ try
+ {
+ cursor = mProvider.query(EventsDbColumns.TABLE_NAME, new String[]
+ { EventsDbColumns.SESSION_KEY_REF }, String.format("%s = ?", EventsDbColumns._ID), new String[] //$NON-NLS-1$
+ { Long.toString(eventId) }, null);
+
+ if (cursor.moveToFirst())
+ {
+ sessionId = cursor.getLong(cursor.getColumnIndexOrThrow(EventsDbColumns.SESSION_KEY_REF));
+ }
+ else
+ {
+ /*
+ * This should never happen
+ */
+ throw new RuntimeException("No session associated with event"); //$NON-NLS-1$
+ }
+ }
+ finally
+ {
+ if (null != cursor)
+ {
+ cursor.close();
+ }
+ }
}
+
+ return sessionId;
}
- return null;
}
/**
- * Runnable which gets passed to the uploader thread so it can
- * notify the library when uploads are complete.
+ * Internal helper class to pass two objects to the Handler via the {@link Message#obj}.
*/
- private final Runnable uploadComplete = new Runnable()
+ /*
+ * Once support for Android 1.6 is dropped, using Android's built-in Pair class would be preferable
+ */
+ private static final class Pair
+ * This is not a general-purpose reflection class but is rather specifically designed for calling methods that must exist in newer
+ * versions of Android.
+ */
+public final class ReflectionUtils
+{
+ /**
+ * Private constructor prevents instantiation
+ *
+ * @throws UnsupportedOperationException because this class cannot be instantiated.
+ */
+ private ReflectionUtils()
+ {
+ throw new UnsupportedOperationException("This class is non-instantiable"); //$NON-NLS-1$
+ }
+
+ /**
+ * Use reflection to invoke a static method for a class object and method name
+ *
+ * @param
*
*
+ *
+ * Permissions recommended:
*
- *
- *
+ *
- *
- * @author Localytics
- * @version 1.5
+ * tagEvent should follow user actions. This limits the
- * amount of data which is stored and uploaded.getApplicationContext to avoid the potential
- * memory leak incurred by maintaining references to activities.
- * @param applicationKey The key unique for each application generated
- * at www.localytics.com
+ * Format string for events
*/
- public LocalyticsSession(final Context appContext, final String applicationKey)
- {
- this._appContext = appContext;
- this._applicationKey = applicationKey;
-
- // Put each application key's files inside a different directory. This
- // makes it possible to have multiple app keys inside a single application.
- // However, this is not a recommended practice!
- this._localyticsDirPath = appContext.getFilesDir() + "/"
- + LocalyticsSession.LOCALYTICS_DIR + "/"
- + this._applicationKey + "/";
-
- // All Localytics API calls are wrapped in try / catch blobs which swallow
- // all exceptions. This way if there is a problem with the library the
- // integrating application does not crash.
- try
- {
- // If there is an opt-out file, everything is opted out.
- File optOutFile = new File(this._localyticsDirPath + LocalyticsSession.OPTOUT_FILNAME);
- if(optOutFile.exists())
- {
- LocalyticsSession._isOptedIn = false;
- return;
- }
+ /* package */static final String EVENT_FORMAT = "%s:%s"; //$NON-NLS-1$
- // Otherwise, everything is opted in.
- LocalyticsSession._isOptedIn = true;
- }
- catch (Exception e)
- {
- Log.v(LocalyticsSession.LOG_TAG, "Swallowing exception: " + e.getMessage());
- }
- }
+ /**
+ * Open event
+ */
+ /* package */static final String OPEN_EVENT = String.format(EVENT_FORMAT, Constants.LOCALYTICS_PACKAGE_NAME, "open"); //$NON-NLS-1$
/**
- * Sets the Localytics Optin 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 false 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.
- *
- * There are very serious implications to the quality of your data when
- * providing an opt out option. For example, users who have opted out will appear
- * as never returning, causing your new/returning chart to skew.
- *
- * If two instances of the same application are running, and one
- * is opted in and the second opts out, the first will also become opted
- * out, and neither will collect any more data.
- *
- * If a session was started while the app was opted out, the session open
- * event has already been lost. For this reason, all sessions started
- * while opted out will not collect data even after the user opts back in
- * or else it taints the comparisons of session lengths and other metrics.
- * @param optedIn True if the user wishes to be opted in, false if they
- * wish to be opted out and have all their Localytics data deleted.
+ * Close event
*/
- public void setOptIn(final boolean optedIn)
- {
- try
- {
- // Do nothing if optin is unchanged
- if(optedIn == LocalyticsSession._isOptedIn)
- {
- return;
- }
+ /* package */static final String CLOSE_EVENT = String.format(EVENT_FORMAT, Constants.LOCALYTICS_PACKAGE_NAME, "close"); //$NON-NLS-1$
- LocalyticsSession._isOptedIn = optedIn;
- File fp;
+ /**
+ * Opt-in event
+ */
+ /* package */static final String OPT_IN_EVENT = String.format(EVENT_FORMAT, Constants.LOCALYTICS_PACKAGE_NAME, "opt_in"); //$NON-NLS-1$
- if(optedIn == true)
- {
- // To opt in, delete the opt out file if it exists.
- fp = new File(this._localyticsDirPath + LocalyticsSession.OPTOUT_FILNAME);
- fp.delete();
+ /**
+ * Opt-out event
+ */
+ /* package */static final String OPT_OUT_EVENT = String.format(EVENT_FORMAT, Constants.LOCALYTICS_PACKAGE_NAME, "opt_out"); //$NON-NLS-1$
- createOptEvent(true);
- }
- else
- {
- // Create the opt-out file. If it can't be written this is fine because
- // it means session files can't be written either so the user is effectively opted out.
- fp = new File(this._localyticsDirPath);
- fp.mkdirs();
- fp = new File(this._localyticsDirPath + LocalyticsSession.OPTOUT_FILNAME);
- try
- {
- fp.createNewFile();
- }
- catch (IOException e) { /**/ }
+ /**
+ * Flow event
+ */
+ /* package */static final String FLOW_EVENT = String.format(EVENT_FORMAT, Constants.LOCALYTICS_PACKAGE_NAME, "flow"); //$NON-NLS-1$
- createOptEvent(false);
- }
- }
- catch (Exception e)
- {
- Log.v(LocalyticsSession.LOG_TAG, "Swallowing exception: " + e.getMessage());
- }
- }
+ /**
+ * 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());
/**
- * Checks whether or not this session is opted in.
- * It is not recommended that an application branch on analytics code
- * because this adds an unnecessary testing burden to the developer.
- * However, this function is provided for developers who wish to
- * pre-populate check boxes in settings menus.
- * @return True if the user is opted in, false otherwise.
+ * Background thread used for all Localytics upload processing. This thread is shared across all instances of
+ * LocalyticsSession within a process.
*/
- public boolean isOptedIn()
- {
- return LocalyticsSession._isOptedIn;
- }
+ /*
+ * By using the class name for the HandlerThread, obfuscation through Proguard is more effective: if Proguard changes the
+ * class name, the thread name also changes.
+ */
+ private static final HandlerThread sUploadHandlerThread = getHandlerThread(UploadHandler.class.getSimpleName());
/**
- * Opens the Localytics session. The session time as presented on the
- * website is the time between the first open and the final close
- * so it is recommended to open the session as early as possible, and close
- * it at the last moment. The session must be opened before any tags can
- * be written. It is recommended that this call be placed in onCreate.
- *
- * If for any reason this is called more than once every subsequent open call
- * will be ignored.
- *
- * For applications with multiple activites, every activity should call open
- * in onCreate. This will cause each activity to reconnect to the currently
- * running session.
+ * 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.
*/
- public void open()
+ private static HandlerThread getHandlerThread(final String name)
{
- // Allow only one open call to happen.
- synchronized(LocalyticsSession.class)
- {
- if(LocalyticsSession._isOptedIn == false || // do nothing if opted out
- this._isSessionOpen == true) // do nothing if already open
- {
- Log.v(LocalyticsSession.LOG_TAG, "Session not opened");
- return;
- }
+ final HandlerThread thread = new HandlerThread(name, android.os.Process.THREAD_PRIORITY_BACKGROUND);
- this._isSessionOpen = true;
- }
+ thread.start();
- try
- {
- // Check if this session was closed within the last 15 seconds. If so, reattach
- // to that session rather than open a new one. This will be the case whenever
- // a new activity is loaded, or the current one is redrawn. Otherwise, create
- // a new session
- this._sessionUUID = getOldSessionUUId();
- if(this._sessionUUID != null)
- {
- this._sessionFilename = LocalyticsSession.SESSION_FILE_PREFIX + this._sessionUUID;
- this._closeFilename = LocalyticsSession.CLOSE_FILE_PREFIX + this._sessionUUID;;
- Log.v(LocalyticsSession.LOG_TAG, "Reconnected to existing session");
- }
- else
- {
- // if there are too many files on the disk already, return w/o doing anything.
- // All other session calls, such as tagEvent and close will return because isSessionOpen == false
- File fp = new File(this._localyticsDirPath);
- if(fp.exists())
- {
- // Get a list of all the session files.
- FilenameFilter filter = new FilenameFilter()
- {
- public boolean accept(File dir, String name)
- {
- // accept any session or uploader files, but ignore the close files b/c they are tiny.
- return (name.startsWith(LocalyticsSession.SESSION_FILE_PREFIX)
- || name.startsWith(LocalyticsSession.UPLOADER_FILE_PREFIX));
- }
- };
+ /*
+ * The exception handler needs to be set after start() is called. If it is set before, sometime's the HandlerThread's
+ * looper is null. This appears to be a bug in Android.
+ */
+ thread.setUncaughtExceptionHandler(new ExceptionHandler());
- // If that list is larger than the max number, don't create a new session.
- if( fp.list(filter).length >= LocalyticsSession.MAX_NUM_SESSIONS)
- {
- this._isSessionOpen = false;
- Log.v(LocalyticsSession.LOG_TAG, "Queue full, session not created");
- return;
- }
- }
+ return thread;
+ }
- // Otherwise, prepare the session.
- this._sessionUUID = UUID.randomUUID().toString();
- this._sessionFilename = LocalyticsSession.SESSION_FILE_PREFIX + this._sessionUUID;
- this._closeFilename = LocalyticsSession.CLOSE_FILE_PREFIX + this._sessionUUID;;
+ /**
+ * Handler object where all session requests of this instance of LocalyticsSession are handed off to.
+ * onPause which is the recommended location). This is
- * fine because only the last close is processed by the server.
- *
- * Closing does not cause the session to stop collecting data. This is a result
- * of the application life cycle. It is possible for onPause to be called long
- * before the application is actually ready to close the session.
+ * 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 false 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.
+ * There are very serious implications to the quality of your data when providing an opt out option. For example, users who
+ * have opted out will appear as never returning, causing your new/returning chart to skew.
+ * If two instances of the same application are running, and one is opted in and the second opts out, the first will also
+ * become opted out, and neither will collect any more data.
+ * If a session was started while the app was opted out, the session open event has already been lost. For this reason, all
+ * sessions started while opted out will not collect data even after the user opts back in or else it taints the comparisons
+ * of session lengths and other metrics.
+ *
+ * @param isOptedOut True if the user should be be opted out and have all his Localytics data deleted.
+ */
+ public void setOptOut(final boolean isOptedOut)
+ {
+ mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_OPT_OUT, isOptedOut ? 1 : 0, 0));
+ }
+
+ /**
+ * Opens the Localytics session. The session time as presented on the website is the time between the first open
+ * and the final close so it is recommended to open the session as early as possible, and close it at the last
+ * moment. The session must be opened before {@link #tagEvent(String)} or {@link #tagEvent(String, Map)} can be called, so
+ * this call should be placed in {@code Activity#onCreate(Bundle)}.
+ * open in onCreate. This will
+ * cause each Activity to reconnect to the currently running session.
+ */
+ public void open()
+ {
+ mSessionHandler.sendEmptyMessage(SessionHandler.MESSAGE_OPEN);
+ }
+
+ /**
+ * Closes the Localytics session. This should be done when the application or activity is ending. Because of the way the
+ * Android lifecycle works, this call could end up in a place which gets called multiple times (such as onPause
+ * which is the recommended location). This is fine because only the last close is processed by the server.
+ * Closing does not cause the session to stop collecting data. This is a result of the application life cycle. It is possible
+ * for onPause to be called long before the application is actually ready to close the session.
*/
public void close()
{
- if(LocalyticsSession._isOptedIn == false || // do nothing if opted out
- this._isSessionOpen == false) // do nothing if session is not open
- {
- Log.v(LocalyticsSession.LOG_TAG, "Session not closed.");
- return;
- }
-
- try
- {
- // Create the session close blob
- StringBuffer closeString = new StringBuffer();
- closeString.append(DatapointHelper.CONTROLLER_SESSION);
- closeString.append(DatapointHelper.ACTION_UPDATE);
- closeString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_UUID,
- this._sessionUUID,
- 2));
- closeString.append(DatapointHelper.OBJECT_SESSION_DP);
- closeString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_APP_UUID,
- this._applicationKey,
- 3));
- closeString.append(DatapointHelper.formatYAMLLine(
- DatapointHelper.PARAM_CLIENT_CLOSED_TIME,
- DatapointHelper.getTimeAsDatetime(),
- 3));
-
- // Overwrite the existing close event with the new one
- File fp = getOrCreateFileWithDefaultPath(this._closeFilename);
- overwriteFile(fp, closeString.toString());
-
- // Write this session id to disk along with a timestamp. This is used to
- // determine whether a session is reconnnecting to an existing session or
- // being created fresh.
- fp = getOrCreateFileWithDefaultPath(LocalyticsSession.SESSION_ID_FILENAME);
- overwriteFile(fp,
- this._sessionUUID + "\n" + Long.toString(System.currentTimeMillis()));
-
- Log.v(LocalyticsSession.LOG_TAG, "Close event written.");
- }
- catch (Exception e)
- {
- Log.v(LocalyticsSession.LOG_TAG, "Swallowing exception: " + e.getMessage());
- }
+ mSessionHandler.sendEmptyMessage(SessionHandler.MESSAGE_CLOSE);
}
/**
- * Allows a session to tag a particular event as having occurred. For
- * example, if a view has three buttons, it might make sense to tag
- * each button click with the name of the button which was clicked.
- * For another example, in a game with many levels it might be valuable
- * to create a new tag every time the user gets to a new level in order
- * to determine how far the average user is progressing in the game.
- *
+ * Allows a session to tag a particular event as having occurred. For example, if a view has three buttons, it might make
+ * sense to tag each button click with the name of the button which was clicked. For another example, in a game with many
+ * levels it might be valuable to create a new tag every time the user gets to a new level in order to determine how far the
+ * average user is progressing in the game.
* Tagging Best Practices
*
*
*
- * @param event The name of the event which occurred.
+ *
+ * @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)
{
@@ -427,110 +358,191 @@ public final class LocalyticsSession
}
/**
- * Allows a session to tag a particular event as having occurred, and
- * optionally attach a collection of attributes to it. For example, if a
- * view has three buttons, it might make sense to tag each button with the
- * name of the button which was clicked.
- * For another example, in a game with many levels it might be valuable
- * to create a new tag every time the user gets to a new level in order
- * to determine how far the average user is progressing in the game.
- *
+ * Allows a session to tag a particular event as having occurred, and optionally attach a collection of attributes to it. For
+ * example, if a view has three buttons, it might make sense to tag each button with the name of the button which was clicked.
+ * For another example, in a game with many levels it might be valuable to create a new tag every time the user gets to a new
+ * level in order to determine how far the average user is progressing in the game.
* Tagging Best Practices
*
*
*
- *
+ *
* @param event The name of the event which occurred.
- * @param attributes The collection of attributes for this particular event.
+ * @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