mirror of https://github.com/tasks/tasks
Goodbye flurry, hello localytics, an open-source and friendlier analytics
parent
a9fc2bd41d
commit
64241c5dfe
@ -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();
|
||||
}
|
||||
}
|
@ -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:
|
||||
* <ul>
|
||||
* <li><a href="http://wiki.localytics.com/index.php?title=Developer's_Integration_Guide">Main Developer's Integration Guide</a></li>
|
||||
* <li><a href="http://wiki.localytics.com/index.php?title=Android_2_Minute_Integration">Android 2 minute integration Guide</a></li>
|
||||
* <li><a href="http://wiki.localytics.com/index.php?title=Android_Integration_Guide">Android Integration Guide</a></li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Permissions required or recommended for this class:
|
||||
* <ul>
|
||||
* <li>android.permission.INTERNET</li> - Required. Necessary to upload data to the webservice.</li>
|
||||
* <li>android.permission.ACCESS_WIFI_STATE</li> - Optional. Without this users connecting via WIFI will show up as
|
||||
* having a connection type of 'unknown' on the webservice</li>
|
||||
* </ul>
|
||||
*
|
||||
* <strong>Best Practices</strong>
|
||||
* <ul>
|
||||
* <li>Instantiate the LocalyticsSession object in onCreate.</li>
|
||||
* <li>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.</li>
|
||||
* <li>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.</li>
|
||||
* <li>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.</li>
|
||||
* <li>Do not call any Localytics functions inside a loop. Instead, calls
|
||||
* such as <code>tagEvent</code> should follow user actions. This limits the
|
||||
* amount of data which is stored and uploaded.</li>
|
||||
* <li>Do not use multiple LocalticsSession objects to upload data with
|
||||
* multiple application keys. This can cause invalid state.</li>
|
||||
* </ul>
|
||||
* @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 <code>getApplicationContext</code> 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.
|
||||
* <br>
|
||||
* 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.
|
||||
* <br>
|
||||
* 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.
|
||||
* <br>
|
||||
* 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 <code>open</code> and the final <code>close</code>
|
||||
* 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 <code>onCreate</code>.
|
||||
* <br>
|
||||
* If for any reason this is called more than once every subsequent open call
|
||||
* will be ignored.
|
||||
* <br>
|
||||
* For applications with multiple activites, every activity should call <code>open</code>
|
||||
* in <code>onCreate</code>. 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
|
||||
* <code>onPause</code> which is the recommended location). This is
|
||||
* fine because only the last close is processed by the server.
|
||||
* <br>
|
||||
* 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.
|
||||
* <br>
|
||||
* <strong>Tagging Best Practices</strong>
|
||||
* <ul>
|
||||
* <li>DO NOT use tags to record personally identifiable information.</li>
|
||||
* <li>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.</li>
|
||||
* <li>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.</li>
|
||||
* </ul>
|
||||
* <br>
|
||||
* @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.
|
||||
* <br>
|
||||
* <strong>Tagging Best Practices</strong>
|
||||
* <ul>
|
||||
* <li>DO NOT use tags to record personally identifiable information.</li>
|
||||
* <li>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.</li>
|
||||
* <li>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.</li>
|
||||
* </ul>
|
||||
* <br>
|
||||
*
|
||||
* @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<String, String> 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<String> 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;
|
||||
}
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
Loading…
Reference in New Issue