From 64241c5dfea024f8a2f1ed1c0cca73ea2cfd51c9 Mon Sep 17 00:00:00 2001 From: Tim Su Date: Wed, 15 Jun 2011 18:11:43 -0700 Subject: [PATCH] Goodbye flurry, hello localytics, an open-source and friendlier analytics --- astrid/.classpath | 1 - .../localytics/android/DatapointHelper.java | 323 ++++++ .../localytics/android/LocalyticsSession.java | 942 ++++++++++++++++++ .../localytics/android/UploaderThread.java | 347 +++++++ astrid/libs/FlurryAgent.jar | Bin 12487 -> 0 bytes .../astrid/activity/FilterListActivity.java | 3 +- .../astrid/activity/TaskListActivity.java | 3 +- .../astrid/service/StatisticsService.java | 35 +- .../com/todoroo/astrid/utility/Constants.java | 4 +- 9 files changed, 1645 insertions(+), 13 deletions(-) create mode 100644 astrid/common-src/com/localytics/android/DatapointHelper.java create mode 100644 astrid/common-src/com/localytics/android/LocalyticsSession.java create mode 100644 astrid/common-src/com/localytics/android/UploaderThread.java delete mode 100644 astrid/libs/FlurryAgent.jar 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 ff08a026108e7bd7754e1aea13b9d110fa9b0b22..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12487 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 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