diff --git a/astrid/.classpath b/astrid/.classpath
index b1900ed7a..927927dd9 100644
--- a/astrid/.classpath
+++ b/astrid/.classpath
@@ -9,7 +9,6 @@
-
diff --git a/astrid/common-src/com/localytics/android/DatapointHelper.java b/astrid/common-src/com/localytics/android/DatapointHelper.java
new file mode 100644
index 000000000..0f28806d9
--- /dev/null
+++ b/astrid/common-src/com/localytics/android/DatapointHelper.java
@@ -0,0 +1,323 @@
+/**
+ * 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.
+ */
+
+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.content.Context;
+import android.content.pm.PackageManager;
+import android.net.wifi.WifiManager;
+import android.telephony.TelephonyManager;
+import android.util.Log;
+import android.provider.Settings.System;
+/**
+ * Provides a number of static functions to aid in the collection and formatting
+ * of datapoints.
+ * @author Localytics
+ */
+public 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 *
+ * *****************************
+ */
+
+ // 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";
+
+ // Current carrier (comes from sim card)
+ public static final String PARAM_NETWORK_CARRIER = "nca";
+
+ // Current mobile network code (comes from sim card)
+ public static final String PARAM_NETWORK_MNC = "mnc";
+
+ // current mobile country code (comes from sim card)
+ public static final String PARAM_NETWORK_MCC = "mcc";
+
+ // type of data connection (wifi, umts, gprs, evdo, ...)
+ public static final String PARAM_DATA_CONNECTION = "dac";
+
+ // the version of this Localytics client library
+ public static final String PARAM_LIBRARY_VERSION = "lv";
+
+ // The source where the location came from
+ public static final String PARAM_LOCATION_SOURCE = "ls";
+
+ // the latitude returned by the location provider
+ public static final String PARAM_LOCATION_LAT = "lat";
+
+ // the longitude from the location provider
+ public static final String PARAM_LOCATION_LNG = "lng";
+
+ // the current time on the user's device
+ public static final String PARAM_CLIENT_TIME = "ct";
+
+ // sent at closing time, the current time on the users's device
+ public static final String PARAM_CLIENT_CLOSED_TIME = "ctc";
+
+ // The name an event that occured
+ public static final String PARAM_EVENT_NAME = "n";
+
+ // the optin value sent in if a user opts in or out.
+ public static final String PARAM_OPT_VALUE = "optin";
+
+ /**
+ * 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
+ */
+ public static String formatYAMLLine(String paramName,String paramValue, int paramIndent)
+ {
+ if (paramName.length() > LocalyticsSession.MAX_NAME_LENGTH)
+ {
+ Log.v(DatapointHelper.LOG_PREFIX, "Parameter name exceeds "
+ + LocalyticsSession.MAX_NAME_LENGTH + " character limit. Truncating.");
+ paramName = paramName.substring(0, LocalyticsSession.MAX_NAME_LENGTH);
+ }
+ if (paramValue.length() > LocalyticsSession.MAX_NAME_LENGTH)
+ {
+ Log.v(DatapointHelper.LOG_PREFIX, "Parameter value exceeds "
+ + LocalyticsSession.MAX_NAME_LENGTH + " character limit. Truncating.");
+ paramValue = paramValue.substring(0, LocalyticsSession.MAX_NAME_LENGTH);
+ }
+ // 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++)
+ {
+ formattedString.append(" ");
+ }
+
+ formattedString.append(escapeString(paramName));
+ formattedString.append(": ");
+
+ // Escape the string.
+ formattedString.append(escapeString(paramValue));
+
+ formattedString.append("\n");
+
+ return formattedString.toString();
+ }
+
+ /**
+ * 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;
+ }
+ }
+
+ /**
+ * Determines the type of network this device is connected to.
+ * @param appContext 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)
+ {
+ 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())
+ {
+ 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";
+ }
+
+ 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";
+ }
+ }
+
+ /**
+ * 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.
+ */
+ public static String getTimeAsDatetime()
+ {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss-00:00");
+ sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
+ return sdf.format(new Date());
+ }
+
+ /***************************
+ * Private Helper Functions *
+ ***************************/
+
+ /**
+ * 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("\"");
+
+ int startRead = 0; // Index to start reading at
+ int stopRead = 0; // Index characters get read from and where the substring ends
+ int bufferLength = rawString.length();
+
+ if (rawString == null)
+ {
+ return "";
+ }
+
+ // 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)
+ {
+ 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')
+ {
+ parseString.append(rawString.substring(startRead, stopRead));
+ startRead = stopRead + 1;
+ }
+ stopRead++;
+ }
+ // Append whatever is left after parsing
+ parseString.append(rawString.substring(startRead, stopRead));
+ // and finish with a closing "
+ parseString.append('\"');
+ return parseString.toString();
+ }
+}
diff --git a/astrid/common-src/com/localytics/android/LocalyticsSession.java b/astrid/common-src/com/localytics/android/LocalyticsSession.java
new file mode 100644
index 000000000..9de4abb5c
--- /dev/null
+++ b/astrid/common-src/com/localytics/android/LocalyticsSession.java
@@ -0,0 +1,942 @@
+/**
+ * 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.
+ */
+
+package com.localytics.android;
+
+import android.content.Context;
+import android.telephony.TelephonyManager;
+import android.util.Log;
+import android.os.Build;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.io.OutputStream;
+
+import java.util.Iterator;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+import java.util.Arrays;
+
+/**
+ * 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:
+ *
+ *
+ * Permissions required or recommended for this class:
+ *
+ * - 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
+ *
+ *
+ * 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.
+ *
+ * @author Localytics
+ * @version 1.5
+ */
+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 //////////////////////
+ ////////////////////////////////////////
+ /**
+ * 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
+ */
+ 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;
+ }
+
+ // Otherwise, everything is opted in.
+ LocalyticsSession._isOptedIn = true;
+ }
+ catch (Exception e)
+ {
+ Log.v(LocalyticsSession.LOG_TAG, "Swallowing exception: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 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.
+ */
+ public void setOptIn(final boolean optedIn)
+ {
+ try
+ {
+ // Do nothing if optin is unchanged
+ if(optedIn == LocalyticsSession._isOptedIn)
+ {
+ return;
+ }
+
+ LocalyticsSession._isOptedIn = optedIn;
+ File fp;
+
+ if(optedIn == true)
+ {
+ // To opt in, delete the opt out file if it exists.
+ fp = new File(this._localyticsDirPath + LocalyticsSession.OPTOUT_FILNAME);
+ fp.delete();
+
+ 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) { }
+
+ createOptEvent(false);
+ }
+ }
+ catch (Exception e)
+ {
+ Log.v(LocalyticsSession.LOG_TAG, "Swallowing exception: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 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.
+ */
+ public boolean isOptedIn()
+ {
+ return LocalyticsSession._isOptedIn;
+ }
+
+ /**
+ * 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.
+ */
+ public void open()
+ {
+ // 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;
+ }
+
+ this._isSessionOpen = true;
+ }
+
+ 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));
+ }
+ };
+
+ // 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;
+ }
+ }
+
+ // 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;;
+
+ // 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;
+ }
+
+ // 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;
+ }
+
+ appendDataToFile(fp, getOpenSessionString());
+ Log.v(LocalyticsSession.LOG_TAG, "Session opened");
+ }
+ }
+ catch (Exception e)
+ {
+ Log.v(LocalyticsSession.LOG_TAG, "Swallowing exception: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 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());
+ }
+ }
+
+ /**
+ * 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.
+ *
+ *
+ * @param event The name of the event which occurred.
+ */
+ public void tagEvent(final String event)
+ {
+ tagEvent(event, null);
+ }
+
+ /**
+ * Allows a session to tag a particular event as having occurred, and
+ * optionally attach a collection of attributes to it. For example, if a
+ * view has three buttons, it might make sense to tag each button with the
+ * name of the button which was clicked.
+ * For another example, in a game with many levels it might be valuable
+ * to create a new tag every time the user gets to a new level in order
+ * to determine how far the average user is progressing in the game.
+ *
+ * Tagging Best Practices
+ *
+ * - 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.
+ *
+ *
+ *
+ * @param event The name of the event which occurred.
+ * @param attributes The collection of attributes for this particular event.
+ */
+ 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
+ {
+ // 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));
+
+ eventString.append(DatapointHelper.formatYAMLLine(
+ DatapointHelper.PARAM_EVENT_NAME, event, 3));
+
+ if (attributes != null)
+ {
+ eventString.append(DatapointHelper.EVENT_ATTRIBUTE);
+
+ // 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++)
+ {
+ String key = (String) attr_it.next();
+ String value = (String) attributes.get(key);
+ eventString.append(DatapointHelper.formatYAMLLine(key, value, 4));
+ }
+ }
+
+ File fp = getOrCreateFileWithDefaultPath(this._sessionFilename);
+ appendDataToFile(fp, eventString.toString());
+ Log.v(LocalyticsSession.LOG_TAG, "Tag written.");
+ }
+ catch (Exception e)
+ {
+ Log.v(LocalyticsSession.LOG_TAG, "Swallowing exception: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 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.
+ */
+ public String createRangedAttribute(int actualValue, int minValue, int maxValue, 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.");
+ return null;
+ }
+ if (minValue >= maxValue)
+ {
+ Log.v(LocalyticsSession.LOG_TAG, "maxValue must not be less than minValue. Returning null.");
+ return null;
+ }
+
+ // Determine the number of steps, rounding up using int math
+ int stepQuantity = (maxValue - minValue + step) / step;
+ int[] steps = new int[stepQuantity + 1];
+ for (int currentStep = 0; currentStep <= stepQuantity; currentStep++)
+ {
+ steps[currentStep] = minValue + (currentStep) * step;
+ }
+ return createRangedAttribute(actualValue, steps);
+ }
+
+ /**
+ * Sorts an int value into a predefined, pre-sorted set of intervals, returning a string representing the
+ * new expected value. The array must be sorted in ascending order, with the first element representing
+ * the inclusive lower bound and the last element representing the exclusive upper bound. For instance,
+ * the array [0,1,3,10] will provide the following buckets: less than 0, 0, 1-2, 3-9, 10 or greater.
+ *
+ * @param actualValue The int value to be bucketed.
+ * @param steps The sorted int array representing the bucketing intervals.
+ */
+ public String createRangedAttribute(int actualValue, int[] steps)
+ {
+ String bucket = null;
+
+ // if less than smallest value
+ if (actualValue < steps[0])
+ {
+ bucket = "less than " + steps[0];
+ }
+ // if greater than largest value
+ else if (actualValue >= steps[steps.length - 1])
+ {
+ bucket = steps[steps.length - 1] + " and above";
+ }
+ else
+ {
+ // binarySearch returns the index of the value, or (-(insertion point) - 1) if not found
+ int bucketIndex = Arrays.binarySearch(steps, actualValue);
+ if (bucketIndex < 0)
+ {
+ // if the index wasn't found, then we want the value before the insertion point as the lower end
+ // the special case where the insertion point is 0 is covered above, so we don't have to worry about it here
+ bucketIndex = (- bucketIndex) - 2;
+ }
+ if (steps[bucketIndex] == (steps[bucketIndex + 1] - 1))
+ {
+ bucket = Integer.toString(steps[bucketIndex]);
+ }
+ else
+ {
+ bucket = steps[bucketIndex] + "-" + (steps[bucketIndex + 1] - 1);
+ }
+ }
+ 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.
+ */
+ public void upload()
+ {
+ // 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)
+ {
+ return;
+ }
+
+ LocalyticsSession._isUploading = true;
+ }
+
+ 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)
+ {
+ Log.v(LocalyticsSession.LOG_TAG, "Swallowing exception: " + e.getMessage());
+ }
+ }
+
+ ////////////////////////////////////////
+ // 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);
+ }
+
+ /**
+ * 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;
+ }
+
+ // 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);
+ }
+
+ return null;
+ }
+
+ /**
+ * 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();
+ }
+ }
+ }
+ catch(IOException e)
+ {
+ Log.v(LocalyticsSession.LOG_TAG, "AppendDataToFile failed with IO Exception: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 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());
+ }
+ }
+ }
+
+ /**
+ * 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();
+ }
+
+ /**
+ * 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();
+ }
+
+ return deviceId;
+ }
+
+ /**
+ * 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;
+
+ // 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 + "/");
+
+ // if the file doesn't exist, create one.
+ if(fp.length() == 0)
+ {
+ deviceId = UUID.randomUUID().toString();
+ appendDataToFile(fp, deviceId);
+ }
+ else
+ {
+ 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();
+ }
+ 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;
+ }
+
+ /**
+ * 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)
+ {
+ // Create the session close blob
+ StringBuffer optString = new StringBuffer();
+ optString.append(DatapointHelper.CONTROLLER_OPT);
+ optString.append(DatapointHelper.ACTION_OPTIN);
+ optString.append(DatapointHelper.OBJECT_OPT);
+
+ optString.append(DatapointHelper.formatYAMLLine(
+ DatapointHelper.PARAM_DEVICE_UUID,
+ getDeviceId(),
+ 3));
+
+ optString.append(DatapointHelper.formatYAMLLine(
+ DatapointHelper.PARAM_APP_UUID,
+ this._applicationKey,
+ 3));
+
+ optString.append(DatapointHelper.formatYAMLLine(
+ DatapointHelper.PARAM_OPT_VALUE,
+ Boolean.toString(optState),
+ 3));
+
+ 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;
+
+ // Open the stored session id file
+ File fp = new File(this._localyticsDirPath + LocalyticsSession.SESSION_ID_FILENAME);
+ if(fp.exists())
+ {
+ try
+ {
+ BufferedReader reader = new BufferedReader(new FileReader(fp), bufferSize);
+ String storedId = reader.readLine();
+ String timeStamp = reader.readLine();
+ reader.close();
+
+ if(timeStamp != null)
+ {
+ // Check if the session happened recently enough
+ long timeSinceSession = System.currentTimeMillis() - Long.parseLong(timeStamp);
+ if(SESSION_EXPIRATION > timeSinceSession)
+ {
+ return storedId;
+ }
+ }
+ }
+ catch (FileNotFoundException e)
+ {
+ Log.v(LocalyticsSession.LOG_TAG, "File Not Found opening stored session");
+ return null;
+ }
+ catch (IOException e)
+ {
+ Log.v(LocalyticsSession.LOG_TAG, "IO Exception getting stored session: " + e.getMessage());
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Runnable which gets passed to the uploader thread so it can
+ * notify the library when uploads are complete.
+ */
+ private Runnable uploadComplete = new Runnable()
+ {
+ public void run()
+ {
+ LocalyticsSession._isUploading = false;
+ }
+ };
+}
diff --git a/astrid/common-src/com/localytics/android/UploaderThread.java b/astrid/common-src/com/localytics/android/UploaderThread.java
new file mode 100644
index 000000000..815c57270
--- /dev/null
+++ b/astrid/common-src/com/localytics/android/UploaderThread.java
@@ -0,0 +1,347 @@
+/**
+ * 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.FileReader;
+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
+ */
+public class UploaderThread extends Thread
+{
+ private Runnable _completeCallback;
+ private File _localyticsDir;
+ private String _sessionFilePrefix;
+ private String _uploaderFilePrefix;
+ private 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.
+ */
+ 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());
+ }
+ }
+
+ 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;
+ }
+ }
+}
diff --git a/astrid/libs/FlurryAgent.jar b/astrid/libs/FlurryAgent.jar
deleted file mode 100644
index ff08a0261..000000000
Binary files a/astrid/libs/FlurryAgent.jar and /dev/null differ
diff --git a/astrid/src/com/todoroo/astrid/activity/FilterListActivity.java b/astrid/src/com/todoroo/astrid/activity/FilterListActivity.java
index c70e7e4bf..3727ac0be 100644
--- a/astrid/src/com/todoroo/astrid/activity/FilterListActivity.java
+++ b/astrid/src/com/todoroo/astrid/activity/FilterListActivity.java
@@ -187,8 +187,8 @@ public class FilterListActivity extends ExpandableListActivity {
@Override
protected void onStop() {
- super.onStop();
StatisticsService.sessionStop(this);
+ super.onStop();
}
@Override
@@ -200,6 +200,7 @@ public class FilterListActivity extends ExpandableListActivity {
@Override
protected void onPause() {
+ StatisticsService.sessionPause();
super.onPause();
if(adapter != null)
adapter.unregisterRecevier();
diff --git a/astrid/src/com/todoroo/astrid/activity/TaskListActivity.java b/astrid/src/com/todoroo/astrid/activity/TaskListActivity.java
index 0080c5d08..a7833dda6 100644
--- a/astrid/src/com/todoroo/astrid/activity/TaskListActivity.java
+++ b/astrid/src/com/todoroo/astrid/activity/TaskListActivity.java
@@ -450,8 +450,8 @@ public class TaskListActivity extends ListActivity implements OnScrollListener,
@Override
protected void onStop() {
- super.onStop();
StatisticsService.sessionStop(this);
+ super.onStop();
}
@Override
@@ -480,6 +480,7 @@ public class TaskListActivity extends ListActivity implements OnScrollListener,
@Override
protected void onPause() {
+ StatisticsService.sessionPause();
super.onPause();
unregisterReceiver(detailReceiver);
unregisterReceiver(refreshReceiver);
diff --git a/astrid/src/com/todoroo/astrid/service/StatisticsService.java b/astrid/src/com/todoroo/astrid/service/StatisticsService.java
index 4dfe7e89d..dca609207 100644
--- a/astrid/src/com/todoroo/astrid/service/StatisticsService.java
+++ b/astrid/src/com/todoroo/astrid/service/StatisticsService.java
@@ -6,13 +6,15 @@ package com.todoroo.astrid.service;
import android.content.Context;
-import com.flurry.android.FlurryAgent;
+import com.localytics.android.LocalyticsSession;
import com.timsu.astrid.R;
import com.todoroo.andlib.utility.Preferences;
import com.todoroo.astrid.utility.Constants;
public class StatisticsService {
+ private static LocalyticsSession localyticsSession;
+
/**
* Indicate session started
*
@@ -22,7 +24,10 @@ public class StatisticsService {
if(dontCollectStatistics())
return;
- FlurryAgent.onStartSession(context, Constants.FLURRY_KEY);
+ localyticsSession = new LocalyticsSession(context.getApplicationContext(),
+ Constants.LOCALYTICS_KEY);
+ localyticsSession.open();
+ localyticsSession.upload();
}
/**
@@ -34,21 +39,35 @@ public class StatisticsService {
if(dontCollectStatistics())
return;
- FlurryAgent.onEndSession(context);
+ localyticsSession.upload();
}
- public static void reportError(String name, String message, String trace) {
- if(dontCollectStatistics())
- return;
+ /**
+ * Indicate session was paused
+ */
+ public static void sessionPause() {
+ localyticsSession.close();
+ }
- FlurryAgent.onError(name, message, trace);
+ /**
+ * Indicates an error occurred
+ * @param name
+ * @param message
+ * @param trace
+ */
+ public static void reportError(String name, String message, String trace) {
+ // no reports yet
}
+ /**
+ * Indicates an event should be reported
+ * @param event
+ */
public static void reportEvent(String event) {
if(dontCollectStatistics())
return;
- FlurryAgent.onEvent(event);
+ localyticsSession.tagEvent(event);
}
private static boolean dontCollectStatistics() {
diff --git a/astrid/src/com/todoroo/astrid/utility/Constants.java b/astrid/src/com/todoroo/astrid/utility/Constants.java
index a35a15923..c96a0ea61 100644
--- a/astrid/src/com/todoroo/astrid/utility/Constants.java
+++ b/astrid/src/com/todoroo/astrid/utility/Constants.java
@@ -6,9 +6,9 @@ public final class Constants {
// --- general application constants
/**
- * Flurry API Key
+ * LCL API Key
*/
- public static final String FLURRY_KEY = "T3JAY9TV2JFMJR4YTG16"; //$NON-NLS-1$
+ public static final String LOCALYTICS_KEY = "ae35a010c66a997ab129ab7-3e2adf46-8bb3-11e0-fe8b-007f58cb3154"; //$NON-NLS-1$
/**
* Application Package