package com.mixpanel.example.hello; import android.content.Context; import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.os.Message; import android.util.Log; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.BufferedReader; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.UUID; /** * This class serves as an example of how session tracking may be done on Android. The length of a session * is defined as the time between a call to startSession() and a call to endSession() after which there is * not another call to startSession() for at least 15 seconds. If a session has been started and * another startSession() function is called, it is a no op. * * This class is not officially supported by Mixpanel, and you may need to modify it for your own application. * * Example Usage: * * <pre> * {@code * public class MainActivity extends ActionBarActivity { * @Override * protected void onCreate(Bundle savedInstanceState) { * super.onCreate(savedInstanceState); * setContentView(R.layout.activity_main); * * this._sessionManager = SessionManager.getInstance(this, new SessionManager.SessionCompleteCallback() { * @Override * public void onSessionComplete(SessionManager.Session session) { * // You may send the session time to Mixpanel in here. * Log.d("MY APP", "session " + session.getUuid() + " lasted for " + * session.getSessionLength()/1000 + " seconds and is now closed"); * } * }); * this._sessionManager.startSession(); * } * * @Override * public void onResume() * { * super.onResume(); * this._sessionManager.startSession(); * } * * @Override * public void onPause() * { * super.onPause(); * this._sessionManager.endSession(); * } * * private SessionManager _sessionManager; * } * } * </pre> * */ public class SessionManager { /** * Instantiate a new SessionManager object * @param context * @param callback */ private SessionManager(Context context, SessionCompleteCallback callback) { this._appContext = context.getApplicationContext(); this._sessionCompleteCallback = callback; // this will be called any time a session is complete HandlerThread handlerThread = new HandlerThread(getClass().getCanonicalName()); handlerThread.start(); this._sessionHandler = new SessionHandler(this, handlerThread.getLooper()); this._sessionHandler.sendEmptyMessage(MESSAGE_INIT); } /** * Get the SessionManager singleton object, create on if one doesn't exist * @param context * @param callback * @return */ public static SessionManager getInstance(Context context, SessionCompleteCallback callback) { if (_instance == null) { _instance = new SessionManager(context, callback); } return _instance; } /** * Dispatch request to handler thread to start a session */ public void startSession() { _sessionHandler.sendEmptyMessage(MESSAGE_START_SESSION); } /** * Dispatch request to handler thread to end a session */ public void endSession() { _sessionHandler.sendEmptyMessage(MESSAGE_END_SESSION); } /** * Called by the handler thread, this will resume the previous session if it ended within * the given threshold otherwise it'll create a new session. If a session already exists, * it will be a noop. */ private void _startSession() { if (_curSession == null) { if (_prevSession != null && !_prevSession.isExpired()) { Log.d(LOGTAG, "resuming session " + _prevSession.getUuid()); _curSession = _prevSession; _curSession.resume(); _prevSession = null; } else { _curSession = new Session(); Log.d(LOGTAG, "creating new session " + _curSession.getUuid()); synchronized (_sessionsLock) { _sessions.add(_curSession); _writeSessionsToFile(); this._initSessionCompleter(); } } } } /** * Takes the current session, sets the end time, and sets it as the previous session. */ private void _endSession() { if (_curSession != null) { _curSession.end(); _prevSession = _curSession; _curSession = null; } } /** * Spawns a thread to monitor for sessions that need to be completed (ended sessions that are * guaranteed not to be resumed). If one is already running, this will be a noop. */ private void _initSessionCompleter() { if (_sessionCompleterThread == null || !_sessionCompleterThread.isAlive()) { _sessionCompleterThread = new Thread() { @Override public void run() { try { while (true) { _completeExpiredSessions(); sleep(1000); } } catch (InterruptedException e) { Log.e(LOGTAG, "expiration watcher thread interrupted", e); } } private void _completeExpiredSessions() { Log.d(LOGTAG, "checking for expired sessions..."); synchronized(_sessionsLock) { Iterator<Session> iterator = _sessions.iterator(); while (iterator.hasNext()) { Session session = iterator.next(); if (session.isExpired()) { Log.d(LOGTAG, "expiring session id " + session.getUuid()); iterator.remove(); _writeSessionsToFile(); _sessionCompleteCallback.onSessionComplete(session); } else { Log.d(LOGTAG, "session id " + session.getUuid() + " not yet expired..."); } } } } }; _sessionCompleterThread.start(); } } /** * Loads any previously non-completed sessions from local disk. This is necessary to guarantee * that sessions are eventually completed when an app is hard-killed or crashes */ private void _loadSessionsFromFile() { FileInputStream fis = null; try { fis = _appContext.openFileInput(SESSIONS_FILE_NAME); InputStreamReader isr = new InputStreamReader(fis); BufferedReader bufferedReader = new BufferedReader(isr); StringBuilder sb = new StringBuilder(); String line; while ((line = bufferedReader.readLine()) != null) { sb.append(line); } JSONArray sessionsJson = new JSONArray(sb.toString()); synchronized(_sessionsLock) { for (int i = 0; i < sessionsJson.length(); i++) { JSONObject sessionsObj = sessionsJson.getJSONObject(i); Session session = new Session(sessionsObj); // if there are sessions that don't have an end time we must assume that the // app was killed mid session so we'll just send now as the end time. The better // solution would be to periodically mark a "lastAccessTime" that can be used // in such a case. if (session.getEndTime() == null) { session.end(); } _sessions.add(session); } if (_sessions.size() > 0) { this._initSessionCompleter(); } } } catch (FileNotFoundException e) { Log.e(LOGTAG, "Could not find sessions file", e); } catch (IOException e) { Log.e(LOGTAG, "Could not read from sessions file", e); } catch (JSONException e) { Log.e(LOGTAG, "Could not serialize json string from file", e); } } /** * Writes the current sessions list to local disk. This is so we have a persistent snapshot * of non-completed sessions that can be reloaded in case of app shutdown / crash. */ private void _writeSessionsToFile() { FileOutputStream fos = null; try { fos = _appContext.openFileOutput(SESSIONS_FILE_NAME, Context.MODE_PRIVATE); JSONArray jsonArray = new JSONArray(); for (Session session : _sessions) { jsonArray.put(session.toJSON()); } fos.write(jsonArray.toString().getBytes()); fos.close(); } catch (FileNotFoundException e) { Log.e(LOGTAG, "Could not find sessions file", e); } catch (IOException e) { Log.e(LOGTAG, "Could not write to sessions file", e); } catch (JSONException e) { Log.e(LOGTAG, "Could not turn session to JSON", e); } } public class Session { private String uuid = null; private Long startTime = null; private Long endTime = null; private Long sessionExpirationGracePeriod = 15000L; public Session() { this.uuid = UUID.randomUUID().toString(); this.startTime = System.currentTimeMillis(); } public Session(JSONObject jsonObject) throws JSONException { this.uuid = jsonObject.getString("uuid"); this.startTime = jsonObject.getLong("startTime"); if (jsonObject.has("endTime")) { this.endTime = jsonObject.getLong("endTime"); } this.sessionExpirationGracePeriod = jsonObject.getLong("sessionExpirationGracePeriod"); } public JSONObject toJSON() throws JSONException { JSONObject jsonObject = new JSONObject(); jsonObject.put("uuid", this.uuid); jsonObject.put("startTime", this.startTime); jsonObject.put("endTime", this.endTime); jsonObject.put("sessionExpirationGracePeriod", this.sessionExpirationGracePeriod); return jsonObject; } public void resume() { this.endTime = null; } public void end() { this.endTime = System.currentTimeMillis(); } public boolean isExpired() { return this.endTime != null && System.currentTimeMillis() > this.endTime + this.sessionExpirationGracePeriod; } public Long getSessionLength() { if (this.endTime != null) { return this.endTime - this.startTime; } else { return System.currentTimeMillis() - this.startTime; } } public String getUuid() { return uuid; } public Long getStartTime() { return startTime; } public Long getEndTime() { return endTime; } public Long getSessionExpirationGracePeriod() { return sessionExpirationGracePeriod; } } public interface SessionCompleteCallback { public void onSessionComplete(Session session); } /** * Handler thread responsible for all session interaction */ public class SessionHandler extends Handler { private SessionManager sessionManager; public SessionHandler(SessionManager sessionManager, Looper looper) { super(looper); this.sessionManager = sessionManager; } @Override public void handleMessage(Message msg) { switch(msg.what) { case MESSAGE_INIT: sessionManager._loadSessionsFromFile(); break; case MESSAGE_START_SESSION: sessionManager._startSession(); break; case MESSAGE_END_SESSION: sessionManager._endSession(); break; } } } private static String LOGTAG = "SessionManager"; private static String SESSIONS_FILE_NAME = "user_sessions"; private static final int MESSAGE_INIT = 0; private static final int MESSAGE_START_SESSION = 1; private static final int MESSAGE_END_SESSION = 2; private static SessionManager _instance = null; private static final Object[] _sessionsLock = new Object[0]; private List<Session> _sessions = new ArrayList<Session>(); private Session _curSession = null; private Session _prevSession = null; private SessionHandler _sessionHandler; private Context _appContext = null; private Thread _sessionCompleterThread = null; private final SessionCompleteCallback _sessionCompleteCallback; }