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: + *

    + *
  1. 1: Initial version
  2. + *
+ */ + private static final int DATABASE_VERSION = 1; + + /** + * Singleton instance of the {@link LocalyticsProvider}. Lazily initialized via {@link #getInstance(Context, String)}. + */ + private static final Map sLocalyticsProviderMap = new HashMap(); + + /** + * Intrinsic lock for synchronizing the initialization of {@link #sLocalyticsProviderMap}. + */ + /* + * Fun fact: Object[0] is more efficient that Object for an intrinsic lock + */ + private static final Object[] sLocalyticsProviderIntrinsicLock = new Object[0]; + + /** + * Unmodifiable set of valid table names. + */ + private static final Set sValidTables = Collections.unmodifiableSet(getValidTables()); + + /** + * SQLite database owned by the provider. + */ + private final SQLiteDatabase mDb; + + /** + * Obtains an instance of the Localytics Provider. Since the provider is a singleton object, only a single instance will be + * returned. + *

+ * 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 getValidTables() + { + final HashSet tables = new HashSet(); + + tables.add(ApiKeysDbColumns.TABLE_NAME); + tables.add(AttributesDbColumns.TABLE_NAME); + tables.add(EventsDbColumns.TABLE_NAME); + tables.add(EventHistoryDbColumns.TABLE_NAME); + tables.add(SessionsDbColumns.TABLE_NAME); + tables.add(UploadBlobsDbColumns.TABLE_NAME); + tables.add(UploadBlobEventsDbColumns.TABLE_NAME); + + return tables; + } + + /** + * Private helper that deletes files from older versions of the Localytics library. + *

+ * 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: + *

    + *
  • {@link permission#INTERNET}
  • - Necessary to upload data to the webservice. + *
+ * Permissions recommended: *
    - *
  • android.permission.INTERNET
  • - Required. Necessary to upload data to the webservice. - *
  • android.permission.ACCESS_WIFI_STATE
  • - Optional. Without this users connecting via WIFI will show up as - * having a connection type of 'unknown' on the webservice + *
  • {@link permission#ACCESS_WIFI_STATE}
  • - Without this users connecting via Wi-Fi will show up as having a connection + * type of 'unknown' on the webservice *
- * + *

+ * This library will create a database called "com.android.localytics.sqlite" within the host application's + * {@link Context#getDatabasePath(String)} directory. For security, this file directory will be created + * {@link Context#MODE_PRIVATE}. The host application must not modify this database file. If the host application implements a + * backup/restore mechanism, such as {@code android.app.backup.BackupManager}, the host application should not worry about backing + * up the data in the Localytics database. + *

+ *

+ * This library is thread-safe but is not multi-process safe. Unless the application explicitly uses different process attributes + * in the Android Manifest, this is not an issue. + *

* Best Practices *
    - *
  • Instantiate the LocalyticsSession object in onCreate.
  • - *
  • Create a new LocalyticsSession object, and open it in the onCreaet - * of every activity in your application. This will cause every new - * activity you display to reconnect to the running session.
  • - *
  • Open your session and begin your uploads in onCreate. This way the - * upload has time to complete and it all happens before your users have a - * chance to begin any data intensive actions of their own.
  • - *
  • Close the session in onPause. This is the last terminating function - * which is guaranteed to be called. The final close is the only one - * considered so worrying about activity re-entrance is not a problem.
  • - *
  • Do not call any Localytics functions inside a loop. Instead, calls - * such as tagEvent should follow user actions. This limits the - * amount of data which is stored and uploaded.
  • - *
  • Do not use multiple LocalticsSession objects to upload data with - * multiple application keys. This can cause invalid state.
  • + *
  • Instantiate and open a {@link LocalyticsSession} object in {@code Activity#onCreate(Bundle)}. This will cause every new + * Activity displayed to reconnect to any running session.
  • + *
  • Consider also performing {@link #upload()} in {@code Activity#onCreate(Bundle)}. This makes it more likely for the upload + * to complete before the Activity is finished, and also causes the upload to start before the user has a chance to begin any data + * intensive actions of his own.
  • + *
  • Close the session in {@code Activity#onPause()}. Based on the Activity lifecycle documentation, this is the last + * terminating method which is guaranteed to be called. The final call to {@link #close()} is the only one considered, so don't + * worry about Activity re-entrance.
  • + *
  • Do not call any {@link LocalyticsSession} methods inside a loop. Instead, calls such as {@link #tagEvent(String)} should + * follow user actions. This limits the amount of data which is stored and uploaded.
  • *
- * @author Localytics - * @version 1.5 + *

+ * 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 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. + *

+ * 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 sIsUploadingMap = new HashMap(); + + /** + * Constructs a new {@link LocalyticsSession} object. + * + * @param context The context used to access resources on behalf of the app. It is recommended to use + * {@link Context#getApplicationContext()} to avoid the potential memory leak incurred by maintaining references to + * {@code Activity} instances. Cannot be null. + * @param key The key unique for each application generated at www.localytics.com. Cannot be null or empty. + * @throws IllegalArgumentException if {@code context} is null + * @throws IllegalArgumentException if {@code key} is null or empty + */ + public LocalyticsSession(final Context context, final String key) + { + if (context == null) + { + throw new IllegalArgumentException("context cannot be null"); //$NON-NLS-1$ } - catch (Exception e) + if (TextUtils.isEmpty(key)) { - Log.v(LocalyticsSession.LOG_TAG, "Swallowing exception: " + e.getMessage()); + throw new IllegalArgumentException("key cannot be null or empty"); //$NON-NLS-1$ } + + /* + * Get the application context to avoid having the Localytics object holding onto an Activity object. Using application + * context is very important to prevent the customer from giving the library multiple different contexts with different + * package names, which would corrupt the events in the database. + * + * Although RenamingDelegatingContext is part of the Android SDK, the class isn't present in the ClassLoader unless the + * process is being run as a unit test. For that reason, comparing class names is necessary instead of doing instanceof. + * + * Note that getting the application context may have unpredictable results for apps sharing a process running Android 2.1 + * and earlier. See for details. + */ + mContext = !(context.getClass().getName().equals("android.test.RenamingDelegatingContext")) && Build.VERSION.SDK_INT >= 8 ? context.getApplicationContext() : context; //$NON-NLS-1$ + mLocalyticsKey = key; + + mSessionHandler = new SessionHandler(mContext, mLocalyticsKey, sSessionHandlerThread.getLooper()); + + /* + * Complete Handler initialization on a background thread. Note that this is not generally a good best practice, as the + * LocalyticsSession object (and its child objects) should be fully initialized by the time the constructor returns. + * However this implementation is safe, as the Handler will process this initialization message before any other message. + */ + mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_INIT)); } /** - * 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. + * 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)}. + *

+ * 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 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 *

    *
  • DO NOT use tags to record personally identifiable information.
  • - *
  • The best way to use tags is to create all the tag strings as predefined - * constants and only use those. This is more efficient and removes the risk of - * collecting personal information.
  • - *
  • Do not set tags inside loops or any other place which gets called - * frequently. This can cause a lot of data to be stored and uploaded.
  • + *
  • The best way to use tags is to create all the tag strings as predefined constants and only use those. This is more + * efficient and removes the risk of collecting personal information.
  • + *
  • Do not set tags inside loops or any other place which gets called frequently. This can cause a lot of data to be stored + * and uploaded.
  • *
*
- * @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 *
    *
  • DO NOT use tags to record personally identifiable information.
  • - *
  • The best way to use tags is to create all the tag strings as predefined - * constants and only use those. This is more efficient and removes the risk of - * collecting personal information.
  • - *
  • Do not set tags inside loops or any other place which gets called - * frequently. This can cause a lot of data to be stored and uploaded.
  • + *
  • The best way to use tags is to create all the tag strings as predefined constants and only use those. This is more + * efficient and removes the risk of collecting personal information.
  • + *
  • Do not set tags inside loops or any other place which gets called frequently. This can cause a lot of data to be stored + * and uploaded.
  • *
*
- * + * * @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 attributes) { - if(LocalyticsSession._isOptedIn == false || // do nothing if opted out - this._isSessionOpen == false) // do nothing if session is not open - { - Log.v(LocalyticsSession.LOG_TAG, "Tag not written"); - return; - } - - try + if (Constants.ENABLE_PARAMETER_CHECKING) { - // Create the YML for the event - StringBuffer eventString = new StringBuffer(); - eventString.append(DatapointHelper.CONTROLLER_EVENT); - eventString.append(DatapointHelper.ACTION_CREATE); - eventString.append(DatapointHelper.OBJECT_EVENT_DP); - eventString.append(DatapointHelper.formatYAMLLine( - DatapointHelper.PARAM_APP_UUID, this._applicationKey, 3)); - eventString.append(DatapointHelper.formatYAMLLine( - DatapointHelper.PARAM_UUID, UUID.randomUUID().toString(), 3)); - eventString.append(DatapointHelper.formatYAMLLine( - DatapointHelper.PARAM_SESSION_UUID, this._sessionUUID, 3)); - eventString.append(DatapointHelper.formatYAMLLine( - DatapointHelper.PARAM_CLIENT_TIME, DatapointHelper.getTimeAsDatetime(), 3)); + if (null == event) + { + throw new IllegalArgumentException("event cannot be null"); //$NON-NLS-1$ + } - eventString.append(DatapointHelper.formatYAMLLine( - DatapointHelper.PARAM_EVENT_NAME, event, 3)); + if (0 == event.length()) + { + throw new IllegalArgumentException("event cannot be empty"); //$NON-NLS-1$ + } - if (attributes != null) + if (null != attributes) { - eventString.append(DatapointHelper.EVENT_ATTRIBUTE); + /* + * Calling this with empty attributes is a smell that indicates a possible programming error on the part of the + * caller + */ + if (attributes.isEmpty()) + { + if (Constants.IS_LOGGABLE) + { + Log.i(Constants.LOG_TAG, "attributes is empty. Did the caller make an error?"); //$NON-NLS-1$ + } + } - // Iterate through the map's elements and append a line for each one - Iterator attr_it = attributes.keySet().iterator(); - for (int currentAttr = 0; attr_it.hasNext() && (currentAttr < MAX_NUM_ATTRIBUTES); currentAttr++) + for (final Entry entry : attributes.entrySet()) { - String key = (String) attr_it.next(); - String value = (String) attributes.get(key); - eventString.append(DatapointHelper.formatYAMLLine(key, value, 4)); + final String key = entry.getKey(); + final String value = entry.getValue(); + + if (null == key) + { + throw new IllegalArgumentException("attributes cannot contain null keys"); //$NON-NLS-1$ + } + if (null == value) + { + throw new IllegalArgumentException("attributes cannot contain null values"); //$NON-NLS-1$ + } + if (0 == key.length()) + { + throw new IllegalArgumentException("attributes cannot contain empty keys"); //$NON-NLS-1$ + } + if (0 == value.length()) + { + throw new IllegalArgumentException("attributes cannot contain empty values"); //$NON-NLS-1$ + } } } + } + + final String eventString = String.format(EVENT_FORMAT, mContext.getPackageName(), event); + + if (null == attributes) + { + mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_TAG_EVENT, new Pair>(eventString, null))); + } + else + { + /* + * Note: it is important to make a copy of the map, to ensure that a client can't modify the map after this method is + * called. A TreeMap is used to ensure that the order that the attributes are written is deterministic. For example, + * if the maximum number of attributes is exceeded the entries that occur later alphabetically will be skipped + * consistently. + */ + mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_TAG_EVENT, new Pair>( + eventString, + new TreeMap( + attributes)))); + } + } - File fp = getOrCreateFileWithDefaultPath(this._sessionFilename); - appendDataToFile(fp, eventString.toString()); - Log.v(LocalyticsSession.LOG_TAG, "Tag written."); + /** + * Note: This implementation will perform duplicate suppression on two identical screen events that occur in a row within a + * single session. For example, in the set of screens {"Screen 1", "Screen 1"} the second screen would be suppressed. However + * in the set {"Screen 1", "Screen 2", "Screen 1"}, no duplicate suppression would occur. + * + * @param screen Name of the screen that was entered. Cannot be null or the empty string. + * @throws IllegalArgumentException if {@code event} is null. + * @throws IllegalArgumentException if {@code event} is empty. + */ + public void tagScreen(final String screen) + { + if (null == screen) + { + throw new IllegalArgumentException("event cannot be null"); //$NON-NLS-1$ } - catch (Exception e) + + if (0 == screen.length()) { - Log.v(LocalyticsSession.LOG_TAG, "Swallowing exception: " + e.getMessage()); + throw new IllegalArgumentException("event cannot be empty"); //$NON-NLS-1$ } + + mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_TAG_SCREEN, screen)); + } + + /** + * Initiates an upload of any Localytics data for this session's API key. This should be done early in the process life in + * order to guarantee as much time as possible for slow connections to complete. It is necessary to do this even if the user + * has opted out because this is how the opt out is transported to the webservice. + */ + public void upload() + { + mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_UPLOAD, null)); } + /* + * This is useful, but not necessarily needed for the public API. If so desired, someone can uncomment this out. + */ + // /** + // * Initiates an upload of any Localytics data for this session's API key. This should be done early in the process life in + // * order to guarantee as much time as possible for slow connections to complete. It is necessary to do this even if the user + // * has opted out because this is how the opt out is transported to the webservice. + // * + // * @param callback a Runnable to execute when the upload completes. A typical use case would be to notify the caller that + // the + // * upload has completed. This runnable will be executed on an undefined thread, so the caller should anticipate + // * this runnable NOT executing on the main thread or the thread that calls {@link #upload}. This parameter may be + // * null. + // */ + // public void upload(final Runnable callback) + // { + // mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_UPLOAD, callback)); + // } + /** - * Sorts an int value into a set of regular intervals as defined by the - * minimum, maximum, and step size. Both the min and max values are - * inclusive, and in the instance where (max - min + 1) is not evenly - * divisible by step size, the method guarantees only the minimum and the - * step size to be accurate to specification, with the new maximum will be - * moved to the next regular step. - * + * Sorts an int value into a set of regular intervals as defined by the minimum, maximum, and step size. Both the min and max + * values are inclusive, and in the instance where (max - min + 1) is not evenly divisible by step size, the method guarantees + * only the minimum and the step size to be accurate to specification, with the new maximum will be moved to the next regular + * step. + * * @param actualValue The int value to be sorted. * @param minValue The int value representing the inclusive minimum interval. * @param maxValue The int value representing the inclusive maximum interval. * @param step The int value representing the increment of each interval. + * @return a ranged attribute suitable for passing as the argument to {@link #tagEvent(String)} or + * {@link #tagEvent(String, Map)}. */ - public String createRangedAttribute(int actualValue, int minValue, int maxValue, int step) + public static String createRangedAttribute(final int actualValue, final int minValue, final int maxValue, final int step) { // Confirm there is at least one bucket if (step < 1) { - Log.v(LocalyticsSession.LOG_TAG, "Step must not be less than zero. Returning null."); + if (Constants.IS_LOGGABLE) + { + Log.v(Constants.LOG_TAG, "Step must not be less than zero. Returning null."); //$NON-NLS-1$ + } return null; } if (minValue >= maxValue) { - Log.v(LocalyticsSession.LOG_TAG, "maxValue must not be less than minValue. Returning null."); + if (Constants.IS_LOGGABLE) + { + Log.v(Constants.LOG_TAG, "maxValue must not be less than minValue. Returning null."); //$NON-NLS-1$ + } return null; } // Determine the number of steps, rounding up using int math - int stepQuantity = (maxValue - minValue + step) / step; - int[] steps = new int[stepQuantity + 1]; + final int stepQuantity = (maxValue - minValue + step) / step; + final int[] steps = new int[stepQuantity + 1]; for (int currentStep = 0; currentStep <= stepQuantity; currentStep++) { steps[currentStep] = minValue + (currentStep) * step; @@ -539,16 +551,29 @@ public final class LocalyticsSession } /** - * Sorts an int value into a predefined, pre-sorted set of intervals, returning a string representing the - * new expected value. The array must be sorted in ascending order, with the first element representing - * the inclusive lower bound and the last element representing the exclusive upper bound. For instance, - * the array [0,1,3,10] will provide the following buckets: less than 0, 0, 1-2, 3-9, 10 or greater. - * + * Sorts an int value into a predefined, pre-sorted set of intervals, returning a string representing the new expected value. + * The array must be sorted in ascending order, with the first element representing the inclusive lower bound and the last + * element representing the exclusive upper bound. For instance, the array [0,1,3,10] will provide the following buckets: less + * than 0, 0, 1-2, 3-9, 10 or greater. + * * @param actualValue The int value to be bucketed. * @param steps The sorted int array representing the bucketing intervals. + * @return String representation of {@code actualValue} that has been bucketed into the range provided by {@code steps}. + * @throws IllegalArgumentException if {@code steps} is null. + * @throws IllegalArgumentException if {@code steps} has length 0. */ - public String createRangedAttribute(int actualValue, int[] steps) + public static String createRangedAttribute(final int actualValue, final int[] steps) { + if (null == steps) + { + throw new IllegalArgumentException("steps cannot be null"); //$NON-NLS-1$ + } + + if (steps.length == 0) + { + throw new IllegalArgumentException("steps length must be greater than 0"); //$NON-NLS-1$ + } + String bucket = null; // if less than smallest value @@ -569,7 +594,7 @@ public final class LocalyticsSession { // if the index wasn't found, then we want the value before the insertion point as the lower end // the special case where the insertion point is 0 is covered above, so we don't have to worry about it here - bucketIndex = (- bucketIndex) - 2; + bucketIndex = (-bucketIndex) - 2; } if (steps[bucketIndex] == (steps[bucketIndex + 1] - 1)) { @@ -577,366 +602,2078 @@ public final class LocalyticsSession } else { - bucket = steps[bucketIndex] + "-" + (steps[bucketIndex + 1] - 1); + bucket = steps[bucketIndex] + "-" + (steps[bucketIndex + 1] - 1); //$NON-NLS-1$ } } return bucket; } /** - * Creates a low priority thread which uploads any Localytics data already stored - * on the device. This should be done early in the process life in order to - * guarantee as much time as possible for slow connections to complete. It - * is necessary to do this even if the user has opted out because this is how - * the opt out is transported to the webservice. + * Helper class to handle session-related work on the {@link LocalyticsSession#sSessionHandlerThread}. */ - public void upload() + /* package */static final class SessionHandler extends Handler { - // Synchronize the check to make sure the upload is not - // already happening. This avoids the possibility of two - // uploader threads being started at once. While this isn't necessary it could - // conceivably reduce the load on the server - synchronized(LocalyticsSession.class) - { - // Uploading should still happen even if the session is opted out. - // This way the opt-out event gets sent to the server so we know this - // user is opted out. After that, no data will be collected so nothing - // will get uploaded. - if(LocalyticsSession._isUploading) + /** + * Empty handler message to initialize the callback. + *

+ * 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> pair = (Pair>) msg.obj; + final String event = pair.first; + final Map attributes = pair.second; - // Open the device ID file. This file is stored at the root level so that - // if an application has multiple app keys (which it shouldn't!) all sessions - // will have a common device_id. - //File fp = getOrCreateFileWithDefaultPath(LocalyticsSession.DEVICE_ID_FILENAME); - File fp = getOrCreateFile( - LocalyticsSession.DEVICE_ID_FILENAME, - this._appContext.getFilesDir() + "/" - + LocalyticsSession.LOCALYTICS_DIR + "/"); + SessionHandler.this.tagEvent(event, attributes); - // if the file doesn't exist, create one. - if(fp.length() == 0) - { - deviceId = UUID.randomUUID().toString(); - appendDataToFile(fp, deviceId); - } - else + break; + } + case MESSAGE_TAG_SCREEN: + { + if (Constants.IS_LOGGABLE) + { + Log.d(Constants.LOG_TAG, "Handler received MESSAGE_SCREEN"); //$NON-NLS-1$ + } + + final String screen = (String) msg.obj; + + SessionHandler.this.tagScreen(screen); + + break; + } + case MESSAGE_UPLOAD: + { + if (Constants.IS_LOGGABLE) + { + Log.d(Constants.LOG_TAG, "SessionHandler received MESSAGE_UPLOAD"); //$NON-NLS-1$ + } + + /* + * Note that callback may be null + */ + final Runnable callback = (Runnable) msg.obj; + + SessionHandler.this.upload(callback); + + break; + } + case MESSAGE_UPLOAD_COMPLETE: + { + if (Constants.IS_LOGGABLE) + { + Log.d(Constants.LOG_TAG, "Handler received MESSAGE_UPLOAD_COMPLETE"); //$NON-NLS-1$ + } + + sIsUploadingMap.put(mApiKey, Boolean.FALSE); + + break; + } + default: + { + /* + * This should never happen + */ + throw new RuntimeException("Fell through switch statement"); //$NON-NLS-1$ + } + } + } + + /** + * Initialize the handler post construction. + *

+ * 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 attributes) + { + if (!mIsSessionOpen) + { + if (Constants.IS_LOGGABLE) + { + Log.w(Constants.LOG_TAG, "Tag not written because the session was not open"); //$NON-NLS-1$ + } + return; + } + + /* + * First insert the event + */ + final long eventId; + { + final ContentValues values = new ContentValues(); + values.put(EventsDbColumns.SESSION_KEY_REF, Long.valueOf(mSessionId)); + values.put(EventsDbColumns.UUID, UUID.randomUUID().toString()); + values.put(EventsDbColumns.EVENT_NAME, event); + values.put(EventsDbColumns.REAL_TIME, Long.valueOf(SystemClock.elapsedRealtime())); + values.put(EventsDbColumns.WALL_TIME, Long.valueOf(System.currentTimeMillis())); + + /* + * Special case for open event: keep the start time in sync with the start time put into the sessions table. + */ + if (OPEN_EVENT.equals(event)) + { + Cursor cursor = null; + try + { + cursor = mProvider.query(SessionsDbColumns.TABLE_NAME, new String[] + { SessionsDbColumns.SESSION_START_WALL_TIME }, String.format("%s = ?", SessionsDbColumns._ID), new String[] { Long.toString(mSessionId) }, null); //$NON-NLS-1$ + + if (cursor.moveToFirst()) + { + values.put(EventsDbColumns.WALL_TIME, Long.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(SessionsDbColumns.SESSION_START_WALL_TIME)))); + } + else + { + // this should never happen + throw new RuntimeException("Session didn't exist"); //$NON-NLS-1$ + } + } + finally + { + if (null != cursor) + { + cursor.close(); + } + } + } + + eventId = mProvider.insert(EventsDbColumns.TABLE_NAME, values); + + if (-1 == eventId) + { + throw new RuntimeException("Inserting event failed"); //$NON-NLS-1$ + } + } + + /* + * If attributes exist, insert them as well + */ + if (null != attributes) + { + int count = 0; + for (final Entry entry : attributes.entrySet()) + { + /* + * Note: the attributes that are skipped are deterministic, because the map is actually an instance of + * TreeMap. + */ + count++; + if (count > Constants.MAX_NUM_ATTRIBUTES) + { + if (Constants.IS_LOGGABLE) + { + Log.w(Constants.LOG_TAG, String.format("Map contains %s keys while the maximum number of attributes is %s. Some attributes were not written. Consider reducing the number of attributes.", Integer.valueOf(attributes.size()), Integer.valueOf(Constants.MAX_NUM_ATTRIBUTES))); //$NON-NLS-1$ + } + break; + } + + final ContentValues values = new ContentValues(); + values.put(AttributesDbColumns.EVENTS_KEY_REF, Long.valueOf(eventId)); + values.put(AttributesDbColumns.ATTRIBUTE_KEY, entry.getKey()); + values.put(AttributesDbColumns.ATTRIBUTE_VALUE, entry.getValue()); + + final long id = mProvider.insert(AttributesDbColumns.TABLE_NAME, values); + + if (-1 == id) + { + throw new RuntimeException("Inserting attribute failed"); //$NON-NLS-1$ + } + } + } + + /* + * Insert the event into the history, only for application events + */ + if (!OPEN_EVENT.equals(event) && !CLOSE_EVENT.equals(event) && !OPT_IN_EVENT.equals(event) && !OPT_OUT_EVENT.equals(event) && !FLOW_EVENT.equals(event)) + { + final ContentValues values = new ContentValues(); + values.put(EventHistoryDbColumns.NAME, event.substring(mContext.getPackageName().length() + 1, event.length())); + values.put(EventHistoryDbColumns.TYPE, Integer.valueOf(EventHistoryDbColumns.TYPE_EVENT)); + values.put(EventHistoryDbColumns.SESSION_KEY_REF, Long.valueOf(mSessionId)); + values.putNull(EventHistoryDbColumns.PROCESSED_IN_BLOB); + mProvider.insert(EventHistoryDbColumns.TABLE_NAME, values); + + conditionallyAddFlowEvent(); + } + } + + /** + * Tag a screen 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 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 eventIds = new HashSet(); + + Cursor eventsCursor = null; + Cursor blob_eventsCursor = null; + try + { + eventsCursor = mProvider.query(EventsDbColumns.TABLE_NAME, new String[] + { + EventsDbColumns._ID, + EventsDbColumns.EVENT_NAME, + EventsDbColumns.WALL_TIME }, null, null, EventsDbColumns._ID); + + // eventsCursor = mProvider.query(EventsDbColumns.TABLE_NAME, new String[] {EventsDbColumns._ID}, String.format("%s != ? AND %s < ?", EventsDbColumns.EVENT_NAME, EventsDbColumns.WALL_TIME), new String[] {CLOSE_EVENT, Long.toString(System.currentTimeMillis() - Constants.SESSION_EXPIRATION)}, 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 (CLOSE_EVENT.equals(eventsCursor.getString(eventsCursor.getColumnIndexOrThrow(EventsDbColumns.EVENT_NAME)))) + { + if (System.currentTimeMillis() - eventsCursor.getLong(eventsCursor.getColumnIndexOrThrow(EventsDbColumns.WALL_TIME)) < Constants.SESSION_EXPIRATION) + { + break; + } + } + eventIds.add(Long.valueOf(eventsCursor.getLong(idColumn))); + break; + } + case BOTH: + break; + case RIGHT: + break; + } + } + } + finally + { + if (eventsCursor != null) + { + eventsCursor.close(); + } + + if (blob_eventsCursor != null) + { + blob_eventsCursor.close(); + } + } + + if (eventIds.size() > 0) + { + final long blobId; + { + final ContentValues values = new ContentValues(); + values.put(UploadBlobsDbColumns.UUID, UUID.randomUUID().toString()); + blobId = mProvider.insert(UploadBlobsDbColumns.TABLE_NAME, values); + } + + { + final ContentValues values = new ContentValues(); + for (final Long x : eventIds) + { + values.clear(); + + values.put(UploadBlobEventsDbColumns.UPLOAD_BLOBS_KEY_REF, Long.valueOf(blobId)); + values.put(UploadBlobEventsDbColumns.EVENTS_KEY_REF, x); + + mProvider.insert(UploadBlobEventsDbColumns.TABLE_NAME, values); + } + } + + final ContentValues values = new ContentValues(); + values.put(EventHistoryDbColumns.PROCESSED_IN_BLOB, Long.valueOf(blobId)); + mProvider.update(EventHistoryDbColumns.TABLE_NAME, values, String.format("%s IS NULL", EventHistoryDbColumns.PROCESSED_IN_BLOB), null); //$NON-NLS-1$ + } + } + + /** + * Initiate upload of all session data currently stored on disk. + *

+ * 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 toUpload = convertDatabaseToJson(); + + if (!toUpload.isEmpty()) + { + final StringBuilder builder = new StringBuilder(); + for (final JSONObject json : toUpload) + { + builder.append(json.toString()); + builder.append("\n"); //$NON-NLS-1$ + } + + if (uploadSessions(String.format(ANALYTICS_URL, mApiKey), builder.toString())) + { + mProvider.runBatchTransaction(new Runnable() + { + @Override + public void run() + { + deleteBlobsAndSessions(mProvider); + } + }); + } + } + } + finally + { + if (callback != null) + { + /* + * Execute the callback on a separate thread, to avoid exposing this thread to the client of the + * library + */ + new Thread(callback).start(); + } + + mSessionHandler.sendEmptyMessage(SessionHandler.MESSAGE_UPLOAD_COMPLETE); + } + break; + } + case MESSAGE_RETRY_UPLOAD_REQUEST: + { + if (Constants.IS_LOGGABLE) + { + Log.d(Constants.LOG_TAG, "Received MESSAGE_RETRY_UPLOAD_REQUEST"); //$NON-NLS-1$ + } + + mSessionHandler.sendMessage(mSessionHandler.obtainMessage(SessionHandler.MESSAGE_UPLOAD, msg.obj)); + break; + } + default: + { + /* + * This should never happen + */ + throw new RuntimeException("Fell through switch statement"); //$NON-NLS-1$ + } + } + } + + /** + * Uploads the post Body to the webservice + * + * @param url where {@code body} will be posted to. Cannot be null. + * @param body upload body as a string. This should be a plain old string. Cannot be null. + * @return True on success, false on failure. + */ + /* package */static boolean uploadSessions(final String url, final String body) + { + if (Constants.ENABLE_PARAMETER_CHECKING) + { + if (null == url) + { + throw new IllegalArgumentException("url cannot be null"); //$NON-NLS-1$ + } + + if (null == body) + { + throw new IllegalArgumentException("body cannot be null"); //$NON-NLS-1$ + } + } + + if (Constants.IS_LOGGABLE) + { + Log.v(Constants.LOG_TAG, String.format("Upload body before compression is: %s", body.toString())); //$NON-NLS-1$ + } + + final DefaultHttpClient client = new DefaultHttpClient(); + final HttpPost method = new HttpPost(url); + method.addHeader("Content-Type", "application/x-gzip"); //$NON-NLS-1$ //$NON-NLS-2$ + + GZIPOutputStream gos = null; try { - // If it did exist, the file contains the ID. - char[] buf = new char[100]; - int numRead; - BufferedReader reader = new BufferedReader(new FileReader(fp), bufferSize); - numRead = reader.read(buf); - deviceId = String.copyValueOf(buf, 0, numRead); - reader.close(); + final byte[] originalBytes = body.getBytes("UTF-8"); //$NON-NLS-1$ + final ByteArrayOutputStream baos = new ByteArrayOutputStream(originalBytes.length); + gos = new GZIPOutputStream(baos); + gos.write(originalBytes); + gos.finish(); + gos.flush(); + + final ByteArrayEntity postBody = new ByteArrayEntity(baos.toByteArray()); + method.setEntity(postBody); + + final HttpResponse response = client.execute(method); + + final StatusLine status = response.getStatusLine(); + final int statusCode = status.getStatusCode(); + if (Constants.IS_LOGGABLE) + { + Log.v(Constants.LOG_TAG, String.format("Upload complete with status %d", Integer.valueOf(statusCode))); //$NON-NLS-1$ + } + + /* + * 5xx status codes indicate a server error, so upload should be reattempted + */ + if (statusCode >= 500 && statusCode <= 599) + { + return false; + } + + return true; + } + catch (final UnsupportedEncodingException e) + { + if (Constants.IS_LOGGABLE) + { + Log.w(Constants.LOG_TAG, "UnsupportedEncodingException", e); //$NON-NLS-1$ + } + return false; + } + catch (final ClientProtocolException e) + { + if (Constants.IS_LOGGABLE) + { + Log.w(Constants.LOG_TAG, "ClientProtocolException", e); //$NON-NLS-1$ + } + return false; + } + catch (final IOException e) + { + if (Constants.IS_LOGGABLE) + { + Log.w(Constants.LOG_TAG, "IOException", e); //$NON-NLS-1$ + } + return false; + } + finally + { + if (null != gos) + { + try + { + gos.close(); + } + catch (final IOException e) + { + // there's nothing to be done if this occurs + } + } } - catch (FileNotFoundException e) { Log.v(LocalyticsSession.LOG_TAG, "GetLocalDeviceID failed with FNF: " + e.getMessage()); } - catch (IOException e) { Log.v(LocalyticsSession.LOG_TAG, "GetLocalDeviceId Failed with IO Exception: " + e.getMessage()); } } - return deviceId; - } + /** + * Helper that converts blobs in the database into a JSON representation for upload. + * + * @return A list of JSON objecs to upload to the server + */ + /* package */List convertDatabaseToJson() + { + final List result = new LinkedList(); + Cursor cursor = null; + try + { + cursor = mProvider.query(UploadBlobsDbColumns.TABLE_NAME, null, null, null, null); - /** - * Creates an event telling the webservice that the user opted in or out. - * @param optState True if they opted in, false if they opted out. - */ - private void createOptEvent(boolean optState) - { - File fp = getOrCreateFileWithDefaultPath(LocalyticsSession.OPT_SESSION); - if(fp != null) + final long creationTime = getApiKeyCreationTime(mProvider, mApiKey); + + final int idColumn = cursor.getColumnIndexOrThrow(UploadBlobsDbColumns._ID); + final int uuidColumn = cursor.getColumnIndexOrThrow(UploadBlobsDbColumns.UUID); + while (cursor.moveToNext()) + { + try + { + final JSONObject blobHeader = new JSONObject(); + + blobHeader.put(JsonObjects.BlobHeader.KEY_DATA_TYPE, BlobHeader.VALUE_DATA_TYPE); + blobHeader.put(JsonObjects.BlobHeader.KEY_PERSISTENT_STORAGE_CREATION_TIME_SECONDS, creationTime); + blobHeader.put(JsonObjects.BlobHeader.KEY_SEQUENCE_NUMBER, cursor.getLong(idColumn)); + blobHeader.put(JsonObjects.BlobHeader.KEY_UNIQUE_ID, cursor.getString(uuidColumn)); + blobHeader.put(JsonObjects.BlobHeader.KEY_ATTRIBUTES, getAttributesFromSession(mProvider, mApiKey, getSessionIdForBlobId(cursor.getLong(idColumn)))); + result.add(blobHeader); + + Cursor blobEvents = null; + try + { + blobEvents = 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(cursor.getLong(idColumn)) }, UploadBlobEventsDbColumns.EVENTS_KEY_REF); + + final int eventIdColumn = blobEvents.getColumnIndexOrThrow(UploadBlobEventsDbColumns.EVENTS_KEY_REF); + while (blobEvents.moveToNext()) + { + result.add(convertEventToJson(mProvider, mContext, blobEvents.getLong(eventIdColumn), cursor.getLong(idColumn), mApiKey)); + } + } + finally + { + if (null != blobEvents) + { + blobEvents.close(); + } + } + } + catch (final JSONException e) + { + } + } + } + finally + { + if (cursor != null) + { + cursor.close(); + } + } + + if (Constants.IS_LOGGABLE) + { + Log.v(Constants.LOG_TAG, String.format("JSON result is %s", result.toString())); //$NON-NLS-1$ + } + + return result; + } + + /** + * Deletes all blobs and sessions/events/attributes associated with those blobs. + *

+ * 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 sessionsToDelete = new LinkedList(); + final HashSet blobsToDelete = new HashSet(); + + Cursor blobEvents = null; + try + { + blobEvents = provider.query(UploadBlobEventsDbColumns.TABLE_NAME, new String[] + { + UploadBlobEventsDbColumns._ID, + UploadBlobEventsDbColumns.EVENTS_KEY_REF, + UploadBlobEventsDbColumns.UPLOAD_BLOBS_KEY_REF }, null, null, null); + + final int uploadBlobIdColumn = blobEvents.getColumnIndexOrThrow(UploadBlobEventsDbColumns.UPLOAD_BLOBS_KEY_REF); + final int blobEventIdColumn = blobEvents.getColumnIndexOrThrow(UploadBlobEventsDbColumns._ID); + final int eventIdColumn = blobEvents.getColumnIndexOrThrow(UploadBlobEventsDbColumns.EVENTS_KEY_REF); + while (blobEvents.moveToNext()) + { + final long blobId = blobEvents.getLong(uploadBlobIdColumn); + final long blobEventId = blobEvents.getLong(blobEventIdColumn); + final long eventId = blobEvents.getLong(eventIdColumn); + + // delete the blobevent + provider.delete(UploadBlobEventsDbColumns.TABLE_NAME, String.format("%s = ?", UploadBlobEventsDbColumns._ID), new String[] { Long.toString(blobEventId) }); //$NON-NLS-1$ + + /* + * Add the blob to the list of blobs to be deleted + */ + blobsToDelete.add(Long.valueOf(blobId)); + + // delete all attributes for the event + provider.delete(AttributesDbColumns.TABLE_NAME, String.format("%s = ?", AttributesDbColumns.EVENTS_KEY_REF), new String[] { Long.toString(eventId) }); //$NON-NLS-1$ + + /* + * Check to see if the event is a close event, indicating that the session is complete and can also be deleted + */ + Cursor eventCursor = null; + try + { + eventCursor = provider.query(EventsDbColumns.TABLE_NAME, new String[] + { EventsDbColumns.SESSION_KEY_REF }, String.format("%s = ? AND %s = ?", EventsDbColumns._ID, EventsDbColumns.EVENT_NAME), new String[] //$NON-NLS-1$ + { + Long.toString(eventId), + CLOSE_EVENT }, null); + + if (eventCursor.moveToFirst()) + { + final long sessionId = eventCursor.getLong(eventCursor.getColumnIndexOrThrow(EventsDbColumns.SESSION_KEY_REF)); + + provider.delete(EventHistoryDbColumns.TABLE_NAME, String.format("%s = ?", EventHistoryDbColumns.SESSION_KEY_REF), new String[] //$NON-NLS-1$ + { Long.toString(sessionId) }); + + sessionsToDelete.add(Long.valueOf(eventCursor.getLong(eventCursor.getColumnIndexOrThrow(EventsDbColumns.SESSION_KEY_REF)))); + } + } + finally + { + if (null != eventCursor) + { + eventCursor.close(); + } + } - optString.append(DatapointHelper.formatYAMLLine( - DatapointHelper.PARAM_DEVICE_UUID, - getDeviceId(), - 3)); + // delete the event + provider.delete(EventsDbColumns.TABLE_NAME, String.format("%s = ?", EventsDbColumns._ID), new String[] { Long.toString(eventId) }); //$NON-NLS-1$ + } + } + finally + { + if (null != blobEvents) + { + blobEvents.close(); + } + } - optString.append(DatapointHelper.formatYAMLLine( - DatapointHelper.PARAM_APP_UUID, - this._applicationKey, - 3)); + // delete blobs + for (final long x : blobsToDelete) + { + provider.delete(UploadBlobsDbColumns.TABLE_NAME, String.format("%s = ?", UploadBlobsDbColumns._ID), new String[] { Long.toString(x) }); //$NON-NLS-1$ + } - optString.append(DatapointHelper.formatYAMLLine( - DatapointHelper.PARAM_OPT_VALUE, - Boolean.toString(optState), - 3)); + // delete sessions + for (final long x : sessionsToDelete) + { + provider.delete(SessionsDbColumns.TABLE_NAME, String.format("%s = ?", SessionsDbColumns._ID), new String[] { Long.toString(x) }); //$NON-NLS-1$ + } - appendDataToFile(fp, optString.toString()); } - } - /** - * Returns the UUID of the last session which was closed if the last session - * was closed within 15 seconds. This allows sessions to be shared between - * activities within the same application. - * - * @return the UUID of the previous LocalyticsSession or null if the session has been closed for too long. - */ - private String getOldSessionUUId() - { - final int bufferSize = 100; + /** + * Gets the creation time for an API key. + * + * @param provider Localytics database provider. Cannot be null. + * @param key Localytics API key. Cannot be null. + * @return The time in seconds since the Unix Epoch when the API key entry was created in the database. + * @throws RuntimeException if the API key entry doesn't exist in the database. + */ + /* package */static long getApiKeyCreationTime(final LocalyticsProvider provider, final String key) + { + Cursor cursor = null; + try + { + cursor = provider.query(ApiKeysDbColumns.TABLE_NAME, null, String.format("%s = ?", ApiKeysDbColumns.API_KEY), new String[] { key }, null); //$NON-NLS-1$ + + if (cursor.moveToFirst()) + { + return Math.round((float) cursor.getLong(cursor.getColumnIndexOrThrow(ApiKeysDbColumns.CREATED_TIME)) / DateUtils.SECOND_IN_MILLIS); + } + + /* + * This should never happen + */ + throw new RuntimeException("API key entry couldn't be found"); //$NON-NLS-1$ + } + finally + { + if (null != cursor) + { + cursor.close(); + } + } + } - // Open the stored session id file - File fp = new File(this._localyticsDirPath + LocalyticsSession.SESSION_ID_FILENAME); - if(fp.exists()) + /** + * Helper method to generate the attributes object for a session + * + * @param provider Instance of the Localytics database provider. Cannot be null. + * @param apiKey Localytics API key. Cannot be null. + * @param sessionId The {@link SessionsDbColumns#_ID} of the session. + * @return a JSONObject representation of the session attributes + * @throws JSONException if a problem occurred converting the element to JSON. + */ + /* package */static JSONObject getAttributesFromSession(final LocalyticsProvider provider, final String apiKey, final long sessionId) throws JSONException { + Cursor cursor = null; + try + { + cursor = provider.query(SessionsDbColumns.TABLE_NAME, null, String.format("%s = ?", SessionsDbColumns._ID), new String[] { Long.toString(sessionId) }, null); //$NON-NLS-1$ + + if (cursor.moveToFirst()) + { + final JSONObject result = new JSONObject(); + result.put(JsonObjects.BlobHeader.Attributes.KEY_CLIENT_APP_VERSION, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.APP_VERSION))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_DATA_CONNECTION, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.NETWORK_TYPE))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_ANDROID_ID_HASH, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_ANDROID_ID_HASH))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_COUNTRY, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_COUNTRY))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_MANUFACTURER, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_MANUFACTURER))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_MODEL, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_MODEL))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_OS_VERSION, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.ANDROID_VERSION))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_PLATFORM, JsonObjects.BlobHeader.Attributes.VALUE_PLATFORM); + result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_SERIAL_HASH, cursor.isNull(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_SERIAL_NUMBER_HASH)) ? JSONObject.NULL + : cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_SERIAL_NUMBER_HASH))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_SDK_LEVEL, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.ANDROID_SDK))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_DEVICE_TELEPHONY_ID, cursor.isNull(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_TELEPHONY_ID)) ? JSONObject.NULL + : cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.DEVICE_TELEPHONY_ID))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_LOCALYTICS_API_KEY, apiKey); + result.put(JsonObjects.BlobHeader.Attributes.KEY_LOCALYTICS_CLIENT_LIBRARY_VERSION, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.LOCALYTICS_LIBRARY_VERSION))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_LOCALYTICS_DATA_TYPE, JsonObjects.BlobHeader.Attributes.VALUE_DATA_TYPE); + result.put(JsonObjects.BlobHeader.Attributes.KEY_LOCALE_COUNTRY, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.LOCALE_COUNTRY))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_LOCALE_LANGUAGE, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.LOCALE_LANGUAGE))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_NETWORK_CARRIER, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.NETWORK_CARRIER))); + result.put(JsonObjects.BlobHeader.Attributes.KEY_NETWORK_COUNTRY, cursor.getString(cursor.getColumnIndexOrThrow(SessionsDbColumns.NETWORK_COUNTRY))); + + return result; + } + + throw new RuntimeException("No session exists"); //$NON-NLS-1$ + } + finally + { + if (null != cursor) + { + cursor.close(); + } + } + } + + /** + * Converts an event into a JSON object. + *

+ * 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 { - public void run() + public final F first; + + public final S second; + + public Pair(final F first, final S second) { - LocalyticsSession._isUploading = false; + this.first = first; + this.second = second; } - }; + } } diff --git a/astrid/common-src/com/localytics/android/ReflectionUtils.java b/astrid/common-src/com/localytics/android/ReflectionUtils.java new file mode 100644 index 000000000..476636823 --- /dev/null +++ b/astrid/common-src/com/localytics/android/ReflectionUtils.java @@ -0,0 +1,112 @@ +package com.localytics.android; + +import java.lang.reflect.InvocationTargetException; + +/** + * Static utilities for performing reflection against newer Android SDKs. + *

+ * 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 Type that the method should return + * @param classObject Class on which to invoke {@code methodName}. Cannot be null. + * @param methodName Name of the method to invoke. Cannot be null. + * @param types explicit types for the objects. This is useful if the types are primitives, rather than objects. + * @param args arguments for the method. May be null if the method takes no arguments. + * @return The result of invoking the named method on the given class for the args + * @throws RuntimeException if the class or method doesn't exist + */ + @SuppressWarnings("unchecked") + public static T tryInvokeStatic(final Class classObject, final String methodName, final Class[] types, final Object[] args) + { + return (T) helper(null, classObject, null, methodName, types, args); + } + + /** + * Use reflection to invoke a static method for a class object and method name + * + * @param Type that the method should return + * @param className Name of the class on which to invoke {@code methodName}. Cannot be null. + * @param methodName Name of the method to invoke. Cannot be null. + * @param types explicit types for the objects. This is useful if the types are primitives, rather than objects. + * @param args arguments for the method. May be null if the method takes no arguments. + * @return The result of invoking the named method on the given class for the args + * @throws RuntimeException if the class or method doesn't exist + */ + @SuppressWarnings("unchecked") + public static T tryInvokeStatic(final String className, final String methodName, final Class[] types, final Object[] args) + { + return (T) helper(className, null, null, methodName, types, args); + } + + /** + * Use reflection to invoke a static method for a class object and method name + * + * @param Type that the method should return + * @param target Object instance on which to invoke {@code methodName}. Cannot be null. + * @param methodName Name of the method to invoke. Cannot be null. + * @param types explicit types for the objects. This is useful if the types are primitives, rather than objects. + * @param args arguments for the method. May be null if the method takes no arguments. + * @return The result of invoking the named method on the given class for the args + * @throws RuntimeException if the class or method doesn't exist + */ + @SuppressWarnings("unchecked") + public static T tryInvokeInstance(final Object target, final String methodName, final Class[] types, final Object[] args) + { + return (T) helper(target, null, null, methodName, types, args); + } + + @SuppressWarnings("unchecked") + private static T helper(final Object target, final Class classObject, final String className, final String methodName, final Class[] argTypes, final Object[] args) + { + try + { + Class cls; + if (classObject != null) + { + cls = classObject; + } + else if (target != null) + { + cls = target.getClass(); + } + else + { + cls = Class.forName(className); + } + + return (T) cls.getMethod(methodName, argTypes).invoke(target, args); + } + catch (final NoSuchMethodException e) + { + throw new RuntimeException(e); + } + catch (final IllegalAccessException e) + { + throw new RuntimeException(e); + } + catch (final InvocationTargetException e) + { + throw new RuntimeException(e); + } + catch (final ClassNotFoundException e) + { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/astrid/common-src/com/localytics/android/UploaderThread.java b/astrid/common-src/com/localytics/android/UploaderThread.java deleted file mode 100644 index 5bee5e916..000000000 --- a/astrid/common-src/com/localytics/android/UploaderThread.java +++ /dev/null @@ -1,353 +0,0 @@ -/** - * UploaderThread.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. - */ - -package com.localytics.android; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.FilenameFilter; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.UnsupportedEncodingException; - -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.StringEntity; -import org.apache.http.impl.client.DefaultHttpClient; - -import android.util.Log; - -/** - * The thread which handles uploading Localytics data. - * @author Localytics - */ -@SuppressWarnings("nls") -public class UploaderThread extends Thread -{ - private final Runnable _completeCallback; - private final File _localyticsDir; - private final String _sessionFilePrefix; - private final String _uploaderFilePrefix; - private final String _closeFilePrefix; - - // The Tag used in logging. - private final static String LOG_TAG = "Localytics_uploader"; - - // The URL to send Localytics session data to - private final static String ANALYTICS_URL = "http://analytics.localytics.com/api/datapoints/bulk"; - - // The size of the buffer used for reading in files. - private final static int BUFFER_SIZE = 1024; - - /** - * Creates a thread which uploads the session files in the passed Localytics - * Directory. All files starting with sessionFilePrefix are renamed, - * uploaded and deleted on upload. This way the sessions can continue - * writing data regardless of whether or not the upload succeeds. Files - * which have been renamed still count towards the total number of Localytics - * files which can be stored on the disk. - * @param appContext The context used to access the disk - * @param completeCallback A runnable which is called notifying the caller that upload is complete. - * @param localyticsDir The directory containing the session files - * @param sessionFilePrefix The filename prefix identifying the session files. - * @param uploaderfilePrefix The filename prefixed identifying files to be uploaded. - */ - public UploaderThread( - File localyticsDir, - String sessionFilePrefix, - String uploaderFilePrefix, - String closeFilePrefix, - Runnable completeCallback) - { - this._localyticsDir = localyticsDir; - this._sessionFilePrefix = sessionFilePrefix; - this._uploaderFilePrefix = uploaderFilePrefix; - this._closeFilePrefix = closeFilePrefix; - this._completeCallback = completeCallback; - } - - /** - * Renames all the session files (so that other threads can keep writing - * datapoints without affecting the upload. And then uploads them. - */ - @Override - public void run() - { - int numFilesToUpload = 0; - - try - { - if(this._localyticsDir != null && this._localyticsDir.exists()) - { - String basePath = this._localyticsDir.getAbsolutePath(); - - // rename all the files, taking care to rename the session files - // before the close files. - renameOrAppendSessionFiles(basePath); - renameOrAppendCloseFiles(basePath); - - // Grab all the files to be uploaded - FilenameFilter filter = new FilenameFilter() - { - public boolean accept(File dir, String name) - { - return name.startsWith(_uploaderFilePrefix); - } - }; - - String uploaderFiles[] = this._localyticsDir.list(filter); - numFilesToUpload = uploaderFiles.length; - String postBody = createPostBodyFromFiles(basePath, uploaderFiles); - - // Attempt to upload this data. If successful, delete all the uploaderFiles. - Log.v(UploaderThread.LOG_TAG, "Attempting to upload " + numFilesToUpload + " files."); - if(uploadSessions(postBody.toString()) == true) - { - int currentFile; - File uploadedFile; - for(currentFile = 0; currentFile < uploaderFiles.length; currentFile++) - { - uploadedFile = new File(basePath + "/" + uploaderFiles[currentFile]); - uploadedFile.delete(); - } - } - } - - // Notify the caller the upload is complete. - if(this._completeCallback != null) - { - this._completeCallback.run(); - } - } - catch (Exception e) - { - Log.v(UploaderThread.LOG_TAG, "Swallowing exception: " + e.getMessage()); - } - } - - /** - * Looks at every file whose name starts with the session file prefix - * and renamed or appends it to the appropriately named uploader file. - * @param basePath The full path to the directory containing the files to upload - */ - private void renameOrAppendSessionFiles(String basePath) - { - int currentFile; - - // Create a filter to only grab the session files. - FilenameFilter filter = new FilenameFilter() - { - public boolean accept(File dir, String name) - { - return name.startsWith(_sessionFilePrefix); - } - }; - - // Go through each of the session files - String[] originalFiles = this._localyticsDir.list(filter); - for(currentFile = 0; currentFile < originalFiles.length; currentFile++) - { - String originalFileName = basePath + "/" + originalFiles[currentFile]; - String targetFileName = basePath + "/" + this._uploaderFilePrefix + originalFiles[currentFile]; - renameOrAppendFile(new File(originalFileName), new File(targetFileName)); - } - } - - /** - * Looks at every close file in the directory and renames or appends it to - * the appropriate uploader file. This is done separately from the session - * files because it makes life simpler on the webservice if the close events - * come after the session events - * @param basePath The full path to the directory containing the files to upload - */ - private void renameOrAppendCloseFiles(String basePath) - { - int currentFile; - - // Create a filter to only grab the session files. - FilenameFilter filter = new FilenameFilter() - { - public boolean accept(File dir, String name) - { - return name.startsWith(_closeFilePrefix); - } - }; - - // Go through each of the session files - String[] originalFiles = this._localyticsDir.list(filter); - for(currentFile = 0; currentFile < originalFiles.length; currentFile++) - { - String originalFileName = basePath + "/" + originalFiles[currentFile]; - - // In order for the close events to be appended to the appropriate files - // remove the close prefix and prepend the session prefix - String targetFileName = basePath + "/" - + this._uploaderFilePrefix - + getSessionFilenameFromCloseFile(originalFiles[currentFile]); - renameOrAppendFile(new File(originalFileName), new File(targetFileName)); - } - } - - /** - * Determines what the name of the session file matching this close file would be - * @param closeFilename Name of close file to be used as a guide - * @return The filename of the session which matches this close file - */ - private String getSessionFilenameFromCloseFile(String closeFilename) - { - return this._sessionFilePrefix + closeFilename.substring(this._closeFilePrefix.length()); - } - - /** - * Checks if destination file exists. If so, it appends the contents of - * source to destination and deletes source. Otherwise, it rename source - * to destination. - * @param source File containing the data to be moved - * @param destination Target for the data - */ - private static void renameOrAppendFile(File source, File destination) - { - if(destination.exists()) - { - try - { - InputStream in = new FileInputStream(source); - OutputStream out = new FileOutputStream(destination, true); - - byte[] buf = new byte[1024]; - int len; - while ((len = in.read(buf)) > 0) - { - out.write(buf, 0, len); - } - in.close(); - out.close(); - source.delete(); - } - catch (FileNotFoundException e) - { - Log.v(LOG_TAG, "File not found."); - } - catch (IOException e) - { - Log.v(LOG_TAG, "IO Exception: " + e.getMessage()); - } - } - else - { - source.renameTo(destination); - } - } - - /** - * Reads in the input files and cats them together in one big string which makes up the - * HTTP request body. - * @param basePath The directory to get the files from - * @param uploaderFiles the list of files to read - * @return A string containing a YML blob which can be uploaded to the webservice. - */ - private String createPostBodyFromFiles(final String basePath, final String[] uploaderFiles) - { - int currentFile; - File inputFile; - StringBuffer postBody = new StringBuffer(); - - // Read each file in to one buffer. This allows the upload to happen as one - // large transfer instead of many smaller transfers which is preferable on - // a mobile device in which the time required to make a connection is often - // disproportionately large compared to the time to upload the data. - for(currentFile = 0; currentFile < uploaderFiles.length; currentFile++) - { - inputFile = new File(basePath + "/" + uploaderFiles[currentFile]); - - try - { - BufferedReader reader = new BufferedReader( - new InputStreamReader( - new FileInputStream(inputFile), - "UTF-8"), - UploaderThread.BUFFER_SIZE); - char[] buf = new char[1024]; - int numRead; - while( (numRead = reader.read(buf)) > 0) - { - postBody.append(buf, 0, numRead); - } - reader.close(); - } - catch (FileNotFoundException e) - { - Log.v(LOG_TAG, "File Not Found"); - } - catch (IOException e) - { - Log.v(LOG_TAG, "IOException: " + e.getMessage()); - } - catch (OutOfMemoryError e) - { - e.printStackTrace(); - Log.v(LOG_TAG, "OutOfMemoryError: " + e.getMessage()); - } - } - - return postBody.toString(); - } - - /** - * Uploads the post Body to the webservice - * @param ymlBlob String containing the YML to upload - * @return True on success, false on failure. - */ - private boolean uploadSessions(String ymlBlob) - { - DefaultHttpClient client = new DefaultHttpClient(); - HttpPost method = new HttpPost(UploaderThread.ANALYTICS_URL); - - try - { - StringEntity postBody = new StringEntity(ymlBlob, "utf8"); - method.setEntity(postBody); - HttpResponse response = client.execute(method); - - StatusLine status = response.getStatusLine(); - Log.v(UploaderThread.LOG_TAG, "Upload complete. Status: " + status.getStatusCode()); - - // On any response from the webservice, return true so the local files get - // deleted. This avoid an infinite loop in which a bad file keeps getting - // submitted to the webservice time and again. - return true; - } - - // return true for any transportation errors. - catch (UnsupportedEncodingException e) - { - Log.v(LOG_TAG, "UnsuppEncodingException: " + e.getMessage()); - return false; - } - catch (ClientProtocolException e) - { - Log.v(LOG_TAG, "ClientProtocolException: " + e.getMessage()); - return false; - } - catch (IOException e) - { - Log.v(LOG_TAG, "IOException: " + e.getMessage()); - return false; - } - } -}