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