package com.mixpanel.android.mpmetrics;
import android.content.Context;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.os.Message;
import android.os.SystemClock;
import android.util.DisplayMetrics;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
import com.google.android.gms.gcm.GoogleCloudMessaging;
import com.google.android.gms.iid.InstanceID;
import com.mixpanel.android.util.Base64Coder;
import com.mixpanel.android.util.HttpService;
import com.mixpanel.android.util.MPLog;
import com.mixpanel.android.util.RemoteService;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.SocketTimeoutException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import javax.net.ssl.SSLSocketFactory;
/**
* Manage communication of events with the internal database and the Mixpanel servers.
*
* <p>This class straddles the thread boundary between user threads and
* a logical Mixpanel thread.
*/
/* package */ class AnalyticsMessages {
/**
* Do not call directly. You should call AnalyticsMessages.getInstance()
*/
/* package */ AnalyticsMessages(final Context context) {
mContext = context;
mConfig = getConfig(context);
mWorker = createWorker();
getPoster().checkIsMixpanelBlocked();
}
protected Worker createWorker() {
return new Worker();
}
/**
* Use this to get an instance of AnalyticsMessages instead of creating one directly
* for yourself.
*
* @param messageContext should be the Main Activity of the application
* associated with these messages.
*/
public static AnalyticsMessages getInstance(final Context messageContext) {
synchronized (sInstances) {
final Context appContext = messageContext.getApplicationContext();
AnalyticsMessages ret;
if (! sInstances.containsKey(appContext)) {
ret = new AnalyticsMessages(appContext);
sInstances.put(appContext, ret);
} else {
ret = sInstances.get(appContext);
}
return ret;
}
}
public void eventsMessage(final EventDescription eventDescription) {
final Message m = Message.obtain();
m.what = ENQUEUE_EVENTS;
m.obj = eventDescription;
mWorker.runMessage(m);
}
// Must be thread safe.
public void peopleMessage(final JSONObject peopleJson) {
final Message m = Message.obtain();
m.what = ENQUEUE_PEOPLE;
m.obj = peopleJson;
mWorker.runMessage(m);
}
public void postToServer() {
final Message m = Message.obtain();
m.what = FLUSH_QUEUE;
mWorker.runMessage(m);
}
public void installDecideCheck(final DecideMessages check) {
final Message m = Message.obtain();
m.what = INSTALL_DECIDE_CHECK;
m.obj = check;
mWorker.runMessage(m);
}
public void registerForGCM(final String senderID) {
final Message m = Message.obtain();
m.what = REGISTER_FOR_GCM;
m.obj = senderID;
mWorker.runMessage(m);
}
public void hardKill() {
final Message m = Message.obtain();
m.what = KILL_WORKER;
mWorker.runMessage(m);
}
/////////////////////////////////////////////////////////
// For testing, to allow for Mocking.
/* package */ boolean isDead() {
return mWorker.isDead();
}
protected MPDbAdapter makeDbAdapter(Context context) {
return new MPDbAdapter(context);
}
protected MPConfig getConfig(Context context) {
return MPConfig.getInstance(context);
}
protected RemoteService getPoster() {
return new HttpService();
}
////////////////////////////////////////////////////
static class EventDescription {
public EventDescription(String eventName, JSONObject properties, String token) {
this.eventName = eventName;
this.properties = properties;
this.token = token;
}
public String getEventName() {
return eventName;
}
public JSONObject getProperties() {
return properties;
}
public String getToken() {
return token;
}
private final String eventName;
private final JSONObject properties;
private final String token;
}
// Sends a message if and only if we are running with Mixpanel Message log enabled.
// Will be called from the Mixpanel thread.
private void logAboutMessageToMixpanel(String message) {
MPLog.v(LOGTAG, message + " (Thread " + Thread.currentThread().getId() + ")");
}
private void logAboutMessageToMixpanel(String message, Throwable e) {
MPLog.v(LOGTAG, message + " (Thread " + Thread.currentThread().getId() + ")", e);
}
// Worker will manage the (at most single) IO thread associated with
// this AnalyticsMessages instance.
// XXX: Worker class is unnecessary, should be just a subclass of HandlerThread
class Worker {
public Worker() {
mHandler = restartWorkerThread();
}
public boolean isDead() {
synchronized(mHandlerLock) {
return mHandler == null;
}
}
public void runMessage(Message msg) {
synchronized(mHandlerLock) {
if (mHandler == null) {
// We died under suspicious circumstances. Don't try to send any more events.
logAboutMessageToMixpanel("Dead mixpanel worker dropping a message: " + msg.what);
} else {
mHandler.sendMessage(msg);
}
}
}
// NOTE that the returned worker will run FOREVER, unless you send a hard kill
// (which you really shouldn't)
protected Handler restartWorkerThread() {
final HandlerThread thread = new HandlerThread("com.mixpanel.android.AnalyticsWorker", Thread.MIN_PRIORITY);
thread.start();
final Handler ret = new AnalyticsMessageHandler(thread.getLooper());
return ret;
}
class AnalyticsMessageHandler extends Handler {
public AnalyticsMessageHandler(Looper looper) {
super(looper);
mDbAdapter = null;
mSystemInformation = new SystemInformation(mContext);
mDecideChecker = createDecideChecker();
mDisableFallback = mConfig.getDisableFallback();
mFlushInterval = mConfig.getFlushInterval();
}
protected DecideChecker createDecideChecker() {
return new DecideChecker(mContext, mConfig, mSystemInformation);
}
@Override
public void handleMessage(Message msg) {
if (mDbAdapter == null) {
mDbAdapter = makeDbAdapter(mContext);
mDbAdapter.cleanupEvents(System.currentTimeMillis() - mConfig.getDataExpiration(), MPDbAdapter.Table.EVENTS);
mDbAdapter.cleanupEvents(System.currentTimeMillis() - mConfig.getDataExpiration(), MPDbAdapter.Table.PEOPLE);
}
try {
int returnCode = MPDbAdapter.DB_UNDEFINED_CODE;
if (msg.what == ENQUEUE_PEOPLE) {
final JSONObject message = (JSONObject) msg.obj;
logAboutMessageToMixpanel("Queuing people record for sending later");
logAboutMessageToMixpanel(" " + message.toString());
returnCode = mDbAdapter.addJSON(message, MPDbAdapter.Table.PEOPLE);
} else if (msg.what == ENQUEUE_EVENTS) {
final EventDescription eventDescription = (EventDescription) msg.obj;
try {
final JSONObject message = prepareEventObject(eventDescription);
logAboutMessageToMixpanel("Queuing event for sending later");
logAboutMessageToMixpanel(" " + message.toString());
returnCode = mDbAdapter.addJSON(message, MPDbAdapter.Table.EVENTS);
} catch (final JSONException e) {
MPLog.e(LOGTAG, "Exception tracking event " + eventDescription.getEventName(), e);
}
} else if (msg.what == FLUSH_QUEUE) {
logAboutMessageToMixpanel("Flushing queue due to scheduled or forced flush");
updateFlushFrequency();
sendAllData(mDbAdapter);
if (SystemClock.elapsedRealtime() >= mDecideRetryAfter) {
try {
mDecideChecker.runDecideChecks(getPoster());
} catch (RemoteService.ServiceUnavailableException e) {
mDecideRetryAfter = SystemClock.elapsedRealtime() + e.getRetryAfter() * 1000;
}
}
} else if (msg.what == INSTALL_DECIDE_CHECK) {
logAboutMessageToMixpanel("Installing a check for in-app notifications");
final DecideMessages check = (DecideMessages) msg.obj;
mDecideChecker.addDecideCheck(check);
if (SystemClock.elapsedRealtime() >= mDecideRetryAfter) {
try {
mDecideChecker.runDecideChecks(getPoster());
} catch (RemoteService.ServiceUnavailableException e) {
mDecideRetryAfter = SystemClock.elapsedRealtime() + e.getRetryAfter() * 1000;
}
}
} else if (msg.what == REGISTER_FOR_GCM) {
final String senderId = (String) msg.obj;
runGCMRegistration(senderId);
} else if (msg.what == KILL_WORKER) {
MPLog.w(LOGTAG, "Worker received a hard kill. Dumping all events and force-killing. Thread id " + Thread.currentThread().getId());
synchronized(mHandlerLock) {
mDbAdapter.deleteDB();
mHandler = null;
Looper.myLooper().quit();
}
} else {
MPLog.e(LOGTAG, "Unexpected message received by Mixpanel worker: " + msg);
}
///////////////////////////
if ((returnCode >= mConfig.getBulkUploadLimit() || returnCode == MPDbAdapter.DB_OUT_OF_MEMORY_ERROR) && mFailedRetries <= 0) {
logAboutMessageToMixpanel("Flushing queue due to bulk upload limit");
updateFlushFrequency();
sendAllData(mDbAdapter);
if (SystemClock.elapsedRealtime() >= mDecideRetryAfter) {
try {
mDecideChecker.runDecideChecks(getPoster());
} catch (RemoteService.ServiceUnavailableException e) {
mDecideRetryAfter = SystemClock.elapsedRealtime() + e.getRetryAfter() * 1000;
}
}
} else if (returnCode > 0 && !hasMessages(FLUSH_QUEUE)) {
// The !hasMessages(FLUSH_QUEUE) check is a courtesy for the common case
// of delayed flushes already enqueued from inside of this thread.
// Callers outside of this thread can still send
// a flush right here, so we may end up with two flushes
// in our queue, but we're OK with that.
logAboutMessageToMixpanel("Queue depth " + returnCode + " - Adding flush in " + mFlushInterval);
if (mFlushInterval >= 0) {
sendEmptyMessageDelayed(FLUSH_QUEUE, mFlushInterval);
}
}
} catch (final RuntimeException e) {
MPLog.e(LOGTAG, "Worker threw an unhandled exception", e);
synchronized (mHandlerLock) {
mHandler = null;
try {
Looper.myLooper().quit();
MPLog.e(LOGTAG, "Mixpanel will not process any more analytics messages", e);
} catch (final Exception tooLate) {
MPLog.e(LOGTAG, "Could not halt looper", tooLate);
}
}
}
}// handleMessage
protected long getTrackEngageRetryAfter() {
return mTrackEngageRetryAfter;
}
private void runGCMRegistration(String senderID) {
final String registrationId;
try {
// We don't actually require Google Play Services to be available
// (since we can't specify what version customers will be using,
// and because the latest Google Play Services actually have
// dependencies on Java 7)
// Consider adding a transitive dependency on the latest
// Google Play Services version and requiring Java 1.7
// in the next major library release.
try {
final int resultCode = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(mContext);
if (resultCode != ConnectionResult.SUCCESS) {
MPLog.i(LOGTAG, "Can't register for push notifications, Google Play Services are not installed.");
return;
}
} catch (RuntimeException e) {
MPLog.i(LOGTAG, "Can't register for push notifications, Google Play services are not configured.");
return;
}
InstanceID instanceID = InstanceID.getInstance(mContext);
registrationId = instanceID.getToken(senderID, GoogleCloudMessaging.INSTANCE_ID_SCOPE, null);
} catch (IOException e) {
MPLog.i(LOGTAG, "Exception when trying to register for GCM", e);
return;
} catch (NoClassDefFoundError e) {
MPLog.w(LOGTAG, "Google play services were not part of this build, push notifications cannot be registered or delivered");
return;
}
MixpanelAPI.allInstances(new MixpanelAPI.InstanceProcessor() {
@Override
public void process(MixpanelAPI api) {
MPLog.v(LOGTAG, "Using existing pushId " + registrationId);
api.getPeople().setPushRegistrationId(registrationId);
}
});
}
private void sendAllData(MPDbAdapter dbAdapter) {
final RemoteService poster = getPoster();
if (!poster.isOnline(mContext, mConfig.getOfflineMode())) {
logAboutMessageToMixpanel("Not flushing data to Mixpanel because the device is not connected to the internet.");
return;
}
if (mDisableFallback) {
sendData(dbAdapter, MPDbAdapter.Table.EVENTS, new String[]{ mConfig.getEventsEndpoint() });
sendData(dbAdapter, MPDbAdapter.Table.PEOPLE, new String[]{ mConfig.getPeopleEndpoint() });
} else {
sendData(dbAdapter, MPDbAdapter.Table.EVENTS,
new String[]{ mConfig.getEventsEndpoint(), mConfig.getEventsFallbackEndpoint() });
sendData(dbAdapter, MPDbAdapter.Table.PEOPLE,
new String[]{ mConfig.getPeopleEndpoint(), mConfig.getPeopleFallbackEndpoint() });
}
}
private void sendData(MPDbAdapter dbAdapter, MPDbAdapter.Table table, String[] urls) {
final RemoteService poster = getPoster();
String[] eventsData = dbAdapter.generateDataString(table);
Integer queueCount = 0;
if (eventsData != null) {
queueCount = Integer.valueOf(eventsData[2]);
}
while (eventsData != null && queueCount > 0) {
final String lastId = eventsData[0];
final String rawMessage = eventsData[1];
final String encodedData = Base64Coder.encodeString(rawMessage);
final Map<String, Object> params = new HashMap<String, Object>();
params.put("data", encodedData);
if (MPConfig.DEBUG) {
params.put("verbose", "1");
}
boolean deleteEvents = true;
byte[] response;
for (String url : urls) {
try {
final SSLSocketFactory socketFactory = mConfig.getSSLSocketFactory();
response = poster.performRequest(url, params, socketFactory);
if (null == response) {
deleteEvents = false;
logAboutMessageToMixpanel("Response was null, unexpected failure posting to " + url + ".");
} else {
deleteEvents = true; // Delete events on any successful post, regardless of 1 or 0 response
String parsedResponse;
try {
parsedResponse = new String(response, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("UTF not supported on this platform?", e);
}
if (mFailedRetries > 0) {
mFailedRetries = 0;
removeMessages(FLUSH_QUEUE);
}
logAboutMessageToMixpanel("Successfully posted to " + url + ": \n" + rawMessage);
logAboutMessageToMixpanel("Response was " + parsedResponse);
}
break;
} catch (final OutOfMemoryError e) {
MPLog.e(LOGTAG, "Out of memory when posting to " + url + ".", e);
break;
} catch (final MalformedURLException e) {
MPLog.e(LOGTAG, "Cannot interpret " + url + " as a URL.", e);
break;
} catch (final RemoteService.ServiceUnavailableException e) {
logAboutMessageToMixpanel("Cannot post message to " + url + ".", e);
deleteEvents = false;
mTrackEngageRetryAfter = e.getRetryAfter() * 1000;
} catch (final SocketTimeoutException e) {
logAboutMessageToMixpanel("Cannot post message to " + url + ".", e);
deleteEvents = false;
} catch (final IOException e) {
logAboutMessageToMixpanel("Cannot post message to " + url + ".", e);
deleteEvents = false;
}
}
if (deleteEvents) {
logAboutMessageToMixpanel("Not retrying this batch of events, deleting them from DB.");
dbAdapter.cleanupEvents(lastId, table);
} else {
removeMessages(FLUSH_QUEUE);
mTrackEngageRetryAfter = Math.max((long)Math.pow(2, mFailedRetries) * 60000, mTrackEngageRetryAfter);
mTrackEngageRetryAfter = Math.min(mTrackEngageRetryAfter, 10 * 60 * 1000); // limit 10 min
sendEmptyMessageDelayed(FLUSH_QUEUE, mTrackEngageRetryAfter);
mFailedRetries++;
logAboutMessageToMixpanel("Retrying this batch of events in " + mTrackEngageRetryAfter + " ms");
break;
}
eventsData = dbAdapter.generateDataString(table);
if (eventsData != null) {
queueCount = Integer.valueOf(eventsData[2]);
}
}
}
private JSONObject getDefaultEventProperties()
throws JSONException {
final JSONObject ret = new JSONObject();
ret.put("mp_lib", "android");
ret.put("$lib_version", MPConfig.VERSION);
// For querying together with data from other libraries
ret.put("$os", "Android");
ret.put("$os_version", Build.VERSION.RELEASE == null ? "UNKNOWN" : Build.VERSION.RELEASE);
ret.put("$manufacturer", Build.MANUFACTURER == null ? "UNKNOWN" : Build.MANUFACTURER);
ret.put("$brand", Build.BRAND == null ? "UNKNOWN" : Build.BRAND);
ret.put("$model", Build.MODEL == null ? "UNKNOWN" : Build.MODEL);
try {
try {
final int servicesAvailable = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(mContext);
switch (servicesAvailable) {
case ConnectionResult.SUCCESS:
ret.put("$google_play_services", "available");
break;
case ConnectionResult.SERVICE_MISSING:
ret.put("$google_play_services", "missing");
break;
case ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED:
ret.put("$google_play_services", "out of date");
break;
case ConnectionResult.SERVICE_DISABLED:
ret.put("$google_play_services", "disabled");
break;
case ConnectionResult.SERVICE_INVALID:
ret.put("$google_play_services", "invalid");
break;
}
} catch (RuntimeException e) {
// Turns out even checking for the service will cause explosions
// unless we've set up meta-data
ret.put("$google_play_services", "not configured");
}
} catch (NoClassDefFoundError e) {
ret.put("$google_play_services", "not included");
}
final DisplayMetrics displayMetrics = mSystemInformation.getDisplayMetrics();
ret.put("$screen_dpi", displayMetrics.densityDpi);
ret.put("$screen_height", displayMetrics.heightPixels);
ret.put("$screen_width", displayMetrics.widthPixels);
final String applicationVersionName = mSystemInformation.getAppVersionName();
if (null != applicationVersionName) {
ret.put("$app_version", applicationVersionName);
ret.put("$app_version_string", applicationVersionName);
}
final Integer applicationVersionCode = mSystemInformation.getAppVersionCode();
if (null != applicationVersionCode) {
ret.put("$app_release", applicationVersionCode);
ret.put("$app_build_number", applicationVersionCode);
}
final Boolean hasNFC = mSystemInformation.hasNFC();
if (null != hasNFC)
ret.put("$has_nfc", hasNFC.booleanValue());
final Boolean hasTelephony = mSystemInformation.hasTelephony();
if (null != hasTelephony)
ret.put("$has_telephone", hasTelephony.booleanValue());
final String carrier = mSystemInformation.getCurrentNetworkOperator();
if (null != carrier)
ret.put("$carrier", carrier);
final Boolean isWifi = mSystemInformation.isWifiConnected();
if (null != isWifi)
ret.put("$wifi", isWifi.booleanValue());
final Boolean isBluetoothEnabled = mSystemInformation.isBluetoothEnabled();
if (isBluetoothEnabled != null)
ret.put("$bluetooth_enabled", isBluetoothEnabled);
final String bluetoothVersion = mSystemInformation.getBluetoothVersion();
if (bluetoothVersion != null)
ret.put("$bluetooth_version", bluetoothVersion);
return ret;
}
private JSONObject prepareEventObject(EventDescription eventDescription) throws JSONException {
final JSONObject eventObj = new JSONObject();
final JSONObject eventProperties = eventDescription.getProperties();
final JSONObject sendProperties = getDefaultEventProperties();
sendProperties.put("token", eventDescription.getToken());
if (eventProperties != null) {
for (final Iterator<?> iter = eventProperties.keys(); iter.hasNext();) {
final String key = (String) iter.next();
sendProperties.put(key, eventProperties.get(key));
}
}
eventObj.put("event", eventDescription.getEventName());
eventObj.put("properties", sendProperties);
return eventObj;
}
private MPDbAdapter mDbAdapter;
private final DecideChecker mDecideChecker;
private final long mFlushInterval;
private final boolean mDisableFallback;
private long mDecideRetryAfter;
private long mTrackEngageRetryAfter;
private int mFailedRetries;
}// AnalyticsMessageHandler
private void updateFlushFrequency() {
final long now = System.currentTimeMillis();
final long newFlushCount = mFlushCount + 1;
if (mLastFlushTime > 0) {
final long flushInterval = now - mLastFlushTime;
final long totalFlushTime = flushInterval + (mAveFlushFrequency * mFlushCount);
mAveFlushFrequency = totalFlushTime / newFlushCount;
final long seconds = mAveFlushFrequency / 1000;
logAboutMessageToMixpanel("Average send frequency approximately " + seconds + " seconds.");
}
mLastFlushTime = now;
mFlushCount = newFlushCount;
}
private final Object mHandlerLock = new Object();
private Handler mHandler;
private long mFlushCount = 0;
private long mAveFlushFrequency = 0;
private long mLastFlushTime = -1;
private SystemInformation mSystemInformation;
}
public long getTrackEngageRetryAfter() {
return ((Worker.AnalyticsMessageHandler) mWorker.mHandler).getTrackEngageRetryAfter();
}
/////////////////////////////////////////////////////////
// Used across thread boundaries
private final Worker mWorker;
protected final Context mContext;
protected final MPConfig mConfig;
// Messages for our thread
private static final int ENQUEUE_PEOPLE = 0; // submit events and people data
private static final int ENQUEUE_EVENTS = 1; // push given JSON message to people DB
private static final int FLUSH_QUEUE = 2; // push given JSON message to events DB
private static final int KILL_WORKER = 5; // Hard-kill the worker thread, discarding all events on the event queue. This is for testing, or disasters.
private static final int INSTALL_DECIDE_CHECK = 12; // Run this DecideCheck at intervals until it isDestroyed()
private static final int REGISTER_FOR_GCM = 13; // Register for GCM using Google Play Services
private static final String LOGTAG = "MixpanelAPI.Messages";
private static final Map<Context, AnalyticsMessages> sInstances = new HashMap<Context, AnalyticsMessages>();
}