diff --git a/acceptance/test-application/src/com/mixpanel/example/hello/MainActivity.java b/acceptance/test-application/src/com/mixpanel/example/hello/MainActivity.java index 17cc74c79..64a73af9e 100644 --- a/acceptance/test-application/src/com/mixpanel/example/hello/MainActivity.java +++ b/acceptance/test-application/src/com/mixpanel/example/hello/MainActivity.java @@ -1,254 +1,240 @@ package com.mixpanel.example.hello; import android.app.Activity; -import android.content.ContentResolver; -import android.content.Intent; import android.content.SharedPreferences; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.drawable.BitmapDrawable; -import android.net.Uri; import android.os.Bundle; import android.util.Base64; -import android.util.Log; import android.view.Menu; import android.view.View; import android.widget.EditText; - import com.mixpanel.android.mpmetrics.MixpanelAPI; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.FileNotFoundException; -import java.io.InputStream; import java.util.Calendar; import java.util.Date; +import org.json.JSONException; +import org.json.JSONObject; /** * A little application that allows people to update their Mixpanel information. * - * For more information about integrating Mixpanel with your Android application, - * please check out: + *

For more information about integrating Mixpanel with your Android application, please check + * out: * - * https://mixpanel.com/docs/integration-libraries/android + *

https://mixpanel.com/docs/integration-libraries/android * * @author mixpanel - * */ public class MainActivity extends Activity { - /* - * You will use a Mixpanel API token to allow your app to send data to Mixpanel. To get your token - * - Log in to Mixpanel, and select the project you want to use for this application - * - Click the gear icon in the lower left corner of the screen to view the settings dialog - * - In the settings dialog, you will see the label "Token", and a string that looks something like this: - * - * 2ef3c08e8466df98e67ea0cfa1512e9f - * - * Paste it below (where you see "YOUR API TOKEN") - */ - public static final String MIXPANEL_API_TOKEN = "NOT A REAL TOKEN"; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - final String trackingDistinctId = getTrackingDistinctId(); - - // Initialize the Mixpanel library for tracking. - mMixpanel = MixpanelAPI.getInstance(this, MIXPANEL_API_TOKEN); - - - // We also identify the current user with a distinct ID. - - mMixpanel.identify(trackingDistinctId); //this is the distinct_id value that - // will be sent with events. If you choose not to set this, - // the SDK will generate one for you - - mMixpanel.getPeople().identify(trackingDistinctId); //this is the distinct_id - // that will be used for people analytics. You must set this explicitly in order - // to dispatch people data. - - // People analytics must be identified separately from event analytics. - // The data-sets are separate, and may have different unique keys (distinct_id). - // We recommend using the same distinct_id value for a given user in both, - // and identifying the user with that id as early as possible. - - setContentView(R.layout.activity_main); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.activity_main, menu); - return true; + /* + * You will use a Mixpanel API token to allow your app to send data to Mixpanel. To get your token + * - Log in to Mixpanel, and select the project you want to use for this application + * - Click the gear icon in the lower left corner of the screen to view the settings dialog + * - In the settings dialog, you will see the label "Token", and a string that looks something like this: + * + * 2ef3c08e8466df98e67ea0cfa1512e9f + * + * Paste it below (where you see "YOUR API TOKEN") + */ + public static final String MIXPANEL_API_TOKEN = "NOT A REAL TOKEN"; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final String trackingDistinctId = getTrackingDistinctId(); + + // Initialize the Mixpanel library for tracking. + mMixpanel = MixpanelAPI.getInstance(this, MIXPANEL_API_TOKEN); + + // We also identify the current user with a distinct ID. + + mMixpanel.identify(trackingDistinctId); // this is the distinct_id value that + // will be sent with events. If you choose not to set this, + // the SDK will generate one for you + + mMixpanel.getPeople().identify(trackingDistinctId); // this is the distinct_id + // that will be used for people analytics. You must set this explicitly in order + // to dispatch people data. + + // People analytics must be identified separately from event analytics. + // The data-sets are separate, and may have different unique keys (distinct_id). + // We recommend using the same distinct_id value for a given user in both, + // and identifying the user with that id as early as possible. + + setContentView(R.layout.activity_main); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.activity_main, menu); + return true; + } + + @Override + protected void onResume() { + super.onResume(); + + final long nowInHours = hoursSinceEpoch(); + final int hourOfTheDay = hourOfTheDay(); + + // For our simple test app, we're interested tracking + // when the user views our application. + + // It will be interesting to segment our data by the date that they + // first viewed our app. We use a + // superProperty (so the value will always be sent with the + // remainder of our events) and register it with + // registerSuperPropertiesOnce (so no matter how many times + // the code below is run, the events will always be sent + // with the value of the first ever call for this user.) + // all the change we make below are LOCAL. No API requests are made. + try { + final JSONObject properties = new JSONObject(); + properties.put("first viewed on", nowInHours); + properties.put("user domain", "(unknown)"); // default value + mMixpanel.registerSuperPropertiesOnce(properties); + } catch (final JSONException e) { + throw new RuntimeException("Could not encode hour first viewed as JSON"); } - @Override - protected void onResume() { - super.onResume(); - - final long nowInHours = hoursSinceEpoch(); - final int hourOfTheDay = hourOfTheDay(); - - // For our simple test app, we're interested tracking - // when the user views our application. - - // It will be interesting to segment our data by the date that they - // first viewed our app. We use a - // superProperty (so the value will always be sent with the - // remainder of our events) and register it with - // registerSuperPropertiesOnce (so no matter how many times - // the code below is run, the events will always be sent - // with the value of the first ever call for this user.) - // all the change we make below are LOCAL. No API requests are made. - try { - final JSONObject properties = new JSONObject(); - properties.put("first viewed on", nowInHours); - properties.put("user domain", "(unknown)"); // default value - mMixpanel.registerSuperPropertiesOnce(properties); - } catch (final JSONException e) { - throw new RuntimeException("Could not encode hour first viewed as JSON"); - } - - // Now we send an event to Mixpanel. We want to send a new - // "App Resumed" event every time we are resumed, and - // we want to send a current value of "hour of the day" for every event. - // As usual,all of the user's super properties will be appended onto this event. - try { - final JSONObject properties = new JSONObject(); - properties.put("hour of the day", hourOfTheDay); - mMixpanel.track("App Resumed", properties); - } catch(final JSONException e) { - throw new RuntimeException("Could not encode hour of the day in JSON"); - } - mMixpanel.getPeople().addOnMixpanelUpdatesReceivedListener(listener); + // Now we send an event to Mixpanel. We want to send a new + // "App Resumed" event every time we are resumed, and + // we want to send a current value of "hour of the day" for every event. + // As usual,all of the user's super properties will be appended onto this event. + try { + final JSONObject properties = new JSONObject(); + properties.put("hour of the day", hourOfTheDay); + mMixpanel.track("App Resumed", properties); + } catch (final JSONException e) { + throw new RuntimeException("Could not encode hour of the day in JSON"); } - - // Associated with the "Send to Mixpanel" button in activity_main.xml - // In this method, we update a Mixpanel people profile using MixpanelAPI.People.set() - // and set some persistent properties that will be sent with - // all future track() calls using MixpanelAPI.registerSuperProperties() - public void sendToMixpanel(final View view) { - - final EditText firstNameEdit = (EditText) findViewById(R.id.edit_first_name); - final EditText lastNameEdit = (EditText) findViewById(R.id.edit_last_name); - final EditText emailEdit = (EditText) findViewById(R.id.edit_email_address); - - final String firstName = firstNameEdit.getText().toString(); - final String lastName = lastNameEdit.getText().toString(); - final String email = emailEdit.getText().toString(); - - final MixpanelAPI.People people = mMixpanel.getPeople(); - - // Update the basic data in the user's People Analytics record. - // Unlike events, People Analytics always stores the most recent value - // provided. - people.set("$first_name", firstName); - people.set("$last_name", lastName); - people.set("$email", email); - - // We also want to keep track of how many times the user - // has updated their info. - people.increment("Update Count", 1L); - - // Mixpanel events are separate from Mixpanel people records, - // but it might be valuable to be able to query events by - // user domain (for example, if they represent customer organizations). - // - // We use the user domain as a superProperty here, but we call registerSuperProperties - // instead of registerSuperPropertiesOnce so we can overwrite old values - // as we get new information. - try { - final JSONObject domainProperty = new JSONObject(); - domainProperty.put("user domain", domainFromEmailAddress(email)); - mMixpanel.registerSuperProperties(domainProperty); - } catch (final JSONException e) { - throw new RuntimeException("Cannot write user email address domain as a super property"); - } - - // In addition to viewing the updated record in mixpanel's UI, it might - // be interesting to see when and how many and what types of users - // are updating their information, so we'll send an event as well. - // You can call track with null if you don't have any properties to add - // to an event (remember all the established superProperties will be added - // before the event is dispatched to Mixpanel) - mMixpanel.track("update info button clicked", null); + mMixpanel.getPeople().addOnMixpanelUpdatesReceivedListener(listener); + } + + // Associated with the "Send to Mixpanel" button in activity_main.xml + // In this method, we update a Mixpanel people profile using MixpanelAPI.People.set() + // and set some persistent properties that will be sent with + // all future track() calls using MixpanelAPI.registerSuperProperties() + public void sendToMixpanel(final View view) { + + final EditText firstNameEdit = (EditText) findViewById(R.id.edit_first_name); + final EditText lastNameEdit = (EditText) findViewById(R.id.edit_last_name); + final EditText emailEdit = (EditText) findViewById(R.id.edit_email_address); + + final String firstName = firstNameEdit.getText().toString(); + final String lastName = lastNameEdit.getText().toString(); + final String email = emailEdit.getText().toString(); + + final MixpanelAPI.People people = mMixpanel.getPeople(); + + // Update the basic data in the user's People Analytics record. + // Unlike events, People Analytics always stores the most recent value + // provided. + people.set("$first_name", firstName); + people.set("$last_name", lastName); + people.set("$email", email); + + // We also want to keep track of how many times the user + // has updated their info. + people.increment("Update Count", 1L); + + // Mixpanel events are separate from Mixpanel people records, + // but it might be valuable to be able to query events by + // user domain (for example, if they represent customer organizations). + // + // We use the user domain as a superProperty here, but we call registerSuperProperties + // instead of registerSuperPropertiesOnce so we can overwrite old values + // as we get new information. + try { + final JSONObject domainProperty = new JSONObject(); + domainProperty.put("user domain", domainFromEmailAddress(email)); + mMixpanel.registerSuperProperties(domainProperty); + } catch (final JSONException e) { + throw new RuntimeException("Cannot write user email address domain as a super property"); } - // This is an example of how you can use Mixpanel's revenue tracking features from Android. - public void recordRevenue(final View view) { - final MixpanelAPI.People people = mMixpanel.getPeople(); - // Call trackCharge() with a floating point amount - // (for example, the amount of money the user has just spent on a purchase) - // and an optional set of properties describing the purchase. - people.trackCharge(1.50, null); + // In addition to viewing the updated record in mixpanel's UI, it might + // be interesting to see when and how many and what types of users + // are updating their information, so we'll send an event as well. + // You can call track with null if you don't have any properties to add + // to an event (remember all the established superProperties will be added + // before the event is dispatched to Mixpanel) + mMixpanel.track("update info button clicked", null); + } + + // This is an example of how you can use Mixpanel's revenue tracking features from Android. + public void recordRevenue(final View view) { + final MixpanelAPI.People people = mMixpanel.getPeople(); + // Call trackCharge() with a floating point amount + // (for example, the amount of money the user has just spent on a purchase) + // and an optional set of properties describing the purchase. + people.trackCharge(1.50, null); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + // To preserve battery life, the Mixpanel library will store + // events rather than send them immediately. This means it + // is important to call flush() to send any unsent events + // before your application is taken out of memory. + mMixpanel.flush(); + } + + //////////////////////////////////////////////////// + + private String getTrackingDistinctId() { + final SharedPreferences prefs = getPreferences(MODE_PRIVATE); + + String ret = prefs.getString(MIXPANEL_DISTINCT_ID_NAME, null); + if (ret == null) { + ret = generateDistinctId(); + final SharedPreferences.Editor prefsEditor = prefs.edit(); + prefsEditor.putString(MIXPANEL_DISTINCT_ID_NAME, ret); + prefsEditor.commit(); } - @Override - protected void onDestroy() { - super.onDestroy(); - - // To preserve battery life, the Mixpanel library will store - // events rather than send them immediately. This means it - // is important to call flush() to send any unsent events - // before your application is taken out of memory. - mMixpanel.flush(); + return ret; + } + + // These disinct ids are here for the purposes of illustration. + // In practice, there are great advantages to using distinct ids that + // are easily associated with user identity, either from server-side + // sources, or user logins. A common best practice is to maintain a field + // in your users table to store mixpanel distinct_id, so it is easily + // accesible for use in attributing cross platform or server side events. + private String generateDistinctId() { + final SecureRandom random = new SecureRandom(); + final byte[] randomBytes = new byte[32]; + random.nextBytes(randomBytes); + return Base64.encodeToString(randomBytes, Base64.NO_WRAP | Base64.NO_PADDING); + } + + /////////////////////////////////////////////////////// + // conveniences + + private int hourOfTheDay() { + final Calendar calendar = Calendar.getInstance(); + return calendar.get(Calendar.HOUR_OF_DAY); + } + + private long hoursSinceEpoch() { + final Date now = new Date(); + final long nowMillis = now.getTime(); + return nowMillis / 1000 * 60 * 60; + } + + private String domainFromEmailAddress(String email) { + String ret = ""; + final int atSymbolIndex = email.indexOf('@'); + if ((atSymbolIndex > -1) && (email.length() > atSymbolIndex)) { + ret = email.substring(atSymbolIndex + 1); } - //////////////////////////////////////////////////// - - private String getTrackingDistinctId() { - final SharedPreferences prefs = getPreferences(MODE_PRIVATE); - - String ret = prefs.getString(MIXPANEL_DISTINCT_ID_NAME, null); - if (ret == null) { - ret = generateDistinctId(); - final SharedPreferences.Editor prefsEditor = prefs.edit(); - prefsEditor.putString(MIXPANEL_DISTINCT_ID_NAME, ret); - prefsEditor.commit(); - } - - return ret; - } - - // These disinct ids are here for the purposes of illustration. - // In practice, there are great advantages to using distinct ids that - // are easily associated with user identity, either from server-side - // sources, or user logins. A common best practice is to maintain a field - // in your users table to store mixpanel distinct_id, so it is easily - // accesible for use in attributing cross platform or server side events. - private String generateDistinctId() { - final SecureRandom random = new SecureRandom(); - final byte[] randomBytes = new byte[32]; - random.nextBytes(randomBytes); - return Base64.encodeToString(randomBytes, Base64.NO_WRAP | Base64.NO_PADDING); - } - - /////////////////////////////////////////////////////// - // conveniences - - private int hourOfTheDay() { - final Calendar calendar = Calendar.getInstance(); - return calendar.get(Calendar.HOUR_OF_DAY); - } - - private long hoursSinceEpoch() { - final Date now = new Date(); - final long nowMillis = now.getTime(); - return nowMillis / 1000 * 60 * 60; - } - - private String domainFromEmailAddress(String email) { - String ret = ""; - final int atSymbolIndex = email.indexOf('@'); - if ((atSymbolIndex > -1) && (email.length() > atSymbolIndex)) { - ret = email.substring(atSymbolIndex + 1); - } - - return ret; - } + return ret; + } - private MixpanelAPI mMixpanel; - private static final String MIXPANEL_DISTINCT_ID_NAME = "Mixpanel Example $distinctid"; - private static final String LOGTAG = "Mixpanel Example Application"; + private MixpanelAPI mMixpanel; + private static final String MIXPANEL_DISTINCT_ID_NAME = "Mixpanel Example $distinctid"; + private static final String LOGTAG = "Mixpanel Example Application"; } diff --git a/acceptance/test-application/src/com/mixpanel/example/hello/SessionManager.java b/acceptance/test-application/src/com/mixpanel/example/hello/SessionManager.java index 0c8602d20..e1668bb8d 100644 --- a/acceptance/test-application/src/com/mixpanel/example/hello/SessionManager.java +++ b/acceptance/test-application/src/com/mixpanel/example/hello/SessionManager.java @@ -6,11 +6,6 @@ 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; @@ -21,355 +16,349 @@ import java.util.Iterator; import java.util.List; import java.util.UUID; - +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; /** - * 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 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. + *

This class is not officially supported by Mixpanel, and you may need to modify it for your own + * application. * - * Example Usage: + *

Example Usage: * - *

- * {@code
- *  public class MainActivity extends ActionBarActivity {
- *      @Override
- *      protected void onCreate(Bundle savedInstanceState) {
- *          super.onCreate(savedInstanceState);
- *          setContentView(R.layout.activity_main);
+ * 
{@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();
- *      }
+ *         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 onResume()
+ *     {
+ *         super.onResume();
+ *         this._sessionManager.startSession();
+ *     }
  *
- *      @Override
- *      public void onPause()
- *      {
- *          super.onPause();
- *          this._sessionManager.endSession();
- *      }
+ *     @Override
+ *     public void onPause()
+ *     {
+ *         super.onPause();
+ *         this._sessionManager.endSession();
+ *     }
  *
- *      private SessionManager _sessionManager;
- *  }
+ *     private SessionManager _sessionManager;
  * }
- * 
- * + * }
*/ 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); + /** + * 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); } - - /** - * 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(); } - 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); + } + + /** 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); + } + } - /** - * 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); + private void _completeExpiredSessions() { + Log.d(LOGTAG, "checking for expired sessions..."); + synchronized (_sessionsLock) { + Iterator iterator = _sessions.iterator(); + while (iterator.hasNext()) { + Session session = iterator.next(); + if (session.isExpired()) { + Log.d(LOGTAG, "expiring session id " + session.getUuid()); + iterator.remove(); _writeSessionsToFile(); - this._initSessionCompleter(); + _sessionCompleteCallback.onSessionComplete(session); + } else { + Log.d(LOGTAG, "session id " + session.getUuid() + " not yet expired..."); + } } + } } - } + }; + _sessionCompleterThread.start(); } - - /** - * 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; + } + + /** + * 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); } + } - /** - * 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); - } - } + public class Session { + private String uuid = null; + private Long startTime = null; + private Long endTime = null; + private Long sessionExpirationGracePeriod = 15000L; - private void _completeExpiredSessions() { - Log.d(LOGTAG, "checking for expired sessions..."); - synchronized(_sessionsLock) { - Iterator 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(); - } + public Session() { + this.uuid = UUID.randomUUID().toString(); + this.startTime = System.currentTimeMillis(); } - /** - * 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); - } + 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"); } - /** - * 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 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 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 void resume() { + this.endTime = null; + } - public boolean isExpired() { - return this.endTime != null && System.currentTimeMillis() > this.endTime + this.sessionExpirationGracePeriod; - } + public void end() { + this.endTime = System.currentTimeMillis(); + } - public Long getSessionLength() { - if (this.endTime != null) { - return this.endTime - this.startTime; - } else { - return System.currentTimeMillis() - this.startTime; - } - } + public boolean isExpired() { + return this.endTime != null + && System.currentTimeMillis() > this.endTime + this.sessionExpirationGracePeriod; + } - public String getUuid() { - return uuid; - } + public Long getSessionLength() { + if (this.endTime != null) { + return this.endTime - this.startTime; + } else { + return System.currentTimeMillis() - this.startTime; + } + } - public Long getStartTime() { - return startTime; - } + public String getUuid() { + return uuid; + } - public Long getEndTime() { - return endTime; - } + public Long getStartTime() { + return startTime; + } - public Long getSessionExpirationGracePeriod() { - return sessionExpirationGracePeriod; - } + public Long getEndTime() { + return endTime; } - public interface SessionCompleteCallback { - public void onSessionComplete(Session session); + public Long getSessionExpirationGracePeriod() { + return sessionExpirationGracePeriod; } + } - /** - * Handler thread responsible for all session interaction - */ - public class SessionHandler extends Handler { - private SessionManager sessionManager; + public interface SessionCompleteCallback { + public void onSessionComplete(Session session); + } - public SessionHandler(SessionManager sessionManager, Looper looper) { - super(looper); - this.sessionManager = sessionManager; - } + /** Handler thread responsible for all session interaction */ + public class SessionHandler extends Handler { + private 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; - } - } + public SessionHandler(SessionManager sessionManager, Looper looper) { + super(looper); + this.sessionManager = sessionManager; } - 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 _sessions = new ArrayList(); - private Session _curSession = null; - private Session _prevSession = null; - private SessionHandler _sessionHandler; - private Context _appContext = null; - private Thread _sessionCompleterThread = null; - private final SessionCompleteCallback _sessionCompleteCallback; + @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 _sessions = new ArrayList(); + private Session _curSession = null; + private Session _prevSession = null; + private SessionHandler _sessionHandler; + private Context _appContext = null; + private Thread _sessionCompleterThread = null; + private final SessionCompleteCallback _sessionCompleteCallback; } diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/AutomaticEventsTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/AutomaticEventsTest.java index 85ac728f2..beab22cd6 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/AutomaticEventsTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/AutomaticEventsTest.java @@ -1,330 +1,394 @@ package com.mixpanel.android.mpmetrics; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + import android.content.Context; import android.content.SharedPreferences; import android.os.Handler; import android.os.HandlerThread; import android.os.Process; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; import androidx.test.platform.app.InstrumentationRegistry; - import com.mixpanel.android.util.Base64Coder; import com.mixpanel.android.util.HttpService; import com.mixpanel.android.util.ProxyServerInteractor; import com.mixpanel.android.util.RemoteService; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; - import javax.net.ssl.SSLSocketFactory; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; -/** - * Created by sergioalonso on 5/16/17. - */ - +/** Created by sergioalonso on 5/16/17. */ @RunWith(AndroidJUnit4.class) @SmallTest public class AutomaticEventsTest { - private MixpanelAPI mCleanMixpanelAPI; - private static final String TOKEN = "Automatic Events Token"; - private static final int MAX_TIMEOUT_POLL = 6500; - final private BlockingQueue mPerformRequestEvents = new LinkedBlockingQueue<>(); - private Future mMockReferrerPreferences; - private int mTrackedEvents; - private CountDownLatch mLatch = new CountDownLatch(1); - private MPDbAdapter mockAdapter; - private CountDownLatch mMinRequestsLatch; - - @Before - public void setUp() { - mMockReferrerPreferences = new TestUtils.EmptyPreferences(InstrumentationRegistry.getInstrumentation().getContext()); - mTrackedEvents = 0; - mMinRequestsLatch = new CountDownLatch(2); // First Time Open and Update - } - - private void setUpInstance(boolean trackAutomaticEvents) { - final RemoteService mockPoster = new HttpService() { - @Override - public byte[] performRequest( - @NonNull String endpointUrl, - @Nullable ProxyServerInteractor interactor, - @Nullable Map params, // Used only if requestBodyBytes is null - @Nullable Map headers, - @Nullable byte[] requestBodyBytes, // If provided, send this as raw body - @Nullable SSLSocketFactory socketFactory) - { - - final String jsonData = Base64Coder.decodeString(params.get("data").toString()); - assertTrue(params.containsKey("data")); - try { - JSONArray jsonArray = new JSONArray(jsonData); - for (int i = 0; i < jsonArray.length(); i++) { - mPerformRequestEvents.put(jsonArray.getJSONObject(i).getString("event")); - mMinRequestsLatch.countDown(); - } - return TestUtils.bytes("1\n"); - } catch (JSONException e) { - throw new RuntimeException("Malformed data passed to test mock", e); - } catch (InterruptedException e) { - throw new RuntimeException("Could not write message to reporting queue for tests.", e); - } - } - }; - - InstrumentationRegistry.getInstrumentation().getContext().deleteDatabase("mixpanel"); - mockAdapter = new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public void cleanupEvents(String last_id, Table table, String token) { - if (token.equalsIgnoreCase(TOKEN)) { - super.cleanupEvents(last_id, table, token); - } - } - - @Override - public int addJSON(JSONObject j, String token, Table table) { - if (token.equalsIgnoreCase(TOKEN)) { - mTrackedEvents++; - mLatch.countDown(); - return super.addJSON(j, token, table); - } - - return 1; + private MixpanelAPI mCleanMixpanelAPI; + private static final String TOKEN = "Automatic Events Token"; + private static final int MAX_TIMEOUT_POLL = 6500; + private final BlockingQueue mPerformRequestEvents = new LinkedBlockingQueue<>(); + private Future mMockReferrerPreferences; + private int mTrackedEvents; + private CountDownLatch mLatch = new CountDownLatch(1); + private MPDbAdapter mockAdapter; + private CountDownLatch mMinRequestsLatch; + + @Before + public void setUp() { + mMockReferrerPreferences = + new TestUtils.EmptyPreferences(InstrumentationRegistry.getInstrumentation().getContext()); + mTrackedEvents = 0; + mMinRequestsLatch = new CountDownLatch(2); // First Time Open and Update + } + + private void setUpInstance(boolean trackAutomaticEvents) { + final RemoteService mockPoster = + new HttpService() { + @Override + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) { + + final String jsonData = Base64Coder.decodeString(params.get("data").toString()); + assertTrue(params.containsKey("data")); + try { + JSONArray jsonArray = new JSONArray(jsonData); + for (int i = 0; i < jsonArray.length(); i++) { + mPerformRequestEvents.put(jsonArray.getJSONObject(i).getString("event")); + mMinRequestsLatch.countDown(); + } + return TestUtils.bytes("1\n"); + } catch (JSONException e) { + throw new RuntimeException("Malformed data passed to test mock", e); + } catch (InterruptedException e) { + throw new RuntimeException( + "Could not write message to reporting queue for tests.", e); } + } }; - final AnalyticsMessages automaticAnalyticsMessages = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - - @Override - protected RemoteService getPoster() { - return mockPoster; + InstrumentationRegistry.getInstrumentation().getContext().deleteDatabase("mixpanel"); + mockAdapter = + new MPDbAdapter( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public void cleanupEvents(String last_id, Table table, String token) { + if (token.equalsIgnoreCase(TOKEN)) { + super.cleanupEvents(last_id, table, token); } - - @Override - protected MPDbAdapter makeDbAdapter(Context context) { - return mockAdapter; + } + + @Override + public int addJSON(JSONObject j, String token, Table table) { + if (token.equalsIgnoreCase(TOKEN)) { + mTrackedEvents++; + mLatch.countDown(); + return super.addJSON(j, token, table); } - @Override - protected Worker createWorker() { - return new Worker() { - @Override - protected Handler restartWorkerThread() { - final HandlerThread thread = new HandlerThread("com.mixpanel.android.AnalyticsWorker", Process.THREAD_PRIORITY_BACKGROUND); - thread.start(); - final Handler ret = new AnalyticsMessageHandler(thread.getLooper()) { - }; - return ret; - } - }; - } + return 1; + } }; - mCleanMixpanelAPI = new MixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockReferrerPreferences, TOKEN, false, null, trackAutomaticEvents) { - - @Override - /* package */ PersistentIdentity getPersistentIdentity(final Context context, final Future referrerPreferences, final String token, final String instanceName) { - String instanceKey = instanceName != null ? instanceName : token; - final String prefsName = "com.mixpanel.android.mpmetrics.MixpanelAPI_" + instanceKey; - final SharedPreferences ret = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE); - ret.edit().clear().commit(); - - final String timeEventsPrefsName = "com.mixpanel.android.mpmetrics.MixpanelAPI.TimeEvents_" + instanceKey; - final SharedPreferences timeSharedPrefs = context.getSharedPreferences(timeEventsPrefsName, Context.MODE_PRIVATE); - timeSharedPrefs.edit().clear().commit(); - - final String mixpanelPrefsName = "com.mixpanel.android.mpmetrics.Mixpanel"; - final SharedPreferences mpSharedPrefs = context.getSharedPreferences(mixpanelPrefsName, Context.MODE_PRIVATE); - mpSharedPrefs.edit().clear().putInt("latest_version_code", -2).commit(); // -1 is the default value - - return super.getPersistentIdentity(context, referrerPreferences, token, instanceName); - } - - @Override - AnalyticsMessages getAnalyticsMessages() { - return automaticAnalyticsMessages; - } + final AnalyticsMessages automaticAnalyticsMessages = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + + @Override + protected RemoteService getPoster() { + return mockPoster; + } + + @Override + protected MPDbAdapter makeDbAdapter(Context context) { + return mockAdapter; + } + + @Override + protected Worker createWorker() { + return new Worker() { + @Override + protected Handler restartWorkerThread() { + final HandlerThread thread = + new HandlerThread( + "com.mixpanel.android.AnalyticsWorker", Process.THREAD_PRIORITY_BACKGROUND); + thread.start(); + final Handler ret = new AnalyticsMessageHandler(thread.getLooper()) {}; + return ret; + } + }; + } }; - } - - @After - public void tearDown() throws Exception { - mMinRequestsLatch.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS); - } - @Test - public void testAutomaticOneInstance() throws InterruptedException { - int calls = 3; // First Time Open, App Update, An Event One - mLatch = new CountDownLatch(calls); - setUpInstance(true); - mCleanMixpanelAPI.track("An event One"); - mCleanMixpanelAPI.flush(); - assertTrue(mLatch.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals(calls, mTrackedEvents); - assertEquals(AutomaticEvents.FIRST_OPEN, mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals(AutomaticEvents.APP_UPDATED, mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals("An event One", mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals(null, mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - } - - @Test - public void testDisableAutomaticEvents() throws InterruptedException { - int calls = 1; - setUpInstance(false); - mLatch = new CountDownLatch(calls); - mCleanMixpanelAPI.track("An Event Three"); - assertTrue(mLatch.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals(calls, mTrackedEvents); - - mCleanMixpanelAPI.track("Automatic Event Two", null, true); // dropped - mCleanMixpanelAPI.track("Automatic Event Three", null, true); // dropped - mCleanMixpanelAPI.track("Automatic Event Four", null, true); // dropped - mCleanMixpanelAPI.flush(); - assertEquals("An Event Three", mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals(null, mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - - String[] noEvents = mockAdapter.generateDataString(MPDbAdapter.Table.EVENTS, TOKEN); - assertNull(noEvents); - - mCleanMixpanelAPI.flush(); - assertEquals(null, mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - } - - @Test - public void testAutomaticMultipleInstances() throws InterruptedException { - final String SECOND_TOKEN = "Automatic Events Token Two"; - int initialCalls = 2; - setUpInstance(true); - mLatch = new CountDownLatch(initialCalls); - - final CountDownLatch secondLatch = new CountDownLatch(initialCalls); - final BlockingQueue secondPerformedRequests = new LinkedBlockingQueue<>(); - - final HttpService mpSecondPoster = new HttpService() { - @Override - public byte[] performRequest( - @NonNull String endpointUrl, - @Nullable ProxyServerInteractor interactor, - @Nullable Map params, // Used only if requestBodyBytes is null - @Nullable Map headers, - @Nullable byte[] requestBodyBytes, // If provided, send this as raw body - @Nullable SSLSocketFactory socketFactory) - { - final String jsonData = Base64Coder.decodeString(params.get("data").toString()); - assertTrue(params.containsKey("data")); - try { - JSONArray jsonArray = new JSONArray(jsonData); - for (int i = 0; i < jsonArray.length(); i++) { - secondPerformedRequests.put(jsonArray.getJSONObject(i).getString("event")); - } - return TestUtils.bytes("1\n"); - } catch (JSONException e) { - throw new RuntimeException("Malformed data passed to test mock", e); - } catch (InterruptedException e) { - throw new RuntimeException("Could not write message to reporting queue for tests.", e); - } - } + mCleanMixpanelAPI = + new MixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockReferrerPreferences, + TOKEN, + false, + null, + trackAutomaticEvents) { + + @Override + /* package */ PersistentIdentity getPersistentIdentity( + final Context context, + final Future referrerPreferences, + final String token, + final String instanceName) { + String instanceKey = instanceName != null ? instanceName : token; + final String prefsName = "com.mixpanel.android.mpmetrics.MixpanelAPI_" + instanceKey; + final SharedPreferences ret = + context.getSharedPreferences(prefsName, Context.MODE_PRIVATE); + ret.edit().clear().commit(); + + final String timeEventsPrefsName = + "com.mixpanel.android.mpmetrics.MixpanelAPI.TimeEvents_" + instanceKey; + final SharedPreferences timeSharedPrefs = + context.getSharedPreferences(timeEventsPrefsName, Context.MODE_PRIVATE); + timeSharedPrefs.edit().clear().commit(); + + final String mixpanelPrefsName = "com.mixpanel.android.mpmetrics.Mixpanel"; + final SharedPreferences mpSharedPrefs = + context.getSharedPreferences(mixpanelPrefsName, Context.MODE_PRIVATE); + mpSharedPrefs + .edit() + .clear() + .putInt("latest_version_code", -2) + .commit(); // -1 is the default value + + return super.getPersistentIdentity(context, referrerPreferences, token, instanceName); + } + + @Override + AnalyticsMessages getAnalyticsMessages() { + return automaticAnalyticsMessages; + } }; - - final MPDbAdapter mpSecondDbAdapter = new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public void cleanupEvents(String last_id, Table table, String token) { - if (token.equalsIgnoreCase(SECOND_TOKEN)) { - super.cleanupEvents(last_id, table, token); - } - } - - @Override - public int addJSON(JSONObject j, String token, Table table) { - if (token.equalsIgnoreCase(SECOND_TOKEN)) { - secondLatch.countDown(); - return super.addJSON(j, token, table); - } - - return 1; + } + + @After + public void tearDown() throws Exception { + mMinRequestsLatch.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS); + } + + @Test + public void testAutomaticOneInstance() throws InterruptedException { + int calls = 3; // First Time Open, App Update, An Event One + mLatch = new CountDownLatch(calls); + setUpInstance(true); + mCleanMixpanelAPI.track("An event One"); + mCleanMixpanelAPI.flush(); + assertTrue(mLatch.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals(calls, mTrackedEvents); + assertEquals( + AutomaticEvents.FIRST_OPEN, + mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals( + AutomaticEvents.APP_UPDATED, + mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals( + "An event One", mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals(null, mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + } + + @Test + public void testDisableAutomaticEvents() throws InterruptedException { + int calls = 1; + setUpInstance(false); + mLatch = new CountDownLatch(calls); + mCleanMixpanelAPI.track("An Event Three"); + assertTrue(mLatch.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals(calls, mTrackedEvents); + + mCleanMixpanelAPI.track("Automatic Event Two", null, true); // dropped + mCleanMixpanelAPI.track("Automatic Event Three", null, true); // dropped + mCleanMixpanelAPI.track("Automatic Event Four", null, true); // dropped + mCleanMixpanelAPI.flush(); + assertEquals( + "An Event Three", mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals(null, mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + + String[] noEvents = mockAdapter.generateDataString(MPDbAdapter.Table.EVENTS, TOKEN); + assertNull(noEvents); + + mCleanMixpanelAPI.flush(); + assertEquals(null, mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + } + + @Test + public void testAutomaticMultipleInstances() throws InterruptedException { + final String SECOND_TOKEN = "Automatic Events Token Two"; + int initialCalls = 2; + setUpInstance(true); + mLatch = new CountDownLatch(initialCalls); + + final CountDownLatch secondLatch = new CountDownLatch(initialCalls); + final BlockingQueue secondPerformedRequests = new LinkedBlockingQueue<>(); + + final HttpService mpSecondPoster = + new HttpService() { + @Override + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) { + final String jsonData = Base64Coder.decodeString(params.get("data").toString()); + assertTrue(params.containsKey("data")); + try { + JSONArray jsonArray = new JSONArray(jsonData); + for (int i = 0; i < jsonArray.length(); i++) { + secondPerformedRequests.put(jsonArray.getJSONObject(i).getString("event")); + } + return TestUtils.bytes("1\n"); + } catch (JSONException e) { + throw new RuntimeException("Malformed data passed to test mock", e); + } catch (InterruptedException e) { + throw new RuntimeException( + "Could not write message to reporting queue for tests.", e); } + } }; - final AnalyticsMessages mpSecondAnalyticsMessages = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - protected RemoteService getPoster() { - return mpSecondPoster; + final MPDbAdapter mpSecondDbAdapter = + new MPDbAdapter( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public void cleanupEvents(String last_id, Table table, String token) { + if (token.equalsIgnoreCase(SECOND_TOKEN)) { + super.cleanupEvents(last_id, table, token); } + } - @Override - protected MPDbAdapter makeDbAdapter(Context context) { - return mpSecondDbAdapter; + @Override + public int addJSON(JSONObject j, String token, Table table) { + if (token.equalsIgnoreCase(SECOND_TOKEN)) { + secondLatch.countDown(); + return super.addJSON(j, token, table); } - @Override - protected Worker createWorker() { - return new Worker() { - @Override - protected Handler restartWorkerThread() { - final HandlerThread thread = new HandlerThread("com.mixpanel.android.AnalyticsWorker", Process.THREAD_PRIORITY_BACKGROUND); - thread.start(); - final Handler ret = new AnalyticsMessageHandler(thread.getLooper()) { - }; - return ret; - } - }; - } + return 1; + } }; - MixpanelAPI mpSecondInstance = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), new TestUtils.EmptyPreferences(InstrumentationRegistry.getInstrumentation().getContext()), SECOND_TOKEN, false) { - @Override - AnalyticsMessages getAnalyticsMessages() { - return mpSecondAnalyticsMessages; - } + final AnalyticsMessages mpSecondAnalyticsMessages = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + protected RemoteService getPoster() { + return mpSecondPoster; + } + + @Override + protected MPDbAdapter makeDbAdapter(Context context) { + return mpSecondDbAdapter; + } + + @Override + protected Worker createWorker() { + return new Worker() { + @Override + protected Handler restartWorkerThread() { + final HandlerThread thread = + new HandlerThread( + "com.mixpanel.android.AnalyticsWorker", Process.THREAD_PRIORITY_BACKGROUND); + thread.start(); + final Handler ret = new AnalyticsMessageHandler(thread.getLooper()) {}; + return ret; + } + }; + } + }; + + MixpanelAPI mpSecondInstance = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + new TestUtils.EmptyPreferences( + InstrumentationRegistry.getInstrumentation().getContext()), + SECOND_TOKEN, + false) { + @Override + AnalyticsMessages getAnalyticsMessages() { + return mpSecondAnalyticsMessages; + } }; - assertTrue(mLatch.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals(initialCalls, mTrackedEvents); - mLatch = new CountDownLatch(MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null).getBulkUploadLimit() - initialCalls); - for (int i = 0; i < MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null).getBulkUploadLimit() - initialCalls; i++) { - mCleanMixpanelAPI.track("Track event " + i); - } - - assertTrue(mLatch.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - mCleanMixpanelAPI.flush(); - - assertEquals(AutomaticEvents.FIRST_OPEN, mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals(AutomaticEvents.APP_UPDATED, mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - for (int i = 0; i < MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null).getBulkUploadLimit() - initialCalls; i++) { - assertEquals("Track event " + i, mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - } - - assertNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertNull(secondPerformedRequests.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - - mpSecondInstance.flush(); - mCleanMixpanelAPI.track("First Instance Event One"); - mpSecondInstance.track("Second Instance Event One"); - mpSecondInstance.track("Second Instance Event Two"); - mpSecondInstance.flush(); - - assertEquals("Second Instance Event One", secondPerformedRequests.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals("Second Instance Event Two", secondPerformedRequests.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertNull(secondPerformedRequests.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - - assertNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertTrue(mLatch.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals(initialCalls, mTrackedEvents); + mLatch = + new CountDownLatch( + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null) + .getBulkUploadLimit() + - initialCalls); + for (int i = 0; + i + < MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null) + .getBulkUploadLimit() + - initialCalls; + i++) { + mCleanMixpanelAPI.track("Track event " + i); + } + + assertTrue(mLatch.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + mCleanMixpanelAPI.flush(); + + assertEquals( + AutomaticEvents.FIRST_OPEN, + mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals( + AutomaticEvents.APP_UPDATED, + mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + for (int i = 0; + i + < MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null) + .getBulkUploadLimit() + - initialCalls; + i++) { + assertEquals( + "Track event " + i, mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); } + + assertNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertNull(secondPerformedRequests.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + + mpSecondInstance.flush(); + mCleanMixpanelAPI.track("First Instance Event One"); + mpSecondInstance.track("Second Instance Event One"); + mpSecondInstance.track("Second Instance Event Two"); + mpSecondInstance.flush(); + + assertEquals( + "Second Instance Event One", + secondPerformedRequests.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals( + "Second Instance Event Two", + secondPerformedRequests.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertNull(secondPerformedRequests.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + + assertNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + } } diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java index a0603cef3..ec56487f0 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java @@ -1,27 +1,19 @@ package com.mixpanel.android.mpmetrics; +import static org.junit.Assert.*; + import android.content.Context; import android.os.Bundle; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; - import com.mixpanel.android.util.MPLog; import com.mixpanel.android.util.OfflineMode; // Assuming this exists import com.mixpanel.android.util.ProxyServerInteractor; import com.mixpanel.android.util.RemoteService; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - import java.io.IOException; +import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; @@ -33,1166 +25,1316 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; -import java.lang.reflect.Field; - import javax.net.ssl.SSLSocketFactory; - -import static org.junit.Assert.*; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class FeatureFlagManagerTest { - private FeatureFlagManager mFeatureFlagManager; - private MockFeatureFlagDelegate mMockDelegate; - private MockRemoteService mMockRemoteService; - private MPConfig mTestConfig; - private Context mContext; - - private static final String TEST_SERVER_URL = "https://test.mixpanel.com"; - private static final String TEST_DISTINCT_ID = "test_distinct_id"; - private static final String TEST_TOKEN = "test_token"; - private static final long ASYNC_TEST_TIMEOUT_MS = 2000; // 2 seconds - - // Helper class for capturing requests made to the mock service - private static class CapturedRequest { - final String endpointUrl; - final Map headers; - final byte[] requestBodyBytes; - - CapturedRequest(String endpointUrl, Map headers, byte[] requestBodyBytes) { - this.endpointUrl = endpointUrl; - this.headers = headers; - this.requestBodyBytes = requestBodyBytes; - } - - public JSONObject getRequestBodyAsJson() throws JSONException { - if (requestBodyBytes == null) return null; - return new JSONObject(new String(requestBodyBytes, StandardCharsets.UTF_8)); - } - } - - private static class MockFeatureFlagDelegate implements FeatureFlagDelegate { - MPConfig configToReturn; - String distinctIdToReturn = TEST_DISTINCT_ID; - String tokenToReturn = TEST_TOKEN; - List trackCalls = new ArrayList<>(); - CountDownLatch trackCalledLatch; // Optional: for tests waiting for track - - static class TrackCall { - final String eventName; - final JSONObject properties; - TrackCall(String eventName, JSONObject properties) { - this.eventName = eventName; - this.properties = properties; - } - } - - public MockFeatureFlagDelegate(MPConfig config) { - this.configToReturn = config; - } - - @Override - public MPConfig getMPConfig() { - return configToReturn; - } - - @Override - public String getDistinctId() { - return distinctIdToReturn; - } - - @Override - public void track(String eventName, JSONObject properties) { - MPLog.v("FeatureFlagManagerTest", "MockDelegate.track called: " + eventName); - trackCalls.add(new TrackCall(eventName, properties)); - if (trackCalledLatch != null) { - trackCalledLatch.countDown(); - } - } - - @Override - public String getToken() { - return tokenToReturn; - } - - public void resetTrackCalls() { - trackCalls.clear(); - trackCalledLatch = null; - } - } - - private static class MockRemoteService implements RemoteService { - // Queue to hold responses/exceptions to be returned by performRequest - private final BlockingQueue mResults = new ArrayBlockingQueue<>(10); - // Queue to capture actual requests made - private final BlockingQueue mCapturedRequests = new ArrayBlockingQueue<>(10); - - @Override - public boolean isOnline(Context context, OfflineMode offlineMode) { - return true; // Assume online for tests unless specified - } - - @Override - public void checkIsMixpanelBlocked() { - // No-op for tests - } - - @Override - public byte[] performRequest( - @NonNull String endpointUrl, - @Nullable ProxyServerInteractor interactor, - @Nullable Map params, - @Nullable Map headers, - @Nullable byte[] requestBodyBytes, - @Nullable SSLSocketFactory socketFactory) - throws ServiceUnavailableException, IOException { - - mCapturedRequests.offer(new CapturedRequest(endpointUrl, headers, requestBodyBytes)); - - try { - Object result = mResults.poll(FeatureFlagManagerTest.ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS); - if (result == null) { - throw new IOException("MockRemoteService timed out waiting for a result to be queued."); - } - if (result instanceof IOException) { - throw (IOException) result; - } - if (result instanceof ServiceUnavailableException) { - throw (ServiceUnavailableException) result; - } - if (result instanceof RuntimeException) { // For other test exceptions - throw (RuntimeException) result; - } - return (byte[]) result; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("MockRemoteService interrupted.", e); - } - } - - public void addResponse(byte[] responseBytes) { - mResults.offer(responseBytes); - } - - public void addResponse(JSONObject responseJson) { - mResults.offer(responseJson.toString().getBytes(StandardCharsets.UTF_8)); - } - - public void addError(Exception e) { - mResults.offer(e); - } - - public CapturedRequest takeRequest(long timeout, TimeUnit unit) throws InterruptedException { - return mCapturedRequests.poll(timeout, unit); - } - - public void reset() { - mResults.clear(); - mCapturedRequests.clear(); - } - } - - @Before - public void setUp() { - mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); - // MPConfig requires a context and a token (even if we override methods) - // Create a basic MPConfig. Specific flag settings will be set via MockDelegate. - mTestConfig = new MPConfig(new Bundle(), mContext, TEST_TOKEN); - - - mMockDelegate = new MockFeatureFlagDelegate(mTestConfig); - mMockRemoteService = new MockRemoteService(); - - mFeatureFlagManager = new FeatureFlagManager( - mMockDelegate, // Pass delegate directly, manager will wrap in WeakReference - mMockRemoteService, - new FlagsConfig(true, new JSONObject()) - ); - MPLog.setLevel(MPLog.VERBOSE); // Enable verbose logging for tests + private FeatureFlagManager mFeatureFlagManager; + private MockFeatureFlagDelegate mMockDelegate; + private MockRemoteService mMockRemoteService; + private MPConfig mTestConfig; + private Context mContext; + + private static final String TEST_SERVER_URL = "https://test.mixpanel.com"; + private static final String TEST_DISTINCT_ID = "test_distinct_id"; + private static final String TEST_TOKEN = "test_token"; + private static final long ASYNC_TEST_TIMEOUT_MS = 2000; // 2 seconds + + // Helper class for capturing requests made to the mock service + private static class CapturedRequest { + final String endpointUrl; + final Map headers; + final byte[] requestBodyBytes; + + CapturedRequest(String endpointUrl, Map headers, byte[] requestBodyBytes) { + this.endpointUrl = endpointUrl; + this.headers = headers; + this.requestBodyBytes = requestBodyBytes; } - @After - public void tearDown() { - // Ensure handler thread is quit if it's still running, though manager re-creation handles it - // For more robust cleanup, FeatureFlagManager could have a .release() method + public JSONObject getRequestBodyAsJson() throws JSONException { + if (requestBodyBytes == null) return null; + return new JSONObject(new String(requestBodyBytes, StandardCharsets.UTF_8)); } - - // Helper method to create a valid flags JSON response string - private String createFlagsResponseJson(Map flags) { - JSONObject flagsObject = new JSONObject(); - try { - for (Map.Entry entry : flags.entrySet()) { - JSONObject flagDef = new JSONObject(); - flagDef.put("variant_key", entry.getValue().key); - // Need to handle different types for value properly - if (entry.getValue().value == null) { - flagDef.put("variant_value", JSONObject.NULL); - } else { - flagDef.put("variant_value", entry.getValue().value); - } - flagsObject.put(entry.getKey(), flagDef); - } - return new JSONObject().put("flags", flagsObject).toString(); - } catch (JSONException e) { - throw new RuntimeException("Error creating test JSON", e); - } + } + + private static class MockFeatureFlagDelegate implements FeatureFlagDelegate { + MPConfig configToReturn; + String distinctIdToReturn = TEST_DISTINCT_ID; + String tokenToReturn = TEST_TOKEN; + List trackCalls = new ArrayList<>(); + CountDownLatch trackCalledLatch; // Optional: for tests waiting for track + + static class TrackCall { + final String eventName; + final JSONObject properties; + + TrackCall(String eventName, JSONObject properties) { + this.eventName = eventName; + this.properties = properties; + } } - // Helper to simulate MPConfig having specific FlagsConfig - private void setupFlagsConfig(boolean enabled, @Nullable JSONObject context) { - final JSONObject finalContext = (context == null) ? new JSONObject() : context; - final FlagsConfig flagsConfig = new FlagsConfig(enabled, finalContext); - - mMockDelegate.configToReturn = new MPConfig(new Bundle(), mContext, TEST_TOKEN) { - @Override - public String getEventsEndpoint() { // Ensure server URL source - return TEST_SERVER_URL + "/track/"; - } - - @Override - public String getFlagsEndpoint() { // Ensure server URL source - return TEST_SERVER_URL + "/flags/"; - } - }; - - mFeatureFlagManager = new FeatureFlagManager( - mMockDelegate, - mMockRemoteService, - flagsConfig - ); + public MockFeatureFlagDelegate(MPConfig config) { + this.configToReturn = config; } - // ---- Test Cases ---- - - @Test - public void testAreFlagsReady_initialState() { - assertFalse("Features should not be ready initially", mFeatureFlagManager.areFlagsReady()); + @Override + public MPConfig getMPConfig() { + return configToReturn; } - @Test - public void testLoadFlags_whenDisabled_doesNotFetch() throws InterruptedException { - setupFlagsConfig(false, null); // Flags disabled - mFeatureFlagManager.loadFlags(); - - // Wait a bit to ensure no network call is attempted - Thread.sleep(200); // Give handler thread time to process if it were to fetch - - CapturedRequest request = mMockRemoteService.takeRequest(100, TimeUnit.MILLISECONDS); - assertNull("No network request should be made when flags are disabled", request); - assertFalse("areFeaturesReady should be false", mFeatureFlagManager.areFlagsReady()); + @Override + public String getDistinctId() { + return distinctIdToReturn; } - @Test - public void testLoadFlags_whenEnabled_andFetchSucceeds_flagsBecomeReady() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); // Flags enabled - - Map testFlags = new HashMap<>(); - testFlags.put("flag1", new MixpanelFlagVariant("v1", true)); - String responseJson = createFlagsResponseJson(testFlags); - mMockRemoteService.addResponse(responseJson.getBytes(StandardCharsets.UTF_8)); - - mFeatureFlagManager.loadFlags(); - - // Wait for fetch to complete (network call + handler processing) - // Ideally, use CountDownLatch if loadFlags had a completion, - // but for now, poll areFeaturesReady or wait a fixed time. - boolean ready = false; - for (int i = 0; i < 20; i++) { // Poll for up to 2 seconds - if (mFeatureFlagManager.areFlagsReady()) { - ready = true; - break; - } - Thread.sleep(100); - } - assertTrue("Flags should become ready after successful fetch", ready); - - CapturedRequest request = mMockRemoteService.takeRequest(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS); - assertNotNull("A network request should have been made", request); - assertTrue("Endpoint should be for flags", request.endpointUrl.endsWith("/flags/")); + @Override + public void track(String eventName, JSONObject properties) { + MPLog.v("FeatureFlagManagerTest", "MockDelegate.track called: " + eventName); + trackCalls.add(new TrackCall(eventName, properties)); + if (trackCalledLatch != null) { + trackCalledLatch.countDown(); + } } - - @Test - public void testLoadFlags_whenEnabled_andFetchFails_flagsNotReady() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - mMockRemoteService.addError(new IOException("Network unavailable")); - - mFeatureFlagManager.loadFlags(); - - // Wait a bit to see if flags become ready (they shouldn't) - Thread.sleep(500); // Enough time for the fetch attempt and failure processing - - assertFalse("Flags should not be ready after failed fetch", mFeatureFlagManager.areFlagsReady()); - CapturedRequest request = mMockRemoteService.takeRequest(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS); - assertNotNull("A network request should have been attempted", request); + @Override + public String getToken() { + return tokenToReturn; } - @Test - public void testGetVariantSync_flagsNotReady_returnsFallback() { - setupFlagsConfig(true, null); // Enabled, but no flags loaded yet - assertFalse(mFeatureFlagManager.areFlagsReady()); - - MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb_key", "fb_value"); - MixpanelFlagVariant result = mFeatureFlagManager.getVariantSync("my_flag", fallback); - - assertEquals("Should return fallback key", fallback.key, result.key); - assertEquals("Should return fallback value", fallback.value, result.value); + public void resetTrackCalls() { + trackCalls.clear(); + trackCalledLatch = null; } + } - @Test - public void testGetVariantSync_flagsReady_flagExists() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - Map serverFlags = new HashMap<>(); - serverFlags.put("test_flag", new MixpanelFlagVariant("variant_A", "hello")); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - mFeatureFlagManager.loadFlags(); - // Wait for flags to load - for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); - assertTrue(mFeatureFlagManager.areFlagsReady()); - - MixpanelFlagVariant fallback = new MixpanelFlagVariant("fallback_key", "fallback_value"); - MixpanelFlagVariant result = mFeatureFlagManager.getVariantSync("test_flag", fallback); - - assertEquals("Should return actual flag key", "variant_A", result.key); - assertEquals("Should return actual flag value", "hello", result.value); - } + private static class MockRemoteService implements RemoteService { + // Queue to hold responses/exceptions to be returned by performRequest + private final BlockingQueue mResults = new ArrayBlockingQueue<>(10); + // Queue to capture actual requests made + private final BlockingQueue mCapturedRequests = new ArrayBlockingQueue<>(10); - @Test - public void testGetVariantSync_flagsReady_flagMissing_returnsFallback() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - Map serverFlags = new HashMap<>(); - serverFlags.put("another_flag", new MixpanelFlagVariant("variant_B", 123)); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - mFeatureFlagManager.loadFlags(); - for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); - assertTrue(mFeatureFlagManager.areFlagsReady()); - - MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb_key_sync", "fb_value_sync"); - MixpanelFlagVariant result = mFeatureFlagManager.getVariantSync("non_existent_flag", fallback); - - assertEquals("Should return fallback key", fallback.key, result.key); - assertEquals("Should return fallback value", fallback.value, result.value); + @Override + public boolean isOnline(Context context, OfflineMode offlineMode) { + return true; // Assume online for tests unless specified } - @Test - public void testGetVariant_Async_flagsReady_flagExists() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - Map serverFlags = new HashMap<>(); - serverFlags.put("async_flag", new MixpanelFlagVariant("v_async", true)); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - mFeatureFlagManager.loadFlags(); - for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); - assertTrue(mFeatureFlagManager.areFlagsReady()); - - final CountDownLatch latch = new CountDownLatch(1); - final AtomicReference resultRef = new AtomicReference<>(); - MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb", false); - - mFeatureFlagManager.getVariant("async_flag", fallback, result -> { - resultRef.set(result); - latch.countDown(); - }); - - assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); - assertNotNull(resultRef.get()); - assertEquals("v_async", resultRef.get().key); - assertEquals(true, resultRef.get().value); + @Override + public void checkIsMixpanelBlocked() { + // No-op for tests } - @Test - public void testGetVariant_Async_flagsNotReady_fetchSucceeds() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); // Enabled for fetch - assertFalse(mFeatureFlagManager.areFlagsReady()); - - Map serverFlags = new HashMap<>(); - serverFlags.put("fetch_flag_async", new MixpanelFlagVariant("fetched_variant", "fetched_value")); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - // No loadFlags() call here, getFeature should trigger it - - final CountDownLatch latch = new CountDownLatch(1); - final AtomicReference resultRef = new AtomicReference<>(); - MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb_fetch", "fb_val_fetch"); - - mFeatureFlagManager.getVariant("fetch_flag_async", fallback, result -> { - resultRef.set(result); - latch.countDown(); - }); - - assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)); // Longer timeout for fetch - assertNotNull(resultRef.get()); - assertEquals("fetched_variant", resultRef.get().key); - assertEquals("fetched_value", resultRef.get().value); - assertTrue(mFeatureFlagManager.areFlagsReady()); + @Override + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, + @Nullable SSLSocketFactory socketFactory) + throws ServiceUnavailableException, IOException { + + mCapturedRequests.offer(new CapturedRequest(endpointUrl, headers, requestBodyBytes)); + + try { + Object result = + mResults.poll(FeatureFlagManagerTest.ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS); + if (result == null) { + throw new IOException("MockRemoteService timed out waiting for a result to be queued."); + } + if (result instanceof IOException) { + throw (IOException) result; + } + if (result instanceof ServiceUnavailableException) { + throw (ServiceUnavailableException) result; + } + if (result instanceof RuntimeException) { // For other test exceptions + throw (RuntimeException) result; + } + return (byte[]) result; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("MockRemoteService interrupted.", e); + } } - @Test - public void testTracking_getVariantSync_calledOnce() throws InterruptedException, JSONException { - setupFlagsConfig(true, new JSONObject()); - Map serverFlags = new HashMap<>(); - serverFlags.put("track_flag_sync", new MixpanelFlagVariant("v_track_sync", "val")); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - mFeatureFlagManager.loadFlags(); - for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); - assertTrue(mFeatureFlagManager.areFlagsReady()); - - mMockDelegate.resetTrackCalls(); - mMockDelegate.trackCalledLatch = new CountDownLatch(1); - - MixpanelFlagVariant fallback = new MixpanelFlagVariant("", null); - mFeatureFlagManager.getVariantSync("track_flag_sync", fallback); // First call, should track - assertTrue("Track should have been called", mMockDelegate.trackCalledLatch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); - assertEquals("Track should be called once", 1, mMockDelegate.trackCalls.size()); - - // Second call, should NOT track again - mFeatureFlagManager.getVariantSync("track_flag_sync", fallback); - // Allow some time for potential erroneous track call - Thread.sleep(200); - assertEquals("Track should still be called only once", 1, mMockDelegate.trackCalls.size()); - - MockFeatureFlagDelegate.TrackCall call = mMockDelegate.trackCalls.get(0); - assertEquals("$experiment_started", call.eventName); - assertEquals("track_flag_sync", call.properties.getString("Experiment name")); - assertEquals("v_track_sync", call.properties.getString("Variant name")); + public void addResponse(byte[] responseBytes) { + mResults.offer(responseBytes); } - @Test - public void testGetVariant_Async_flagsNotReady_fetchFails_returnsFallback() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - assertFalse(mFeatureFlagManager.areFlagsReady()); - - mMockRemoteService.addError(new IOException("Simulated fetch failure")); - - final CountDownLatch latch = new CountDownLatch(1); - final AtomicReference resultRef = new AtomicReference<>(); - final MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb_async_fail", "val_async_fail"); - - mFeatureFlagManager.getVariant("some_flag_on_fail", fallback, result -> { - resultRef.set(result); - latch.countDown(); - }); - - assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)); - assertNotNull(resultRef.get()); - assertEquals(fallback.key, resultRef.get().key); - assertEquals(fallback.value, resultRef.get().value); - assertFalse(mFeatureFlagManager.areFlagsReady()); - assertEquals(0, mMockDelegate.trackCalls.size()); // No tracking on fallback + public void addResponse(JSONObject responseJson) { + mResults.offer(responseJson.toString().getBytes(StandardCharsets.UTF_8)); } - @Test - public void testIsEnabledSync_flagsReady_flagExistsWithBooleanTrue() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - Map serverFlags = new HashMap<>(); - serverFlags.put("bool_flag_true", new MixpanelFlagVariant("enabled", true)); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - mFeatureFlagManager.loadFlags(); - for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); - assertTrue(mFeatureFlagManager.areFlagsReady()); - - boolean result = mFeatureFlagManager.isEnabledSync("bool_flag_true", false); - assertTrue("Should return true when flag value is true", result); + public void addError(Exception e) { + mResults.offer(e); } - @Test - public void testIsEnabledSync_flagsReady_flagExistsWithBooleanFalse() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - Map serverFlags = new HashMap<>(); - serverFlags.put("bool_flag_false", new MixpanelFlagVariant("disabled", false)); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - mFeatureFlagManager.loadFlags(); - for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); - assertTrue(mFeatureFlagManager.areFlagsReady()); - - boolean result = mFeatureFlagManager.isEnabledSync("bool_flag_false", true); - assertFalse("Should return false when flag value is false", result); + public CapturedRequest takeRequest(long timeout, TimeUnit unit) throws InterruptedException { + return mCapturedRequests.poll(timeout, unit); } - @Test - public void testIsEnabledSync_flagsReady_flagDoesNotExist_returnsFallback() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - Map serverFlags = new HashMap<>(); - serverFlags.put("other_flag", new MixpanelFlagVariant("v1", "value")); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - mFeatureFlagManager.loadFlags(); - for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); - assertTrue(mFeatureFlagManager.areFlagsReady()); - - boolean result = mFeatureFlagManager.isEnabledSync("missing_bool_flag", true); - assertTrue("Should return fallback value (true) when flag doesn't exist", result); - - boolean result2 = mFeatureFlagManager.isEnabledSync("missing_bool_flag", false); - assertFalse("Should return fallback value (false) when flag doesn't exist", result2); + public void reset() { + mResults.clear(); + mCapturedRequests.clear(); } + } - @Test - public void testIsEnabledSync_flagsNotReady_returnsFallback() { - setupFlagsConfig(true, null); - assertFalse(mFeatureFlagManager.areFlagsReady()); + @Before + public void setUp() { + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + // MPConfig requires a context and a token (even if we override methods) + // Create a basic MPConfig. Specific flag settings will be set via MockDelegate. + mTestConfig = new MPConfig(new Bundle(), mContext, TEST_TOKEN); - boolean result = mFeatureFlagManager.isEnabledSync("any_flag", true); - assertTrue("Should return fallback value (true) when flags not ready", result); + mMockDelegate = new MockFeatureFlagDelegate(mTestConfig); + mMockRemoteService = new MockRemoteService(); - boolean result2 = mFeatureFlagManager.isEnabledSync("any_flag", false); - assertFalse("Should return fallback value (false) when flags not ready", result2); + mFeatureFlagManager = + new FeatureFlagManager( + mMockDelegate, // Pass delegate directly, manager will wrap in WeakReference + mMockRemoteService, + new FlagsConfig(true, new JSONObject())); + MPLog.setLevel(MPLog.VERBOSE); // Enable verbose logging for tests + } + + @After + public void tearDown() { + // Ensure handler thread is quit if it's still running, though manager re-creation handles it + // For more robust cleanup, FeatureFlagManager could have a .release() method + } + + // Helper method to create a valid flags JSON response string + private String createFlagsResponseJson(Map flags) { + JSONObject flagsObject = new JSONObject(); + try { + for (Map.Entry entry : flags.entrySet()) { + JSONObject flagDef = new JSONObject(); + flagDef.put("variant_key", entry.getValue().key); + // Need to handle different types for value properly + if (entry.getValue().value == null) { + flagDef.put("variant_value", JSONObject.NULL); + } else { + flagDef.put("variant_value", entry.getValue().value); + } + flagsObject.put(entry.getKey(), flagDef); + } + return new JSONObject().put("flags", flagsObject).toString(); + } catch (JSONException e) { + throw new RuntimeException("Error creating test JSON", e); } + } + + // Helper to simulate MPConfig having specific FlagsConfig + private void setupFlagsConfig(boolean enabled, @Nullable JSONObject context) { + final JSONObject finalContext = (context == null) ? new JSONObject() : context; + final FlagsConfig flagsConfig = new FlagsConfig(enabled, finalContext); + + mMockDelegate.configToReturn = + new MPConfig(new Bundle(), mContext, TEST_TOKEN) { + @Override + public String getEventsEndpoint() { // Ensure server URL source + return TEST_SERVER_URL + "/track/"; + } + + @Override + public String getFlagsEndpoint() { // Ensure server URL source + return TEST_SERVER_URL + "/flags/"; + } + }; - @Test - public void testIsEnabledSync_flagsReady_nonBooleanValue_returnsFallback() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - Map serverFlags = new HashMap<>(); - serverFlags.put("string_flag", new MixpanelFlagVariant("v1", "not_a_boolean")); - serverFlags.put("number_flag", new MixpanelFlagVariant("v2", 123)); - serverFlags.put("null_flag", new MixpanelFlagVariant("v3", null)); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - mFeatureFlagManager.loadFlags(); - for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); - assertTrue(mFeatureFlagManager.areFlagsReady()); - - assertTrue("String value should return fallback true", mFeatureFlagManager.isEnabledSync("string_flag", true)); - assertFalse("String value should return fallback false", mFeatureFlagManager.isEnabledSync("string_flag", false)); - - assertTrue("Number value should return fallback true", mFeatureFlagManager.isEnabledSync("number_flag", true)); - assertFalse("Number value should return fallback false", mFeatureFlagManager.isEnabledSync("number_flag", false)); - - assertTrue("Null value should return fallback true", mFeatureFlagManager.isEnabledSync("null_flag", true)); - assertFalse("Null value should return fallback false", mFeatureFlagManager.isEnabledSync("null_flag", false)); + mFeatureFlagManager = new FeatureFlagManager(mMockDelegate, mMockRemoteService, flagsConfig); + } + + // ---- Test Cases ---- + + @Test + public void testAreFlagsReady_initialState() { + assertFalse("Features should not be ready initially", mFeatureFlagManager.areFlagsReady()); + } + + @Test + public void testLoadFlags_whenDisabled_doesNotFetch() throws InterruptedException { + setupFlagsConfig(false, null); // Flags disabled + mFeatureFlagManager.loadFlags(); + + // Wait a bit to ensure no network call is attempted + Thread.sleep(200); // Give handler thread time to process if it were to fetch + + CapturedRequest request = mMockRemoteService.takeRequest(100, TimeUnit.MILLISECONDS); + assertNull("No network request should be made when flags are disabled", request); + assertFalse("areFeaturesReady should be false", mFeatureFlagManager.areFlagsReady()); + } + + @Test + public void testLoadFlags_whenEnabled_andFetchSucceeds_flagsBecomeReady() + throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); // Flags enabled + + Map testFlags = new HashMap<>(); + testFlags.put("flag1", new MixpanelFlagVariant("v1", true)); + String responseJson = createFlagsResponseJson(testFlags); + mMockRemoteService.addResponse(responseJson.getBytes(StandardCharsets.UTF_8)); + + mFeatureFlagManager.loadFlags(); + + // Wait for fetch to complete (network call + handler processing) + // Ideally, use CountDownLatch if loadFlags had a completion, + // but for now, poll areFeaturesReady or wait a fixed time. + boolean ready = false; + for (int i = 0; i < 20; i++) { // Poll for up to 2 seconds + if (mFeatureFlagManager.areFlagsReady()) { + ready = true; + break; + } + Thread.sleep(100); } - - @Test - public void testIsEnabled_Async_flagsReady_booleanTrue() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - Map serverFlags = new HashMap<>(); - serverFlags.put("async_bool_true", new MixpanelFlagVariant("v_true", true)); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - mFeatureFlagManager.loadFlags(); - for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); - assertTrue(mFeatureFlagManager.areFlagsReady()); - - final CountDownLatch latch = new CountDownLatch(1); - final AtomicReference resultRef = new AtomicReference<>(); - - mFeatureFlagManager.isEnabled("async_bool_true", false, result -> { - resultRef.set(result); - latch.countDown(); + assertTrue("Flags should become ready after successful fetch", ready); + + CapturedRequest request = + mMockRemoteService.takeRequest(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS); + assertNotNull("A network request should have been made", request); + assertTrue("Endpoint should be for flags", request.endpointUrl.endsWith("/flags/")); + } + + @Test + public void testLoadFlags_whenEnabled_andFetchFails_flagsNotReady() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + mMockRemoteService.addError(new IOException("Network unavailable")); + + mFeatureFlagManager.loadFlags(); + + // Wait a bit to see if flags become ready (they shouldn't) + Thread.sleep(500); // Enough time for the fetch attempt and failure processing + + assertFalse( + "Flags should not be ready after failed fetch", mFeatureFlagManager.areFlagsReady()); + CapturedRequest request = + mMockRemoteService.takeRequest(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS); + assertNotNull("A network request should have been attempted", request); + } + + @Test + public void testGetVariantSync_flagsNotReady_returnsFallback() { + setupFlagsConfig(true, null); // Enabled, but no flags loaded yet + assertFalse(mFeatureFlagManager.areFlagsReady()); + + MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb_key", "fb_value"); + MixpanelFlagVariant result = mFeatureFlagManager.getVariantSync("my_flag", fallback); + + assertEquals("Should return fallback key", fallback.key, result.key); + assertEquals("Should return fallback value", fallback.value, result.value); + } + + @Test + public void testGetVariantSync_flagsReady_flagExists() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("test_flag", new MixpanelFlagVariant("variant_A", "hello")); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + // Wait for flags to load + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + MixpanelFlagVariant fallback = new MixpanelFlagVariant("fallback_key", "fallback_value"); + MixpanelFlagVariant result = mFeatureFlagManager.getVariantSync("test_flag", fallback); + + assertEquals("Should return actual flag key", "variant_A", result.key); + assertEquals("Should return actual flag value", "hello", result.value); + } + + @Test + public void testGetVariantSync_flagsReady_flagMissing_returnsFallback() + throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("another_flag", new MixpanelFlagVariant("variant_B", 123)); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb_key_sync", "fb_value_sync"); + MixpanelFlagVariant result = mFeatureFlagManager.getVariantSync("non_existent_flag", fallback); + + assertEquals("Should return fallback key", fallback.key, result.key); + assertEquals("Should return fallback value", fallback.value, result.value); + } + + @Test + public void testGetVariant_Async_flagsReady_flagExists() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("async_flag", new MixpanelFlagVariant("v_async", true)); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb", false); + + mFeatureFlagManager.getVariant( + "async_flag", + fallback, + result -> { + resultRef.set(result); + latch.countDown(); }); - assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); - assertNotNull(resultRef.get()); - assertTrue("Should return true for boolean true flag", resultRef.get()); - } - - @Test - public void testIsEnabled_Async_flagsReady_booleanFalse() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - Map serverFlags = new HashMap<>(); - serverFlags.put("async_bool_false", new MixpanelFlagVariant("v_false", false)); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - mFeatureFlagManager.loadFlags(); - for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); - assertTrue(mFeatureFlagManager.areFlagsReady()); - - final CountDownLatch latch = new CountDownLatch(1); - final AtomicReference resultRef = new AtomicReference<>(); - - mFeatureFlagManager.isEnabled("async_bool_false", true, result -> { - resultRef.set(result); - latch.countDown(); + assertTrue( + "Callback should complete within timeout", + latch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertEquals("v_async", resultRef.get().key); + assertEquals(true, resultRef.get().value); + } + + @Test + public void testGetVariant_Async_flagsNotReady_fetchSucceeds() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); // Enabled for fetch + assertFalse(mFeatureFlagManager.areFlagsReady()); + + Map serverFlags = new HashMap<>(); + serverFlags.put( + "fetch_flag_async", new MixpanelFlagVariant("fetched_variant", "fetched_value")); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + // No loadFlags() call here, getFeature should trigger it + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb_fetch", "fb_val_fetch"); + + mFeatureFlagManager.getVariant( + "fetch_flag_async", + fallback, + result -> { + resultRef.set(result); + latch.countDown(); }); - assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); - assertNotNull(resultRef.get()); - assertFalse("Should return false for boolean false flag", resultRef.get()); - } - - @Test - public void testIsEnabled_Async_flagsNotReady_fetchSucceeds() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - assertFalse(mFeatureFlagManager.areFlagsReady()); - - Map serverFlags = new HashMap<>(); - serverFlags.put("fetch_bool_flag", new MixpanelFlagVariant("fetched", true)); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - - final CountDownLatch latch = new CountDownLatch(1); - final AtomicReference resultRef = new AtomicReference<>(); - - mFeatureFlagManager.isEnabled("fetch_bool_flag", false, result -> { - resultRef.set(result); - latch.countDown(); + assertTrue( + "Callback should complete within timeout", + latch.await(ASYNC_TEST_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)); // Longer timeout for fetch + assertNotNull(resultRef.get()); + assertEquals("fetched_variant", resultRef.get().key); + assertEquals("fetched_value", resultRef.get().value); + assertTrue(mFeatureFlagManager.areFlagsReady()); + } + + @Test + public void testTracking_getVariantSync_calledOnce() throws InterruptedException, JSONException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("track_flag_sync", new MixpanelFlagVariant("v_track_sync", "val")); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + mMockDelegate.resetTrackCalls(); + mMockDelegate.trackCalledLatch = new CountDownLatch(1); + + MixpanelFlagVariant fallback = new MixpanelFlagVariant("", null); + mFeatureFlagManager.getVariantSync("track_flag_sync", fallback); // First call, should track + assertTrue( + "Track should have been called", + mMockDelegate.trackCalledLatch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertEquals("Track should be called once", 1, mMockDelegate.trackCalls.size()); + + // Second call, should NOT track again + mFeatureFlagManager.getVariantSync("track_flag_sync", fallback); + // Allow some time for potential erroneous track call + Thread.sleep(200); + assertEquals("Track should still be called only once", 1, mMockDelegate.trackCalls.size()); + + MockFeatureFlagDelegate.TrackCall call = mMockDelegate.trackCalls.get(0); + assertEquals("$experiment_started", call.eventName); + assertEquals("track_flag_sync", call.properties.getString("Experiment name")); + assertEquals("v_track_sync", call.properties.getString("Variant name")); + } + + @Test + public void testGetVariant_Async_flagsNotReady_fetchFails_returnsFallback() + throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + assertFalse(mFeatureFlagManager.areFlagsReady()); + + mMockRemoteService.addError(new IOException("Simulated fetch failure")); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + final MixpanelFlagVariant fallback = new MixpanelFlagVariant("fb_async_fail", "val_async_fail"); + + mFeatureFlagManager.getVariant( + "some_flag_on_fail", + fallback, + result -> { + resultRef.set(result); + latch.countDown(); }); - assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)); - assertNotNull(resultRef.get()); - assertTrue("Should return true after successful fetch", resultRef.get()); - assertTrue(mFeatureFlagManager.areFlagsReady()); - } + assertTrue( + "Callback should complete within timeout", + latch.await(ASYNC_TEST_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertEquals(fallback.key, resultRef.get().key); + assertEquals(fallback.value, resultRef.get().value); + assertFalse(mFeatureFlagManager.areFlagsReady()); + assertEquals(0, mMockDelegate.trackCalls.size()); // No tracking on fallback + } + + @Test + public void testIsEnabledSync_flagsReady_flagExistsWithBooleanTrue() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("bool_flag_true", new MixpanelFlagVariant("enabled", true)); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + boolean result = mFeatureFlagManager.isEnabledSync("bool_flag_true", false); + assertTrue("Should return true when flag value is true", result); + } + + @Test + public void testIsEnabledSync_flagsReady_flagExistsWithBooleanFalse() + throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("bool_flag_false", new MixpanelFlagVariant("disabled", false)); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + boolean result = mFeatureFlagManager.isEnabledSync("bool_flag_false", true); + assertFalse("Should return false when flag value is false", result); + } + + @Test + public void testIsEnabledSync_flagsReady_flagDoesNotExist_returnsFallback() + throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("other_flag", new MixpanelFlagVariant("v1", "value")); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + boolean result = mFeatureFlagManager.isEnabledSync("missing_bool_flag", true); + assertTrue("Should return fallback value (true) when flag doesn't exist", result); + + boolean result2 = mFeatureFlagManager.isEnabledSync("missing_bool_flag", false); + assertFalse("Should return fallback value (false) when flag doesn't exist", result2); + } + + @Test + public void testIsEnabledSync_flagsNotReady_returnsFallback() { + setupFlagsConfig(true, null); + assertFalse(mFeatureFlagManager.areFlagsReady()); + + boolean result = mFeatureFlagManager.isEnabledSync("any_flag", true); + assertTrue("Should return fallback value (true) when flags not ready", result); + + boolean result2 = mFeatureFlagManager.isEnabledSync("any_flag", false); + assertFalse("Should return fallback value (false) when flags not ready", result2); + } + + @Test + public void testIsEnabledSync_flagsReady_nonBooleanValue_returnsFallback() + throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("string_flag", new MixpanelFlagVariant("v1", "not_a_boolean")); + serverFlags.put("number_flag", new MixpanelFlagVariant("v2", 123)); + serverFlags.put("null_flag", new MixpanelFlagVariant("v3", null)); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + assertTrue( + "String value should return fallback true", + mFeatureFlagManager.isEnabledSync("string_flag", true)); + assertFalse( + "String value should return fallback false", + mFeatureFlagManager.isEnabledSync("string_flag", false)); + + assertTrue( + "Number value should return fallback true", + mFeatureFlagManager.isEnabledSync("number_flag", true)); + assertFalse( + "Number value should return fallback false", + mFeatureFlagManager.isEnabledSync("number_flag", false)); + + assertTrue( + "Null value should return fallback true", + mFeatureFlagManager.isEnabledSync("null_flag", true)); + assertFalse( + "Null value should return fallback false", + mFeatureFlagManager.isEnabledSync("null_flag", false)); + } + + @Test + public void testIsEnabled_Async_flagsReady_booleanTrue() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("async_bool_true", new MixpanelFlagVariant("v_true", true)); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + + mFeatureFlagManager.isEnabled( + "async_bool_true", + false, + result -> { + resultRef.set(result); + latch.countDown(); + }); - @Test - public void testIsEnabled_Async_nonBooleanValue_returnsFallback() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - Map serverFlags = new HashMap<>(); - serverFlags.put("string_async", new MixpanelFlagVariant("v_str", "hello")); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - mFeatureFlagManager.loadFlags(); - for(int i = 0; i<20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); - assertTrue(mFeatureFlagManager.areFlagsReady()); - - final CountDownLatch latch = new CountDownLatch(1); - final AtomicReference resultRef = new AtomicReference<>(); - - mFeatureFlagManager.isEnabled("string_async", true, result -> { - resultRef.set(result); - latch.countDown(); + assertTrue( + "Callback should complete within timeout", + latch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertTrue("Should return true for boolean true flag", resultRef.get()); + } + + @Test + public void testIsEnabled_Async_flagsReady_booleanFalse() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("async_bool_false", new MixpanelFlagVariant("v_false", false)); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + + mFeatureFlagManager.isEnabled( + "async_bool_false", + true, + result -> { + resultRef.set(result); + latch.countDown(); }); - assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); - assertNotNull(resultRef.get()); - assertTrue("Should return fallback (true) for non-boolean value", resultRef.get()); - } + assertTrue( + "Callback should complete within timeout", + latch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertFalse("Should return false for boolean false flag", resultRef.get()); + } + + @Test + public void testIsEnabled_Async_flagsNotReady_fetchSucceeds() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + assertFalse(mFeatureFlagManager.areFlagsReady()); + + Map serverFlags = new HashMap<>(); + serverFlags.put("fetch_bool_flag", new MixpanelFlagVariant("fetched", true)); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + + mFeatureFlagManager.isEnabled( + "fetch_bool_flag", + false, + result -> { + resultRef.set(result); + latch.countDown(); + }); - @Test - public void testIsEnabled_Async_fetchFails_returnsFallback() throws InterruptedException { - setupFlagsConfig(true, new JSONObject()); - assertFalse(mFeatureFlagManager.areFlagsReady()); + assertTrue( + "Callback should complete within timeout", + latch.await(ASYNC_TEST_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertTrue("Should return true after successful fetch", resultRef.get()); + assertTrue(mFeatureFlagManager.areFlagsReady()); + } + + @Test + public void testIsEnabled_Async_nonBooleanValue_returnsFallback() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + Map serverFlags = new HashMap<>(); + serverFlags.put("string_async", new MixpanelFlagVariant("v_str", "hello")); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); ++i) Thread.sleep(100); + assertTrue(mFeatureFlagManager.areFlagsReady()); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + + mFeatureFlagManager.isEnabled( + "string_async", + true, + result -> { + resultRef.set(result); + latch.countDown(); + }); - mMockRemoteService.addError(new IOException("Network error")); + assertTrue( + "Callback should complete within timeout", + latch.await(ASYNC_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertTrue("Should return fallback (true) for non-boolean value", resultRef.get()); + } + + @Test + public void testIsEnabled_Async_fetchFails_returnsFallback() throws InterruptedException { + setupFlagsConfig(true, new JSONObject()); + assertFalse(mFeatureFlagManager.areFlagsReady()); + + mMockRemoteService.addError(new IOException("Network error")); + + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference resultRef = new AtomicReference<>(); + + mFeatureFlagManager.isEnabled( + "fail_flag", + true, + result -> { + resultRef.set(result); + latch.countDown(); + }); - final CountDownLatch latch = new CountDownLatch(1); - final AtomicReference resultRef = new AtomicReference<>(); + assertTrue( + "Callback should complete within timeout", + latch.await(ASYNC_TEST_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)); + assertNotNull(resultRef.get()); + assertTrue("Should return fallback (true) when fetch fails", resultRef.get()); + assertFalse(mFeatureFlagManager.areFlagsReady()); + } + + @Test + public void testConcurrentLoadFlagsCalls() throws InterruptedException { + // Setup with flags enabled + setupFlagsConfig(true, new JSONObject()); + + // Track number of network requests made + final AtomicInteger requestCount = new AtomicInteger(0); + + // Prepare response data + Map serverFlags = new HashMap<>(); + serverFlags.put("concurrent_flag", new MixpanelFlagVariant("test_variant", "test_value")); + + // Create a custom MockRemoteService that counts requests and introduces delay + MockRemoteService customMockService = + new MockRemoteService() { + @Override + public byte[] performRequest( + String endpointUrl, + ProxyServerInteractor interactor, + Map params, + Map headers, + byte[] requestBodyBytes, + SSLSocketFactory socketFactory) + throws ServiceUnavailableException, IOException { + // Count the request + requestCount.incrementAndGet(); + + // Introduce a delay to simulate network latency + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } - mFeatureFlagManager.isEnabled("fail_flag", true, result -> { - resultRef.set(result); - latch.countDown(); - }); + // Return the prepared response + return super.performRequest( + endpointUrl, interactor, params, headers, requestBodyBytes, socketFactory); + } + }; - assertTrue("Callback should complete within timeout", latch.await(ASYNC_TEST_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)); - assertNotNull(resultRef.get()); - assertTrue("Should return fallback (true) when fetch fails", resultRef.get()); - assertFalse(mFeatureFlagManager.areFlagsReady()); + // Add response to the custom mock service + customMockService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Use reflection to set the custom mock service + try { + Field httpServiceField = FeatureFlagManager.class.getDeclaredField("mHttpService"); + httpServiceField.setAccessible(true); + httpServiceField.set(mFeatureFlagManager, customMockService); + } catch (Exception e) { + fail("Failed to set mock http service: " + e.getMessage()); } - @Test - public void testConcurrentLoadFlagsCalls() throws InterruptedException { - // Setup with flags enabled - setupFlagsConfig(true, new JSONObject()); - - // Track number of network requests made - final AtomicInteger requestCount = new AtomicInteger(0); - - // Prepare response data - Map serverFlags = new HashMap<>(); - serverFlags.put("concurrent_flag", new MixpanelFlagVariant("test_variant", "test_value")); - - // Create a custom MockRemoteService that counts requests and introduces delay - MockRemoteService customMockService = new MockRemoteService() { - @Override - public byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, - Map params, Map headers, - byte[] requestBodyBytes, SSLSocketFactory socketFactory) - throws ServiceUnavailableException, IOException { - // Count the request - requestCount.incrementAndGet(); - - // Introduce a delay to simulate network latency - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - - // Return the prepared response - return super.performRequest(endpointUrl, interactor, params, headers, requestBodyBytes, socketFactory); - } - }; - - // Add response to the custom mock service - customMockService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - - // Use reflection to set the custom mock service - try { - Field httpServiceField = FeatureFlagManager.class.getDeclaredField("mHttpService"); - httpServiceField.setAccessible(true); - httpServiceField.set(mFeatureFlagManager, customMockService); - } catch (Exception e) { - fail("Failed to set mock http service: " + e.getMessage()); - } - - // Number of concurrent threads - final int threadCount = 10; - final CountDownLatch startLatch = new CountDownLatch(1); - final CountDownLatch completionLatch = new CountDownLatch(threadCount); - final List threads = new ArrayList<>(); - final AtomicInteger successCount = new AtomicInteger(0); - - // Create multiple threads that will call loadFlags concurrently - for (int i = 0; i < threadCount; i++) { - Thread thread = new Thread(() -> { + // Number of concurrent threads + final int threadCount = 10; + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch completionLatch = new CountDownLatch(threadCount); + final List threads = new ArrayList<>(); + final AtomicInteger successCount = new AtomicInteger(0); + + // Create multiple threads that will call loadFlags concurrently + for (int i = 0; i < threadCount; i++) { + Thread thread = + new Thread( + () -> { try { - // Wait for signal to start all threads simultaneously - startLatch.await(); - // Call loadFlags - mFeatureFlagManager.loadFlags(); - successCount.incrementAndGet(); + // Wait for signal to start all threads simultaneously + startLatch.await(); + // Call loadFlags + mFeatureFlagManager.loadFlags(); + successCount.incrementAndGet(); } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + Thread.currentThread().interrupt(); } finally { - completionLatch.countDown(); + completionLatch.countDown(); } - }); - threads.add(thread); - thread.start(); - } - - // Start all threads at the same time - startLatch.countDown(); - - // Wait for all threads to complete - assertTrue("All threads should complete within timeout", - completionLatch.await(5000, TimeUnit.MILLISECONDS)); - - // Wait a bit more for all loadFlags operations to complete - Thread.sleep(500); - - // Verify results - assertEquals("All threads should have completed successfully", threadCount, successCount.get()); - - // Only one network request should have been made despite multiple concurrent calls - // This verifies that loadFlags properly handles concurrent calls - assertEquals("Should only make one network request for concurrent loadFlags calls", - 1, requestCount.get()); - - // Verify flags are ready - assertTrue("Flags should be ready after concurrent loads", mFeatureFlagManager.areFlagsReady()); - - // Test accessing the flag synchronously - MixpanelFlagVariant variant = mFeatureFlagManager.getVariantSync("concurrent_flag", - new MixpanelFlagVariant("default")); - assertNotNull("Flag variant should not be null", variant); - assertEquals("test_variant", variant.key); - assertEquals("test_value", variant.value); + }); + threads.add(thread); + thread.start(); } - @Test - public void testConcurrentGetVariantCalls_whenFlagsNotReady() throws InterruptedException { - // Setup with flags enabled - setupFlagsConfig(true, new JSONObject()); - - // Prepare response data that will be delayed - Map serverFlags = new HashMap<>(); - serverFlags.put("concurrent_get_flag1", new MixpanelFlagVariant("variant1", "value1")); - serverFlags.put("concurrent_get_flag2", new MixpanelFlagVariant("variant2", "value2")); - serverFlags.put("concurrent_get_flag3", new MixpanelFlagVariant("variant3", "value3")); - - // Create a mock service that introduces significant delay to simulate slow network - final AtomicInteger requestCount = new AtomicInteger(0); - MockRemoteService delayedMockService = new MockRemoteService() { - @Override - public byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, - Map params, Map headers, - byte[] requestBodyBytes, SSLSocketFactory socketFactory) - throws ServiceUnavailableException, IOException { - requestCount.incrementAndGet(); - // Introduce significant delay to ensure getVariant calls happen before flags are ready - try { - Thread.sleep(500); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return super.performRequest(endpointUrl, interactor, params, headers, requestBodyBytes, socketFactory); + // Start all threads at the same time + startLatch.countDown(); + + // Wait for all threads to complete + assertTrue( + "All threads should complete within timeout", + completionLatch.await(5000, TimeUnit.MILLISECONDS)); + + // Wait a bit more for all loadFlags operations to complete + Thread.sleep(500); + + // Verify results + assertEquals("All threads should have completed successfully", threadCount, successCount.get()); + + // Only one network request should have been made despite multiple concurrent calls + // This verifies that loadFlags properly handles concurrent calls + assertEquals( + "Should only make one network request for concurrent loadFlags calls", + 1, + requestCount.get()); + + // Verify flags are ready + assertTrue("Flags should be ready after concurrent loads", mFeatureFlagManager.areFlagsReady()); + + // Test accessing the flag synchronously + MixpanelFlagVariant variant = + mFeatureFlagManager.getVariantSync("concurrent_flag", new MixpanelFlagVariant("default")); + assertNotNull("Flag variant should not be null", variant); + assertEquals("test_variant", variant.key); + assertEquals("test_value", variant.value); + } + + @Test + public void testConcurrentGetVariantCalls_whenFlagsNotReady() throws InterruptedException { + // Setup with flags enabled + setupFlagsConfig(true, new JSONObject()); + + // Prepare response data that will be delayed + Map serverFlags = new HashMap<>(); + serverFlags.put("concurrent_get_flag1", new MixpanelFlagVariant("variant1", "value1")); + serverFlags.put("concurrent_get_flag2", new MixpanelFlagVariant("variant2", "value2")); + serverFlags.put("concurrent_get_flag3", new MixpanelFlagVariant("variant3", "value3")); + + // Create a mock service that introduces significant delay to simulate slow network + final AtomicInteger requestCount = new AtomicInteger(0); + MockRemoteService delayedMockService = + new MockRemoteService() { + @Override + public byte[] performRequest( + String endpointUrl, + ProxyServerInteractor interactor, + Map params, + Map headers, + byte[] requestBodyBytes, + SSLSocketFactory socketFactory) + throws ServiceUnavailableException, IOException { + requestCount.incrementAndGet(); + // Introduce significant delay to ensure getVariant calls happen before flags are ready + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } + return super.performRequest( + endpointUrl, interactor, params, headers, requestBodyBytes, socketFactory); + } }; - - // Add response to the mock service - delayedMockService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - - // Use reflection to set the custom mock service - try { - Field httpServiceField = FeatureFlagManager.class.getDeclaredField("mHttpService"); - httpServiceField.setAccessible(true); - httpServiceField.set(mFeatureFlagManager, delayedMockService); - } catch (Exception e) { - fail("Failed to set mock http service: " + e.getMessage()); - } - - // Trigger loadFlags which will be delayed - mFeatureFlagManager.loadFlags(); - - // Verify flags are not ready yet - assertFalse("Flags should not be ready immediately after loadFlags", mFeatureFlagManager.areFlagsReady()); - - // Number of concurrent threads calling getVariant - final int threadCount = 20; - final CountDownLatch startLatch = new CountDownLatch(1); - final CountDownLatch completionLatch = new CountDownLatch(threadCount); - final List threads = new ArrayList<>(); - final Map results = new HashMap<>(); - final AtomicInteger successCount = new AtomicInteger(0); - - // Create multiple threads that will call getVariant concurrently while flags are loading - for (int i = 0; i < threadCount; i++) { - final int threadIndex = i; - final String flagName = "concurrent_get_flag" + ((i % 3) + 1); // Rotate through 3 different flags - final MixpanelFlagVariant fallback = new MixpanelFlagVariant("fallback" + threadIndex, "fallback_value" + threadIndex); - - Thread thread = new Thread(() -> { + + // Add response to the mock service + delayedMockService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Use reflection to set the custom mock service + try { + Field httpServiceField = FeatureFlagManager.class.getDeclaredField("mHttpService"); + httpServiceField.setAccessible(true); + httpServiceField.set(mFeatureFlagManager, delayedMockService); + } catch (Exception e) { + fail("Failed to set mock http service: " + e.getMessage()); + } + + // Trigger loadFlags which will be delayed + mFeatureFlagManager.loadFlags(); + + // Verify flags are not ready yet + assertFalse( + "Flags should not be ready immediately after loadFlags", + mFeatureFlagManager.areFlagsReady()); + + // Number of concurrent threads calling getVariant + final int threadCount = 20; + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch completionLatch = new CountDownLatch(threadCount); + final List threads = new ArrayList<>(); + final Map results = new HashMap<>(); + final AtomicInteger successCount = new AtomicInteger(0); + + // Create multiple threads that will call getVariant concurrently while flags are loading + for (int i = 0; i < threadCount; i++) { + final int threadIndex = i; + final String flagName = + "concurrent_get_flag" + ((i % 3) + 1); // Rotate through 3 different flags + final MixpanelFlagVariant fallback = + new MixpanelFlagVariant("fallback" + threadIndex, "fallback_value" + threadIndex); + + Thread thread = + new Thread( + () -> { try { - // Wait for signal to start all threads simultaneously - startLatch.await(); - - // Use async getVariant with callback - final CountDownLatch variantLatch = new CountDownLatch(1); - final AtomicReference variantRef = new AtomicReference<>(); - - mFeatureFlagManager.getVariant(flagName, fallback, variant -> { + // Wait for signal to start all threads simultaneously + startLatch.await(); + + // Use async getVariant with callback + final CountDownLatch variantLatch = new CountDownLatch(1); + final AtomicReference variantRef = new AtomicReference<>(); + + mFeatureFlagManager.getVariant( + flagName, + fallback, + variant -> { variantRef.set(variant); variantLatch.countDown(); - }); - - // Wait for callback - if (variantLatch.await(2000, TimeUnit.MILLISECONDS)) { - results.put(threadIndex, variantRef.get()); - successCount.incrementAndGet(); - } + }); + + // Wait for callback + if (variantLatch.await(2000, TimeUnit.MILLISECONDS)) { + results.put(threadIndex, variantRef.get()); + successCount.incrementAndGet(); + } } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + Thread.currentThread().interrupt(); } finally { - completionLatch.countDown(); + completionLatch.countDown(); } - }); - threads.add(thread); - thread.start(); - } - - // Start all threads at the same time (while flags are still loading) - startLatch.countDown(); - - // Wait for all threads to complete - assertTrue("All threads should complete within timeout", - completionLatch.await(3000, TimeUnit.MILLISECONDS)); - - // Verify results - assertEquals("All threads should have completed successfully", threadCount, successCount.get()); - - // Only one network request should have been made - assertEquals("Should only make one network request", 1, requestCount.get()); - - // Verify flags are now ready - assertTrue("Flags should be ready after all getVariant calls complete", mFeatureFlagManager.areFlagsReady()); - - // Verify all threads got the correct values (not fallbacks) - for (int i = 0; i < threadCount; i++) { - MixpanelFlagVariant result = results.get(i); - assertNotNull("Thread " + i + " should have a result", result); - - int flagIndex = (i % 3) + 1; - String expectedKey = "variant" + flagIndex; - String expectedValue = "value" + flagIndex; - - assertEquals("Thread " + i + " should have correct variant key", expectedKey, result.key); - assertEquals("Thread " + i + " should have correct variant value", expectedValue, result.value); - - // Verify it's not the fallback - assertNotEquals("Thread " + i + " should not have fallback key", "fallback" + i, result.key); - } + }); + threads.add(thread); + thread.start(); } - @Test - public void testRequestBodyConstruction_performFetchRequest() throws InterruptedException, JSONException { - // Setup with flags enabled and specific context data - JSONObject contextData = new JSONObject(); - contextData.put("$os", "Android"); - contextData.put("$os_version", "13"); - contextData.put("custom_property", "test_value"); - setupFlagsConfig(true, contextData); - - // Set distinct ID for the request - mMockDelegate.distinctIdToReturn = "test_user_123"; - - // Create response data - Map serverFlags = new HashMap<>(); - serverFlags.put("test_flag", new MixpanelFlagVariant("variant_a", "value_a")); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - - // Trigger loadFlags to initiate the request - mFeatureFlagManager.loadFlags(); - - // Capture the request - CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); - assertNotNull("Request should have been made", capturedRequest); - - // Verify the endpoint URL - assertTrue("URL should contain /flags endpoint", capturedRequest.endpointUrl.contains("/flags")); - - // Parse and verify the request body - assertNotNull("Request should have body", capturedRequest.requestBodyBytes); - JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); - assertNotNull("Request body should be valid JSON", requestBody); - - // Log the actual request body for debugging - MPLog.v("FeatureFlagManagerTest", "Request body: " + requestBody.toString()); - - // Verify context is included - assertTrue("Request should contain context", requestBody.has("context")); - JSONObject requestContext = requestBody.getJSONObject("context"); - - // Verify distinct_id is in the context - assertTrue("Context should contain distinct_id", requestContext.has("distinct_id")); - assertEquals("Context should contain correct distinct_id", - "test_user_123", requestContext.getString("distinct_id")); - - // Verify the context contains the expected properties from FlagsConfig - assertEquals("Context should contain $os", "Android", requestContext.getString("$os")); - assertEquals("Context should contain $os_version", "13", requestContext.getString("$os_version")); - assertEquals("Context should contain custom_property", "test_value", requestContext.getString("custom_property")); - - // Verify headers - assertNotNull("Request should have headers", capturedRequest.headers); - assertEquals("Content-Type should be application/json with charset", - "application/json; charset=utf-8", capturedRequest.headers.get("Content-Type")); - - // Wait for flags to be ready - for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); i++) { - Thread.sleep(100); - } - assertTrue("Flags should be ready", mFeatureFlagManager.areFlagsReady()); - } - - @Test - public void testRequestBodyConstruction_withNullContext() throws InterruptedException, JSONException { - // Setup with flags enabled but null context - setupFlagsConfig(true, null); - - // Set distinct ID - mMockDelegate.distinctIdToReturn = "user_456"; - - // Create response - Map serverFlags = new HashMap<>(); - serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - - // Trigger request - mFeatureFlagManager.loadFlags(); - - // Capture request - CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); - assertNotNull("Request should have been made", capturedRequest); - - // Parse request body - JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); - - // Verify context exists - assertTrue("Request should contain context", requestBody.has("context")); - JSONObject requestContext = requestBody.getJSONObject("context"); - - // Verify distinct_id is in context - assertEquals("Context should contain correct distinct_id", "user_456", requestContext.getString("distinct_id")); - - // When FlagsConfig context is null, the context object should only contain distinct_id - assertEquals("Context should only contain distinct_id when FlagsConfig context is null", - 1, requestContext.length()); - } - - @Test - public void testRequestBodyConstruction_withEmptyDistinctId() throws InterruptedException, JSONException { - // Setup with flags enabled - setupFlagsConfig(true, new JSONObject()); - - // Set empty distinct ID - mMockDelegate.distinctIdToReturn = ""; - - // Create response - Map serverFlags = new HashMap<>(); - serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - - // Trigger request - mFeatureFlagManager.loadFlags(); - - // Capture request - CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); - assertNotNull("Request should have been made", capturedRequest); - - // Parse request body - JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); - - // Verify context exists - assertTrue("Request should contain context", requestBody.has("context")); - JSONObject requestContext = requestBody.getJSONObject("context"); - - // Verify distinct_id is included in context even when empty - assertTrue("Context should contain distinct_id field", requestContext.has("distinct_id")); - assertEquals("Context should contain empty distinct_id", "", requestContext.getString("distinct_id")); - } + // Start all threads at the same time (while flags are still loading) + startLatch.countDown(); - @Test - public void testFlagsConfigContextUsage_initialContext() throws InterruptedException, JSONException { - // Test that initial context from FlagsConfig is properly used - JSONObject initialContext = new JSONObject(); - initialContext.put("app_version", "1.0.0"); - initialContext.put("platform", "Android"); - initialContext.put("custom_prop", "initial_value"); - - setupFlagsConfig(true, initialContext); - - // Create response - Map serverFlags = new HashMap<>(); - serverFlags.put("test_flag", new MixpanelFlagVariant("v1", "value1")); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - - // Trigger request - mFeatureFlagManager.loadFlags(); - - // Capture and verify request - CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); - assertNotNull("Request should have been made", capturedRequest); - - JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); - JSONObject requestContext = requestBody.getJSONObject("context"); - - // Verify all initial context properties are included - assertEquals("app_version should be preserved", "1.0.0", requestContext.getString("app_version")); - assertEquals("platform should be preserved", "Android", requestContext.getString("platform")); - assertEquals("custom_prop should be preserved", "initial_value", requestContext.getString("custom_prop")); - - // Verify distinct_id is added to context - assertTrue("distinct_id should be added to context", requestContext.has("distinct_id")); - } - - @Test - public void testFlagsConfigContextUsage_contextMerging() throws InterruptedException, JSONException { - // Test that distinct_id doesn't override existing context properties - JSONObject initialContext = new JSONObject(); - initialContext.put("distinct_id", "should_be_overridden"); // This should be overridden - initialContext.put("user_type", "premium"); - initialContext.put("$os", "Android"); - - setupFlagsConfig(true, initialContext); - - // Set a different distinct_id via delegate - mMockDelegate.distinctIdToReturn = "actual_distinct_id"; - - // Create response - Map serverFlags = new HashMap<>(); - serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - - // Trigger request - mFeatureFlagManager.loadFlags(); - - // Capture and verify request - CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); - JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); - JSONObject requestContext = requestBody.getJSONObject("context"); - - // Verify distinct_id from delegate overrides the one in initial context - assertEquals("distinct_id should be from delegate, not initial context", - "actual_distinct_id", requestContext.getString("distinct_id")); - - // Verify other properties are preserved - assertEquals("user_type should be preserved", "premium", requestContext.getString("user_type")); - assertEquals("$os should be preserved", "Android", requestContext.getString("$os")); - } - - @Test - public void testFlagsConfigContextUsage_emptyContext() throws InterruptedException, JSONException { - // Test behavior with empty context object - JSONObject emptyContext = new JSONObject(); - setupFlagsConfig(true, emptyContext); - - mMockDelegate.distinctIdToReturn = "test_user"; - - // Create response - Map serverFlags = new HashMap<>(); - serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - - // Trigger request - mFeatureFlagManager.loadFlags(); - - // Capture and verify request - CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); - JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); - JSONObject requestContext = requestBody.getJSONObject("context"); - - // Context should only contain distinct_id when initial context is empty - assertEquals("Context should only contain distinct_id", 1, requestContext.length()); - assertEquals("distinct_id should be present", "test_user", requestContext.getString("distinct_id")); - } - - @Test - public void testFlagsConfigContextUsage_complexNestedContext() throws InterruptedException, JSONException { - // Test that complex nested objects in context are preserved - JSONObject nestedObj = new JSONObject(); - nestedObj.put("city", "San Francisco"); - nestedObj.put("country", "USA"); - - JSONObject initialContext = new JSONObject(); - initialContext.put("location", nestedObj); - initialContext.put("features_enabled", new JSONArray().put("feature1").put("feature2")); - initialContext.put("is_beta", true); - initialContext.put("score", 95.5); - - setupFlagsConfig(true, initialContext); - - // Create response - Map serverFlags = new HashMap<>(); - serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - - // Trigger request - mFeatureFlagManager.loadFlags(); - - // Capture and verify request - CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); - JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); - JSONObject requestContext = requestBody.getJSONObject("context"); - - // Verify complex nested structures are preserved - JSONObject locationInRequest = requestContext.getJSONObject("location"); - assertEquals("city should be preserved", "San Francisco", locationInRequest.getString("city")); - assertEquals("country should be preserved", "USA", locationInRequest.getString("country")); - - JSONArray featuresInRequest = requestContext.getJSONArray("features_enabled"); - assertEquals("features array length should be preserved", 2, featuresInRequest.length()); - assertEquals("feature1 should be preserved", "feature1", featuresInRequest.getString(0)); - assertEquals("feature2 should be preserved", "feature2", featuresInRequest.getString(1)); - - assertTrue("is_beta should be preserved", requestContext.getBoolean("is_beta")); - assertEquals("score should be preserved", 95.5, requestContext.getDouble("score"), 0.001); - - // And distinct_id should still be added - assertTrue("distinct_id should be added", requestContext.has("distinct_id")); + // Wait for all threads to complete + assertTrue( + "All threads should complete within timeout", + completionLatch.await(3000, TimeUnit.MILLISECONDS)); + + // Verify results + assertEquals("All threads should have completed successfully", threadCount, successCount.get()); + + // Only one network request should have been made + assertEquals("Should only make one network request", 1, requestCount.get()); + + // Verify flags are now ready + assertTrue( + "Flags should be ready after all getVariant calls complete", + mFeatureFlagManager.areFlagsReady()); + + // Verify all threads got the correct values (not fallbacks) + for (int i = 0; i < threadCount; i++) { + MixpanelFlagVariant result = results.get(i); + assertNotNull("Thread " + i + " should have a result", result); + + int flagIndex = (i % 3) + 1; + String expectedKey = "variant" + flagIndex; + String expectedValue = "value" + flagIndex; + + assertEquals("Thread " + i + " should have correct variant key", expectedKey, result.key); + assertEquals( + "Thread " + i + " should have correct variant value", expectedValue, result.value); + + // Verify it's not the fallback + assertNotEquals("Thread " + i + " should not have fallback key", "fallback" + i, result.key); } - - @Test - public void testFlagsConfigContextUsage_specialCharactersInContext() throws InterruptedException, JSONException { - // Test that special characters and unicode in context are handled properly - JSONObject initialContext = new JSONObject(); - initialContext.put("emoji", "🚀🎉"); - initialContext.put("special_chars", "!@#$%^&*()_+-=[]{}|;':\",./<>?"); - initialContext.put("unicode", "你好世界"); - initialContext.put("newline", "line1\nline2"); - - setupFlagsConfig(true, initialContext); - - // Create response - Map serverFlags = new HashMap<>(); - serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); - mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); - - // Trigger request - mFeatureFlagManager.loadFlags(); - - // Capture and verify request - CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); - JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); - JSONObject requestContext = requestBody.getJSONObject("context"); - - // Verify special characters are preserved correctly - assertEquals("emoji should be preserved", "🚀🎉", requestContext.getString("emoji")); - assertEquals("special_chars should be preserved", - "!@#$%^&*()_+-=[]{}|;':\",./<>?", requestContext.getString("special_chars")); - assertEquals("unicode should be preserved", "你好世界", requestContext.getString("unicode")); - assertEquals("newline should be preserved", "line1\nline2", requestContext.getString("newline")); + } + + @Test + public void testRequestBodyConstruction_performFetchRequest() + throws InterruptedException, JSONException { + // Setup with flags enabled and specific context data + JSONObject contextData = new JSONObject(); + contextData.put("$os", "Android"); + contextData.put("$os_version", "13"); + contextData.put("custom_property", "test_value"); + setupFlagsConfig(true, contextData); + + // Set distinct ID for the request + mMockDelegate.distinctIdToReturn = "test_user_123"; + + // Create response data + Map serverFlags = new HashMap<>(); + serverFlags.put("test_flag", new MixpanelFlagVariant("variant_a", "value_a")); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger loadFlags to initiate the request + mFeatureFlagManager.loadFlags(); + + // Capture the request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + assertNotNull("Request should have been made", capturedRequest); + + // Verify the endpoint URL + assertTrue( + "URL should contain /flags endpoint", capturedRequest.endpointUrl.contains("/flags")); + + // Parse and verify the request body + assertNotNull("Request should have body", capturedRequest.requestBodyBytes); + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + assertNotNull("Request body should be valid JSON", requestBody); + + // Log the actual request body for debugging + MPLog.v("FeatureFlagManagerTest", "Request body: " + requestBody.toString()); + + // Verify context is included + assertTrue("Request should contain context", requestBody.has("context")); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify distinct_id is in the context + assertTrue("Context should contain distinct_id", requestContext.has("distinct_id")); + assertEquals( + "Context should contain correct distinct_id", + "test_user_123", + requestContext.getString("distinct_id")); + + // Verify the context contains the expected properties from FlagsConfig + assertEquals("Context should contain $os", "Android", requestContext.getString("$os")); + assertEquals( + "Context should contain $os_version", "13", requestContext.getString("$os_version")); + assertEquals( + "Context should contain custom_property", + "test_value", + requestContext.getString("custom_property")); + + // Verify headers + assertNotNull("Request should have headers", capturedRequest.headers); + assertEquals( + "Content-Type should be application/json with charset", + "application/json; charset=utf-8", + capturedRequest.headers.get("Content-Type")); + + // Wait for flags to be ready + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); i++) { + Thread.sleep(100); } -} \ No newline at end of file + assertTrue("Flags should be ready", mFeatureFlagManager.areFlagsReady()); + } + + @Test + public void testRequestBodyConstruction_withNullContext() + throws InterruptedException, JSONException { + // Setup with flags enabled but null context + setupFlagsConfig(true, null); + + // Set distinct ID + mMockDelegate.distinctIdToReturn = "user_456"; + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + assertNotNull("Request should have been made", capturedRequest); + + // Parse request body + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + + // Verify context exists + assertTrue("Request should contain context", requestBody.has("context")); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify distinct_id is in context + assertEquals( + "Context should contain correct distinct_id", + "user_456", + requestContext.getString("distinct_id")); + + // When FlagsConfig context is null, the context object should only contain distinct_id + assertEquals( + "Context should only contain distinct_id when FlagsConfig context is null", + 1, + requestContext.length()); + } + + @Test + public void testRequestBodyConstruction_withEmptyDistinctId() + throws InterruptedException, JSONException { + // Setup with flags enabled + setupFlagsConfig(true, new JSONObject()); + + // Set empty distinct ID + mMockDelegate.distinctIdToReturn = ""; + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + assertNotNull("Request should have been made", capturedRequest); + + // Parse request body + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + + // Verify context exists + assertTrue("Request should contain context", requestBody.has("context")); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify distinct_id is included in context even when empty + assertTrue("Context should contain distinct_id field", requestContext.has("distinct_id")); + assertEquals( + "Context should contain empty distinct_id", "", requestContext.getString("distinct_id")); + } + + @Test + public void testFlagsConfigContextUsage_initialContext() + throws InterruptedException, JSONException { + // Test that initial context from FlagsConfig is properly used + JSONObject initialContext = new JSONObject(); + initialContext.put("app_version", "1.0.0"); + initialContext.put("platform", "Android"); + initialContext.put("custom_prop", "initial_value"); + + setupFlagsConfig(true, initialContext); + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("test_flag", new MixpanelFlagVariant("v1", "value1")); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture and verify request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + assertNotNull("Request should have been made", capturedRequest); + + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify all initial context properties are included + assertEquals( + "app_version should be preserved", "1.0.0", requestContext.getString("app_version")); + assertEquals("platform should be preserved", "Android", requestContext.getString("platform")); + assertEquals( + "custom_prop should be preserved", + "initial_value", + requestContext.getString("custom_prop")); + + // Verify distinct_id is added to context + assertTrue("distinct_id should be added to context", requestContext.has("distinct_id")); + } + + @Test + public void testFlagsConfigContextUsage_contextMerging() + throws InterruptedException, JSONException { + // Test that distinct_id doesn't override existing context properties + JSONObject initialContext = new JSONObject(); + initialContext.put("distinct_id", "should_be_overridden"); // This should be overridden + initialContext.put("user_type", "premium"); + initialContext.put("$os", "Android"); + + setupFlagsConfig(true, initialContext); + + // Set a different distinct_id via delegate + mMockDelegate.distinctIdToReturn = "actual_distinct_id"; + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture and verify request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify distinct_id from delegate overrides the one in initial context + assertEquals( + "distinct_id should be from delegate, not initial context", + "actual_distinct_id", + requestContext.getString("distinct_id")); + + // Verify other properties are preserved + assertEquals("user_type should be preserved", "premium", requestContext.getString("user_type")); + assertEquals("$os should be preserved", "Android", requestContext.getString("$os")); + } + + @Test + public void testFlagsConfigContextUsage_emptyContext() + throws InterruptedException, JSONException { + // Test behavior with empty context object + JSONObject emptyContext = new JSONObject(); + setupFlagsConfig(true, emptyContext); + + mMockDelegate.distinctIdToReturn = "test_user"; + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture and verify request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Context should only contain distinct_id when initial context is empty + assertEquals("Context should only contain distinct_id", 1, requestContext.length()); + assertEquals( + "distinct_id should be present", "test_user", requestContext.getString("distinct_id")); + } + + @Test + public void testFlagsConfigContextUsage_complexNestedContext() + throws InterruptedException, JSONException { + // Test that complex nested objects in context are preserved + JSONObject nestedObj = new JSONObject(); + nestedObj.put("city", "San Francisco"); + nestedObj.put("country", "USA"); + + JSONObject initialContext = new JSONObject(); + initialContext.put("location", nestedObj); + initialContext.put("features_enabled", new JSONArray().put("feature1").put("feature2")); + initialContext.put("is_beta", true); + initialContext.put("score", 95.5); + + setupFlagsConfig(true, initialContext); + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture and verify request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify complex nested structures are preserved + JSONObject locationInRequest = requestContext.getJSONObject("location"); + assertEquals("city should be preserved", "San Francisco", locationInRequest.getString("city")); + assertEquals("country should be preserved", "USA", locationInRequest.getString("country")); + + JSONArray featuresInRequest = requestContext.getJSONArray("features_enabled"); + assertEquals("features array length should be preserved", 2, featuresInRequest.length()); + assertEquals("feature1 should be preserved", "feature1", featuresInRequest.getString(0)); + assertEquals("feature2 should be preserved", "feature2", featuresInRequest.getString(1)); + + assertTrue("is_beta should be preserved", requestContext.getBoolean("is_beta")); + assertEquals("score should be preserved", 95.5, requestContext.getDouble("score"), 0.001); + + // And distinct_id should still be added + assertTrue("distinct_id should be added", requestContext.has("distinct_id")); + } + + @Test + public void testFlagsConfigContextUsage_specialCharactersInContext() + throws InterruptedException, JSONException { + // Test that special characters and unicode in context are handled properly + JSONObject initialContext = new JSONObject(); + initialContext.put("emoji", "🚀🎉"); + initialContext.put("special_chars", "!@#$%^&*()_+-=[]{}|;':\",./<>?"); + initialContext.put("unicode", "你好世界"); + initialContext.put("newline", "line1\nline2"); + + setupFlagsConfig(true, initialContext); + + // Create response + Map serverFlags = new HashMap<>(); + serverFlags.put("flag", new MixpanelFlagVariant("v", "val")); + mMockRemoteService.addResponse( + createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + + // Trigger request + mFeatureFlagManager.loadFlags(); + + // Capture and verify request + CapturedRequest capturedRequest = mMockRemoteService.takeRequest(1000, TimeUnit.MILLISECONDS); + JSONObject requestBody = capturedRequest.getRequestBodyAsJson(); + JSONObject requestContext = requestBody.getJSONObject("context"); + + // Verify special characters are preserved correctly + assertEquals("emoji should be preserved", "🚀🎉", requestContext.getString("emoji")); + assertEquals( + "special_chars should be preserved", + "!@#$%^&*()_+-=[]{}|;':\",./<>?", + requestContext.getString("special_chars")); + assertEquals("unicode should be preserved", "你好世界", requestContext.getString("unicode")); + assertEquals( + "newline should be preserved", "line1\nline2", requestContext.getString("newline")); + } +} diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/HttpTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/HttpTest.java index e18817196..6df0f7dd0 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/HttpTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/HttpTest.java @@ -1,23 +1,19 @@ package com.mixpanel.android.mpmetrics; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + import android.content.Context; import android.content.SharedPreferences; import android.os.Bundle; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; - import com.mixpanel.android.util.Base64Coder; +import com.mixpanel.android.util.HttpService; import com.mixpanel.android.util.ProxyServerInteractor; import com.mixpanel.android.util.RemoteService; -import com.mixpanel.android.util.HttpService; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - import java.io.IOException; import java.net.MalformedURLException; import java.net.SocketTimeoutException; @@ -28,303 +24,321 @@ import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; - import javax.net.ssl.SSLSocketFactory; - +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - @RunWith(AndroidJUnit4.class) public class HttpTest { - private Future mMockPreferences; - private List mFlushResults; - private BlockingQueue mPerformRequestCalls; - private List mCleanupCalls; - private MixpanelAPI mMetrics; - private volatile int mFlushInterval; - private volatile boolean mForceOverMemThreshold; - private static final long POLL_WAIT_MAX_MILLISECONDS = 3500; - private static final TimeUnit DEFAULT_TIMEUNIT = TimeUnit.MILLISECONDS; - private static final String SUCCEED_TEXT = "Should Succeed"; - private static final String FAIL_TEXT = "Should Fail"; - - @Before - public void setUp() { - mFlushInterval = 2 * 1000; - mMockPreferences = new TestUtils.EmptyPreferences(InstrumentationRegistry.getInstrumentation().getContext()); - mFlushResults = new ArrayList(); - mPerformRequestCalls = new LinkedBlockingQueue(); - mCleanupCalls = new ArrayList(); - mForceOverMemThreshold = false; - - final RemoteService mockPoster = new HttpService() { - @Override - public byte[] performRequest( - @NonNull String endpointUrl, - @Nullable ProxyServerInteractor interactor, - @Nullable Map params, // Used only if requestBodyBytes is null - @Nullable Map headers, - @Nullable byte[] requestBodyBytes, // If provided, send this as raw body - @Nullable SSLSocketFactory socketFactory) - throws ServiceUnavailableException, IOException - { - try { - if (mFlushResults.isEmpty()) { - mFlushResults.add(TestUtils.bytes("1\n")); - } - assertTrue(params.containsKey("data")); - - final Object obj = mFlushResults.remove(0); - if (obj instanceof IOException) { - throw (IOException)obj; - } else if (obj instanceof MalformedURLException) { - throw (MalformedURLException)obj; - } else if (obj instanceof ServiceUnavailableException) { - throw (ServiceUnavailableException)obj; - } else if (obj instanceof SocketTimeoutException) { - throw (SocketTimeoutException)obj; - } - - final String jsonData = Base64Coder.decodeString(params.get("data").toString()); - JSONArray msg = new JSONArray(jsonData); - JSONObject event = msg.getJSONObject(0); - mPerformRequestCalls.put(event.getString("event")); - - return (byte[])obj; - } catch (JSONException e) { - throw new RuntimeException("Malformed data passed to test mock", e); - } catch (InterruptedException e) { - throw new RuntimeException("Could not write message to reporting queue for tests.", e); - } + private Future mMockPreferences; + private List mFlushResults; + private BlockingQueue mPerformRequestCalls; + private List mCleanupCalls; + private MixpanelAPI mMetrics; + private volatile int mFlushInterval; + private volatile boolean mForceOverMemThreshold; + private static final long POLL_WAIT_MAX_MILLISECONDS = 3500; + private static final TimeUnit DEFAULT_TIMEUNIT = TimeUnit.MILLISECONDS; + private static final String SUCCEED_TEXT = "Should Succeed"; + private static final String FAIL_TEXT = "Should Fail"; + + @Before + public void setUp() { + mFlushInterval = 2 * 1000; + mMockPreferences = + new TestUtils.EmptyPreferences(InstrumentationRegistry.getInstrumentation().getContext()); + mFlushResults = new ArrayList(); + mPerformRequestCalls = new LinkedBlockingQueue(); + mCleanupCalls = new ArrayList(); + mForceOverMemThreshold = false; + + final RemoteService mockPoster = + new HttpService() { + @Override + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) + throws ServiceUnavailableException, IOException { + try { + if (mFlushResults.isEmpty()) { + mFlushResults.add(TestUtils.bytes("1\n")); + } + assertTrue(params.containsKey("data")); + + final Object obj = mFlushResults.remove(0); + if (obj instanceof IOException) { + throw (IOException) obj; + } else if (obj instanceof MalformedURLException) { + throw (MalformedURLException) obj; + } else if (obj instanceof ServiceUnavailableException) { + throw (ServiceUnavailableException) obj; + } else if (obj instanceof SocketTimeoutException) { + throw (SocketTimeoutException) obj; + } + + final String jsonData = Base64Coder.decodeString(params.get("data").toString()); + JSONArray msg = new JSONArray(jsonData); + JSONObject event = msg.getJSONObject(0); + mPerformRequestCalls.put(event.getString("event")); + + return (byte[]) obj; + } catch (JSONException e) { + throw new RuntimeException("Malformed data passed to test mock", e); + } catch (InterruptedException e) { + throw new RuntimeException( + "Could not write message to reporting queue for tests.", e); } + } }; - final MPConfig config = new MPConfig(new Bundle(), InstrumentationRegistry.getInstrumentation().getContext(), null) { + final MPConfig config = + new MPConfig( + new Bundle(), InstrumentationRegistry.getInstrumentation().getContext(), null) { - @Override - public String getEventsEndpoint() { - return "EVENTS ENDPOINT"; - } + @Override + public String getEventsEndpoint() { + return "EVENTS ENDPOINT"; + } - @Override - public int getFlushInterval() { - return mFlushInterval; - } + @Override + public int getFlushInterval() { + return mFlushInterval; + } }; - final MPDbAdapter mockAdapter = new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), config) { - @Override - public void cleanupEvents(String last_id, Table table, String token) { - mCleanupCalls.add("called"); - super.cleanupEvents(last_id, table, token); - } - - @Override - protected boolean aboveMemThreshold() { - if (mForceOverMemThreshold) { - return true; - } else { - return super.aboveMemThreshold(); - } + final MPDbAdapter mockAdapter = + new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), config) { + @Override + public void cleanupEvents(String last_id, Table table, String token) { + mCleanupCalls.add("called"); + super.cleanupEvents(last_id, table, token); + } + + @Override + protected boolean aboveMemThreshold() { + if (mForceOverMemThreshold) { + return true; + } else { + return super.aboveMemThreshold(); } + } }; - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), config) { - @Override - protected MPDbAdapter makeDbAdapter(Context context) { - return mockAdapter; - } - - @Override - protected RemoteService getPoster() { - return mockPoster; - } - + final AnalyticsMessages listener = + new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), config) { + @Override + protected MPDbAdapter makeDbAdapter(Context context) { + return mockAdapter; + } + + @Override + protected RemoteService getPoster() { + return mockPoster; + } }; - mMetrics = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "Test Message Queuing") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } + mMetrics = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "Test Message Queuing") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } }; + } + + @Test + public void testHTTPFailures() { + try { + runBasicSucceed(); + runIOException(); + runMalformedURLException(); + runServiceUnavailableException(null); + runServiceUnavailableException("10"); + runServiceUnavailableException("40"); + runDoubleServiceUnavailableException(); + runBasicSucceed(); + runMemoryTest(); + } catch (InterruptedException e) { + throw new RuntimeException("Test was interrupted."); } + } - @Test - public void testHTTPFailures() { - try { - runBasicSucceed(); - runIOException(); - runMalformedURLException(); - runServiceUnavailableException(null); - runServiceUnavailableException("10"); - runServiceUnavailableException("40"); - runDoubleServiceUnavailableException(); - runBasicSucceed(); - runMemoryTest(); - } catch (InterruptedException e) { - throw new RuntimeException("Test was interrupted."); - } - } - - public void runBasicSucceed() throws InterruptedException { - mCleanupCalls.clear(); - mMetrics.track(SUCCEED_TEXT, null); - waitForFlushInternval(); - assertEquals(SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(null, mPerformRequestCalls.poll()); - assertEquals(1, mCleanupCalls.size()); - } - - public void runIOException() throws InterruptedException { - mCleanupCalls.clear(); - mFlushResults.add(new IOException()); - mMetrics.track(SUCCEED_TEXT, null); + public void runBasicSucceed() throws InterruptedException { + mCleanupCalls.clear(); + mMetrics.track(SUCCEED_TEXT, null); + waitForFlushInternval(); + assertEquals( + SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals(null, mPerformRequestCalls.poll()); + assertEquals(1, mCleanupCalls.size()); + } - waitForFlushInternval(); + public void runIOException() throws InterruptedException { + mCleanupCalls.clear(); + mFlushResults.add(new IOException()); + mMetrics.track(SUCCEED_TEXT, null); - assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(0, mCleanupCalls.size()); + waitForFlushInternval(); - mMetrics.track(SUCCEED_TEXT, null); - mMetrics.track(SUCCEED_TEXT, null); - mMetrics.track(SUCCEED_TEXT, null); - mMetrics.track(SUCCEED_TEXT, null); + assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals(0, mCleanupCalls.size()); - waitForBackOffTimeInterval(); + mMetrics.track(SUCCEED_TEXT, null); + mMetrics.track(SUCCEED_TEXT, null); + mMetrics.track(SUCCEED_TEXT, null); + mMetrics.track(SUCCEED_TEXT, null); - assertEquals(1, mCleanupCalls.size()); - assertEquals(SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + waitForBackOffTimeInterval(); - mMetrics.track(SUCCEED_TEXT, null); - mMetrics.track(SUCCEED_TEXT, null); + assertEquals(1, mCleanupCalls.size()); + assertEquals( + SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - waitForFlushInternval(); + mMetrics.track(SUCCEED_TEXT, null); + mMetrics.track(SUCCEED_TEXT, null); - assertEquals(2, mCleanupCalls.size()); - assertEquals(SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - } - - public void runMalformedURLException() throws InterruptedException { - mCleanupCalls.clear(); - mFlushResults.add(new MalformedURLException()); - mMetrics.track(SUCCEED_TEXT, null); - - waitForFlushInternval(); + waitForFlushInternval(); - assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(1, mCleanupCalls.size()); + assertEquals(2, mCleanupCalls.size()); + assertEquals( + SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + } - mFlushResults.add(new MalformedURLException()); - mMetrics.track(SUCCEED_TEXT, null); - mMetrics.track(SUCCEED_TEXT, null); - mMetrics.track(SUCCEED_TEXT, null); - mMetrics.track(SUCCEED_TEXT, null); + public void runMalformedURLException() throws InterruptedException { + mCleanupCalls.clear(); + mFlushResults.add(new MalformedURLException()); + mMetrics.track(SUCCEED_TEXT, null); - waitForFlushInternval(); + waitForFlushInternval(); - assertEquals(2, mCleanupCalls.size()); - assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals(1, mCleanupCalls.size()); - mMetrics.track(SUCCEED_TEXT, null); - mMetrics.track(SUCCEED_TEXT, null); + mFlushResults.add(new MalformedURLException()); + mMetrics.track(SUCCEED_TEXT, null); + mMetrics.track(SUCCEED_TEXT, null); + mMetrics.track(SUCCEED_TEXT, null); + mMetrics.track(SUCCEED_TEXT, null); - waitForFlushInternval(); + waitForFlushInternval(); - assertEquals(3, mCleanupCalls.size()); - assertEquals(SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - } - - private void runServiceUnavailableException(String retryAfterSeconds) throws InterruptedException { - mCleanupCalls.clear(); - mFlushResults.add(new RemoteService.ServiceUnavailableException("", retryAfterSeconds)); - mMetrics.track(SUCCEED_TEXT, null); + assertEquals(2, mCleanupCalls.size()); + assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - waitForFlushInternval(); + mMetrics.track(SUCCEED_TEXT, null); + mMetrics.track(SUCCEED_TEXT, null); - assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(0, mCleanupCalls.size()); + waitForFlushInternval(); - mMetrics.track(SUCCEED_TEXT, null); - mMetrics.track(SUCCEED_TEXT, null); - mMetrics.track(SUCCEED_TEXT, null); - mMetrics.track(SUCCEED_TEXT, null); + assertEquals(3, mCleanupCalls.size()); + assertEquals( + SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + } - waitForBackOffTimeInterval(); + private void runServiceUnavailableException(String retryAfterSeconds) + throws InterruptedException { + mCleanupCalls.clear(); + mFlushResults.add(new RemoteService.ServiceUnavailableException("", retryAfterSeconds)); + mMetrics.track(SUCCEED_TEXT, null); - assertEquals(1, mCleanupCalls.size()); - assertEquals(SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + waitForFlushInternval(); - mMetrics.track(SUCCEED_TEXT, null); - mMetrics.track(SUCCEED_TEXT, null); + assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals(0, mCleanupCalls.size()); - waitForFlushInternval(); + mMetrics.track(SUCCEED_TEXT, null); + mMetrics.track(SUCCEED_TEXT, null); + mMetrics.track(SUCCEED_TEXT, null); + mMetrics.track(SUCCEED_TEXT, null); - assertEquals(2, mCleanupCalls.size()); - assertEquals(SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - } + waitForBackOffTimeInterval(); - private void runDoubleServiceUnavailableException() throws InterruptedException { - mCleanupCalls.clear(); - mFlushResults.add(new RemoteService.ServiceUnavailableException("", "")); - mFlushResults.add(new RemoteService.ServiceUnavailableException("", "")); - mMetrics.track(SUCCEED_TEXT, null); + assertEquals(1, mCleanupCalls.size()); + assertEquals( + SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - waitForFlushInternval(); + mMetrics.track(SUCCEED_TEXT, null); + mMetrics.track(SUCCEED_TEXT, null); - assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(0, mCleanupCalls.size()); + waitForFlushInternval(); - int numEvents = 2 * 50 + 20; // we send batches of 50 each time - for (int i = 0; i <= numEvents; i++) { - mMetrics.track(SUCCEED_TEXT, null); - } + assertEquals(2, mCleanupCalls.size()); + assertEquals( + SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + } - waitForBackOffTimeInterval(); + private void runDoubleServiceUnavailableException() throws InterruptedException { + mCleanupCalls.clear(); + mFlushResults.add(new RemoteService.ServiceUnavailableException("", "")); + mFlushResults.add(new RemoteService.ServiceUnavailableException("", "")); + mMetrics.track(SUCCEED_TEXT, null); - assertEquals(0, mCleanupCalls.size()); - assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + waitForFlushInternval(); - waitForBackOffTimeInterval(); + assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals(0, mCleanupCalls.size()); - assertEquals(3, mCleanupCalls.size()); - assertEquals(SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + int numEvents = 2 * 50 + 20; // we send batches of 50 each time + for (int i = 0; i <= numEvents; i++) { + mMetrics.track(SUCCEED_TEXT, null); } - private void runMemoryTest() throws InterruptedException { - mForceOverMemThreshold = true; - mCleanupCalls.clear(); - mMetrics.track(FAIL_TEXT, null); - waitForFlushInternval(); - assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(0, mCleanupCalls.size()); - - mForceOverMemThreshold = false; - mMetrics.track(SUCCEED_TEXT, null); - waitForFlushInternval(); - assertEquals(SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); - assertEquals(1, mCleanupCalls.size()); - } - - private void waitForBackOffTimeInterval() throws InterruptedException { - long waitForMs = mMetrics.getAnalyticsMessages().getTrackEngageRetryAfter(); - Thread.sleep(waitForMs); - Thread.sleep(1500); - } - - private void waitForFlushInternval() throws InterruptedException { - Thread.sleep(mFlushInterval); - Thread.sleep(1500); - } + waitForBackOffTimeInterval(); + + assertEquals(0, mCleanupCalls.size()); + assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + + waitForBackOffTimeInterval(); + + assertEquals(3, mCleanupCalls.size()); + assertEquals( + SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals( + SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals( + SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + } + + private void runMemoryTest() throws InterruptedException { + mForceOverMemThreshold = true; + mCleanupCalls.clear(); + mMetrics.track(FAIL_TEXT, null); + waitForFlushInternval(); + assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals(0, mCleanupCalls.size()); + + mForceOverMemThreshold = false; + mMetrics.track(SUCCEED_TEXT, null); + waitForFlushInternval(); + assertEquals( + SUCCEED_TEXT, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals(null, mPerformRequestCalls.poll(POLL_WAIT_MAX_MILLISECONDS, DEFAULT_TIMEUNIT)); + assertEquals(1, mCleanupCalls.size()); + } + + private void waitForBackOffTimeInterval() throws InterruptedException { + long waitForMs = mMetrics.getAnalyticsMessages().getTrackEngageRetryAfter(); + Thread.sleep(waitForMs); + Thread.sleep(1500); + } + + private void waitForFlushInternval() throws InterruptedException { + Thread.sleep(mFlushInterval); + Thread.sleep(1500); + } } diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/MPConfigTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/MPConfigTest.java index 1852a8ad5..e1743fbcd 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/MPConfigTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/MPConfigTest.java @@ -1,231 +1,253 @@ package com.mixpanel.android.mpmetrics; -import android.os.Bundle; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import org.junit.Test; -import org.junit.runner.RunWith; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import android.os.Bundle; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; import java.util.UUID; +import org.junit.Test; +import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class MPConfigTest { - public static final String TOKEN = "TOKEN"; - public static final String DISABLE_VIEW_CRAWLER_METADATA_KEY = "com.mixpanel.android.MPConfig.DisableViewCrawler"; - - @Test - public void testSetUseIpAddressForGeolocation() { - final Bundle metaData = new Bundle(); - metaData.putString("com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/?ip=1"); - metaData.putString("com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/?ip=1"); - - MPConfig config = mpConfig(metaData); - final MixpanelAPI mixpanelAPI = mixpanelApi(config); - - mixpanelAPI.setUseIpAddressForGeolocation(false); - assertEquals("https://api.mixpanel.com/track/?ip=0", config.getEventsEndpoint()); - assertEquals("https://api.mixpanel.com/engage/?ip=0", config.getPeopleEndpoint()); - assertEquals("https://api.mixpanel.com/groups/?ip=0", config.getGroupsEndpoint()); - - mixpanelAPI.setUseIpAddressForGeolocation(true); - assertEquals("https://api.mixpanel.com/track/?ip=1", config.getEventsEndpoint()); - assertEquals("https://api.mixpanel.com/engage/?ip=1", config.getPeopleEndpoint()); - assertEquals("https://api.mixpanel.com/groups/?ip=1", config.getGroupsEndpoint()); - } - - @Test - public void testSetUseIpAddressForGeolocationOverwrite() { - final Bundle metaData = new Bundle(); - metaData.putString("com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/?ip=1"); - metaData.putString("com.mixpanel.android.MPConfig.PeopleEndpoint", "https://api.mixpanel.com/engage/?ip=1"); - - MPConfig config = mpConfig(metaData); - final MixpanelAPI mixpanelAPI = mixpanelApi(config); - assertEquals("https://api.mixpanel.com/track/?ip=1", config.getEventsEndpoint()); - assertEquals("https://api.mixpanel.com/engage/?ip=1", config.getPeopleEndpoint()); - - mixpanelAPI.setUseIpAddressForGeolocation(false); - assertEquals("https://api.mixpanel.com/track/?ip=0", config.getEventsEndpoint()); - assertEquals("https://api.mixpanel.com/engage/?ip=0", config.getPeopleEndpoint()); - - final Bundle metaData2 = new Bundle(); - metaData2.putString("com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/?ip=0"); - metaData2.putString("com.mixpanel.android.MPConfig.PeopleEndpoint", "https://api.mixpanel.com/engage/?ip=0"); - - MPConfig config2 = mpConfig(metaData2); - final MixpanelAPI mixpanelAPI2 = mixpanelApi(config2); - assertEquals("https://api.mixpanel.com/track/?ip=0", config2.getEventsEndpoint()); - assertEquals("https://api.mixpanel.com/engage/?ip=0", config2.getPeopleEndpoint()); - - mixpanelAPI2.setUseIpAddressForGeolocation(true); - assertEquals("https://api.mixpanel.com/track/?ip=1", config2.getEventsEndpoint()); - assertEquals("https://api.mixpanel.com/engage/?ip=1", config2.getPeopleEndpoint()); - } - - @Test - public void testEndPointAndGeoSettingBothReadFromConfigTrue() { - final Bundle metaData = new Bundle(); - metaData.putString("com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/"); - metaData.putString("com.mixpanel.android.MPConfig.PeopleEndpoint", "https://api.mixpanel.com/engage/"); - metaData.putString("com.mixpanel.android.MPConfig.GroupsEndpoint", "https://api.mixpanel.com/groups/"); - metaData.putBoolean("com.mixpanel.android.MPConfig.UseIpAddressForGeolocation", true); - - MPConfig config = mpConfig(metaData); - final MixpanelAPI mixpanelAPI = mixpanelApi(config); - assertEquals("https://api.mixpanel.com/track/?ip=1", config.getEventsEndpoint()); - assertEquals("https://api.mixpanel.com/engage/?ip=1", config.getPeopleEndpoint()); - assertEquals("https://api.mixpanel.com/groups/?ip=1", config.getGroupsEndpoint()); - } - - public void testSetServerURL() throws Exception { - final Bundle metaData = new Bundle(); - MPConfig config = mpConfig(metaData); - final MixpanelAPI mixpanelAPI = mixpanelApi(config); - // default Mixpanel endpoint - assertEquals("https://api.mixpanel.com/track/?ip=1", config.getEventsEndpoint()); - assertEquals("https://api.mixpanel.com/engage/?ip=1", config.getPeopleEndpoint()); - assertEquals("https://api.mixpanel.com/groups/?ip=1", config.getGroupsEndpoint()); - - mixpanelAPI.setServerURL("https://api-eu.mixpanel.com"); - assertEquals("https://api-eu.mixpanel.com/track/?ip=1", config.getEventsEndpoint()); - assertEquals("https://api-eu.mixpanel.com/engage/?ip=1", config.getPeopleEndpoint()); - assertEquals("https://api-eu.mixpanel.com/groups/?ip=1", config.getGroupsEndpoint()); - } - - @Test - public void testEndPointAndGeoSettingBothReadFromConfigFalse() { - final Bundle metaData = new Bundle(); - metaData.putString("com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/"); - metaData.putString("com.mixpanel.android.MPConfig.PeopleEndpoint", "https://api.mixpanel.com/engage/"); - metaData.putString("com.mixpanel.android.MPConfig.GroupsEndpoint", "https://api.mixpanel.com/groups/"); - metaData.putBoolean("com.mixpanel.android.MPConfig.UseIpAddressForGeolocation", false); - - MPConfig config = mpConfig(metaData); - final MixpanelAPI mixpanelAPI = mixpanelApi(config); - assertEquals("https://api.mixpanel.com/track/?ip=0", config.getEventsEndpoint()); - assertEquals("https://api.mixpanel.com/engage/?ip=0", config.getPeopleEndpoint()); - assertEquals("https://api.mixpanel.com/groups/?ip=0", config.getGroupsEndpoint()); - } - - @Test - public void testEndPointAndGeoSettingBothReadFromConfigFalseOverwrite() { - final Bundle metaData = new Bundle(); - metaData.putString("com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/?ip=1"); - metaData.putString("com.mixpanel.android.MPConfig.PeopleEndpoint", "https://api.mixpanel.com/engage/?ip=1"); - metaData.putString("com.mixpanel.android.MPConfig.GroupsEndpoint", "https://api.mixpanel.com/groups/?ip=1"); - metaData.putBoolean("com.mixpanel.android.MPConfig.UseIpAddressForGeolocation", false); - - MPConfig config = mpConfig(metaData); - final MixpanelAPI mixpanelAPI = mixpanelApi(config); - assertEquals("https://api.mixpanel.com/track/?ip=0", config.getEventsEndpoint()); - assertEquals("https://api.mixpanel.com/engage/?ip=0", config.getPeopleEndpoint()); - assertEquals("https://api.mixpanel.com/groups/?ip=0", config.getGroupsEndpoint()); - } - - @Test - public void testSetEnableLogging() throws Exception { - final Bundle metaData = new Bundle(); - MPConfig config = mpConfig(metaData); - final MixpanelAPI mixpanelAPI = mixpanelApi(config); - mixpanelAPI.setEnableLogging(true); - assertTrue(config.DEBUG); - mixpanelAPI.setEnableLogging(false); - assertFalse(config.DEBUG); - } - - - @Test - public void testSetFlushBatchSize() { - final Bundle metaData = new Bundle(); - MPConfig config = mpConfig(metaData); - final MixpanelAPI mixpanelAPI = mixpanelApi(config); - mixpanelAPI.setFlushBatchSize(10); - assertEquals(10, config.getFlushBatchSize()); - mixpanelAPI.setFlushBatchSize(100); - assertEquals(100, config.getFlushBatchSize()); - } - - @Test - public void testSetFlushBatchSize2() { - final Bundle metaData = new Bundle(); - metaData.putInt("com.mixpanel.android.MPConfig.FlushBatchSize", 5); - MPConfig config = mpConfig(metaData); - final MixpanelAPI mixpanelAPI = mixpanelApi(config); - assertEquals(5, mixpanelAPI.getFlushBatchSize()); - } - - @Test - public void testSetFlushBatchSizeMulptipleConfigs() { - String fakeToken = UUID.randomUUID().toString(); - MixpanelAPI mixpanel1 = MixpanelAPI.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), fakeToken, false); - mixpanel1.setFlushBatchSize(10); - assertEquals(10, mixpanel1.getFlushBatchSize()); - - String fakeToken2 = UUID.randomUUID().toString(); - MixpanelAPI mixpanel2 = MixpanelAPI.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), fakeToken2, false); - mixpanel2.setFlushBatchSize(20); - assertEquals(20, mixpanel2.getFlushBatchSize()); - // mixpanel2 should not overwrite the settings to mixpanel1 - assertEquals(10, mixpanel1.getFlushBatchSize()); - } - - - @Test - public void testSetMaximumDatabaseLimit() { - final Bundle metaData = new Bundle(); - MPConfig config = mpConfig(metaData); - final MixpanelAPI mixpanelAPI = mixpanelApi(config); - mixpanelAPI.setMaximumDatabaseLimit(10000); - assertEquals(10000, config.getMaximumDatabaseLimit()); - } - - @Test - public void testSetMaximumDatabaseLimit2() { - final Bundle metaData = new Bundle(); - metaData.putInt("com.mixpanel.android.MPConfig.MaximumDatabaseLimit", 100000000); - MPConfig config = mpConfig(metaData); - final MixpanelAPI mixpanelAPI = mixpanelApi(config); - assertEquals(100000000, mixpanelAPI.getMaximumDatabaseLimit()); - } - - @Test - public void testShouldGzipRequestPayload() { - final Bundle metaData = new Bundle(); - metaData.putBoolean("com.mixpanel.android.MPConfig.GzipRequestPayload", true); - MPConfig mpConfig = mpConfig(metaData); - assertTrue(mpConfig.shouldGzipRequestPayload()); - - mpConfig.setShouldGzipRequestPayload(false); - assertFalse(mpConfig.shouldGzipRequestPayload()); - - mpConfig.setShouldGzipRequestPayload(true); - assertTrue(mpConfig.shouldGzipRequestPayload()); - - // assert false by default - MPConfig mpConfig2 = mpConfig(new Bundle()); - assertFalse(mpConfig2.shouldGzipRequestPayload()); - - MixpanelAPI mixpanelAPI = mixpanelApi(mpConfig); - - assertTrue(mixpanelAPI.shouldGzipRequestPayload()); - - mixpanelAPI.setShouldGzipRequestPayload(false); - assertFalse(mixpanelAPI.shouldGzipRequestPayload()); - - } - - private MPConfig mpConfig(final Bundle metaData) { - return new MPConfig(metaData, InstrumentationRegistry.getInstrumentation().getContext(), null); - } - - private MixpanelAPI mixpanelApi(final MPConfig config) { - return new MixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), new TestUtils.EmptyPreferences(InstrumentationRegistry.getInstrumentation().getContext()), TOKEN, config, false, null,null, true); - } + public static final String TOKEN = "TOKEN"; + public static final String DISABLE_VIEW_CRAWLER_METADATA_KEY = + "com.mixpanel.android.MPConfig.DisableViewCrawler"; + + @Test + public void testSetUseIpAddressForGeolocation() { + final Bundle metaData = new Bundle(); + metaData.putString( + "com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/?ip=1"); + metaData.putString( + "com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/?ip=1"); + + MPConfig config = mpConfig(metaData); + final MixpanelAPI mixpanelAPI = mixpanelApi(config); + + mixpanelAPI.setUseIpAddressForGeolocation(false); + assertEquals("https://api.mixpanel.com/track/?ip=0", config.getEventsEndpoint()); + assertEquals("https://api.mixpanel.com/engage/?ip=0", config.getPeopleEndpoint()); + assertEquals("https://api.mixpanel.com/groups/?ip=0", config.getGroupsEndpoint()); + + mixpanelAPI.setUseIpAddressForGeolocation(true); + assertEquals("https://api.mixpanel.com/track/?ip=1", config.getEventsEndpoint()); + assertEquals("https://api.mixpanel.com/engage/?ip=1", config.getPeopleEndpoint()); + assertEquals("https://api.mixpanel.com/groups/?ip=1", config.getGroupsEndpoint()); + } + + @Test + public void testSetUseIpAddressForGeolocationOverwrite() { + final Bundle metaData = new Bundle(); + metaData.putString( + "com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/?ip=1"); + metaData.putString( + "com.mixpanel.android.MPConfig.PeopleEndpoint", "https://api.mixpanel.com/engage/?ip=1"); + + MPConfig config = mpConfig(metaData); + final MixpanelAPI mixpanelAPI = mixpanelApi(config); + assertEquals("https://api.mixpanel.com/track/?ip=1", config.getEventsEndpoint()); + assertEquals("https://api.mixpanel.com/engage/?ip=1", config.getPeopleEndpoint()); + + mixpanelAPI.setUseIpAddressForGeolocation(false); + assertEquals("https://api.mixpanel.com/track/?ip=0", config.getEventsEndpoint()); + assertEquals("https://api.mixpanel.com/engage/?ip=0", config.getPeopleEndpoint()); + + final Bundle metaData2 = new Bundle(); + metaData2.putString( + "com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/?ip=0"); + metaData2.putString( + "com.mixpanel.android.MPConfig.PeopleEndpoint", "https://api.mixpanel.com/engage/?ip=0"); + + MPConfig config2 = mpConfig(metaData2); + final MixpanelAPI mixpanelAPI2 = mixpanelApi(config2); + assertEquals("https://api.mixpanel.com/track/?ip=0", config2.getEventsEndpoint()); + assertEquals("https://api.mixpanel.com/engage/?ip=0", config2.getPeopleEndpoint()); + + mixpanelAPI2.setUseIpAddressForGeolocation(true); + assertEquals("https://api.mixpanel.com/track/?ip=1", config2.getEventsEndpoint()); + assertEquals("https://api.mixpanel.com/engage/?ip=1", config2.getPeopleEndpoint()); + } + + @Test + public void testEndPointAndGeoSettingBothReadFromConfigTrue() { + final Bundle metaData = new Bundle(); + metaData.putString( + "com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/"); + metaData.putString( + "com.mixpanel.android.MPConfig.PeopleEndpoint", "https://api.mixpanel.com/engage/"); + metaData.putString( + "com.mixpanel.android.MPConfig.GroupsEndpoint", "https://api.mixpanel.com/groups/"); + metaData.putBoolean("com.mixpanel.android.MPConfig.UseIpAddressForGeolocation", true); + + MPConfig config = mpConfig(metaData); + final MixpanelAPI mixpanelAPI = mixpanelApi(config); + assertEquals("https://api.mixpanel.com/track/?ip=1", config.getEventsEndpoint()); + assertEquals("https://api.mixpanel.com/engage/?ip=1", config.getPeopleEndpoint()); + assertEquals("https://api.mixpanel.com/groups/?ip=1", config.getGroupsEndpoint()); + } + + public void testSetServerURL() throws Exception { + final Bundle metaData = new Bundle(); + MPConfig config = mpConfig(metaData); + final MixpanelAPI mixpanelAPI = mixpanelApi(config); + // default Mixpanel endpoint + assertEquals("https://api.mixpanel.com/track/?ip=1", config.getEventsEndpoint()); + assertEquals("https://api.mixpanel.com/engage/?ip=1", config.getPeopleEndpoint()); + assertEquals("https://api.mixpanel.com/groups/?ip=1", config.getGroupsEndpoint()); + + mixpanelAPI.setServerURL("https://api-eu.mixpanel.com"); + assertEquals("https://api-eu.mixpanel.com/track/?ip=1", config.getEventsEndpoint()); + assertEquals("https://api-eu.mixpanel.com/engage/?ip=1", config.getPeopleEndpoint()); + assertEquals("https://api-eu.mixpanel.com/groups/?ip=1", config.getGroupsEndpoint()); + } + + @Test + public void testEndPointAndGeoSettingBothReadFromConfigFalse() { + final Bundle metaData = new Bundle(); + metaData.putString( + "com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/"); + metaData.putString( + "com.mixpanel.android.MPConfig.PeopleEndpoint", "https://api.mixpanel.com/engage/"); + metaData.putString( + "com.mixpanel.android.MPConfig.GroupsEndpoint", "https://api.mixpanel.com/groups/"); + metaData.putBoolean("com.mixpanel.android.MPConfig.UseIpAddressForGeolocation", false); + + MPConfig config = mpConfig(metaData); + final MixpanelAPI mixpanelAPI = mixpanelApi(config); + assertEquals("https://api.mixpanel.com/track/?ip=0", config.getEventsEndpoint()); + assertEquals("https://api.mixpanel.com/engage/?ip=0", config.getPeopleEndpoint()); + assertEquals("https://api.mixpanel.com/groups/?ip=0", config.getGroupsEndpoint()); + } + + @Test + public void testEndPointAndGeoSettingBothReadFromConfigFalseOverwrite() { + final Bundle metaData = new Bundle(); + metaData.putString( + "com.mixpanel.android.MPConfig.EventsEndpoint", "https://api.mixpanel.com/track/?ip=1"); + metaData.putString( + "com.mixpanel.android.MPConfig.PeopleEndpoint", "https://api.mixpanel.com/engage/?ip=1"); + metaData.putString( + "com.mixpanel.android.MPConfig.GroupsEndpoint", "https://api.mixpanel.com/groups/?ip=1"); + metaData.putBoolean("com.mixpanel.android.MPConfig.UseIpAddressForGeolocation", false); + + MPConfig config = mpConfig(metaData); + final MixpanelAPI mixpanelAPI = mixpanelApi(config); + assertEquals("https://api.mixpanel.com/track/?ip=0", config.getEventsEndpoint()); + assertEquals("https://api.mixpanel.com/engage/?ip=0", config.getPeopleEndpoint()); + assertEquals("https://api.mixpanel.com/groups/?ip=0", config.getGroupsEndpoint()); + } + + @Test + public void testSetEnableLogging() throws Exception { + final Bundle metaData = new Bundle(); + MPConfig config = mpConfig(metaData); + final MixpanelAPI mixpanelAPI = mixpanelApi(config); + mixpanelAPI.setEnableLogging(true); + assertTrue(config.DEBUG); + mixpanelAPI.setEnableLogging(false); + assertFalse(config.DEBUG); + } + + @Test + public void testSetFlushBatchSize() { + final Bundle metaData = new Bundle(); + MPConfig config = mpConfig(metaData); + final MixpanelAPI mixpanelAPI = mixpanelApi(config); + mixpanelAPI.setFlushBatchSize(10); + assertEquals(10, config.getFlushBatchSize()); + mixpanelAPI.setFlushBatchSize(100); + assertEquals(100, config.getFlushBatchSize()); + } + + @Test + public void testSetFlushBatchSize2() { + final Bundle metaData = new Bundle(); + metaData.putInt("com.mixpanel.android.MPConfig.FlushBatchSize", 5); + MPConfig config = mpConfig(metaData); + final MixpanelAPI mixpanelAPI = mixpanelApi(config); + assertEquals(5, mixpanelAPI.getFlushBatchSize()); + } + + @Test + public void testSetFlushBatchSizeMulptipleConfigs() { + String fakeToken = UUID.randomUUID().toString(); + MixpanelAPI mixpanel1 = + MixpanelAPI.getInstance( + InstrumentationRegistry.getInstrumentation().getContext(), fakeToken, false); + mixpanel1.setFlushBatchSize(10); + assertEquals(10, mixpanel1.getFlushBatchSize()); + + String fakeToken2 = UUID.randomUUID().toString(); + MixpanelAPI mixpanel2 = + MixpanelAPI.getInstance( + InstrumentationRegistry.getInstrumentation().getContext(), fakeToken2, false); + mixpanel2.setFlushBatchSize(20); + assertEquals(20, mixpanel2.getFlushBatchSize()); + // mixpanel2 should not overwrite the settings to mixpanel1 + assertEquals(10, mixpanel1.getFlushBatchSize()); + } + + @Test + public void testSetMaximumDatabaseLimit() { + final Bundle metaData = new Bundle(); + MPConfig config = mpConfig(metaData); + final MixpanelAPI mixpanelAPI = mixpanelApi(config); + mixpanelAPI.setMaximumDatabaseLimit(10000); + assertEquals(10000, config.getMaximumDatabaseLimit()); + } + + @Test + public void testSetMaximumDatabaseLimit2() { + final Bundle metaData = new Bundle(); + metaData.putInt("com.mixpanel.android.MPConfig.MaximumDatabaseLimit", 100000000); + MPConfig config = mpConfig(metaData); + final MixpanelAPI mixpanelAPI = mixpanelApi(config); + assertEquals(100000000, mixpanelAPI.getMaximumDatabaseLimit()); + } + + @Test + public void testShouldGzipRequestPayload() { + final Bundle metaData = new Bundle(); + metaData.putBoolean("com.mixpanel.android.MPConfig.GzipRequestPayload", true); + MPConfig mpConfig = mpConfig(metaData); + assertTrue(mpConfig.shouldGzipRequestPayload()); + + mpConfig.setShouldGzipRequestPayload(false); + assertFalse(mpConfig.shouldGzipRequestPayload()); + + mpConfig.setShouldGzipRequestPayload(true); + assertTrue(mpConfig.shouldGzipRequestPayload()); + + // assert false by default + MPConfig mpConfig2 = mpConfig(new Bundle()); + assertFalse(mpConfig2.shouldGzipRequestPayload()); + + MixpanelAPI mixpanelAPI = mixpanelApi(mpConfig); + + assertTrue(mixpanelAPI.shouldGzipRequestPayload()); + + mixpanelAPI.setShouldGzipRequestPayload(false); + assertFalse(mixpanelAPI.shouldGzipRequestPayload()); + } + + private MPConfig mpConfig(final Bundle metaData) { + return new MPConfig(metaData, InstrumentationRegistry.getInstrumentation().getContext(), null); + } + + private MixpanelAPI mixpanelApi(final MPConfig config) { + return new MixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + new TestUtils.EmptyPreferences(InstrumentationRegistry.getInstrumentation().getContext()), + TOKEN, + config, + false, + null, + null, + true); + } } diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/MixpanelBasicTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/MixpanelBasicTest.java index 56f64f411..c1ab94c51 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/MixpanelBasicTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/MixpanelBasicTest.java @@ -1,31 +1,31 @@ package com.mixpanel.android.mpmetrics; +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + import android.content.Context; import android.content.SharedPreferences; import android.os.Build; import android.os.Bundle; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import androidx.test.platform.app.InstrumentationRegistry; - import com.mixpanel.android.BuildConfig; import com.mixpanel.android.util.Base64Coder; import com.mixpanel.android.util.HttpService; import com.mixpanel.android.util.ProxyServerInteractor; import com.mixpanel.android.util.RemoteService; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - import java.io.IOException; -import java.lang.reflect.Field; import java.net.URLEncoder; import java.util.ArrayList; import java.util.HashMap; @@ -36,1714 +36,2094 @@ import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; - import javax.net.ssl.SSLSocketFactory; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNotSame; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThat; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; -import static org.hamcrest.CoreMatchers.*; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) @LargeTest public class MixpanelBasicTest { - @Before - public void setUp() throws Exception { - mMockPreferences = new TestUtils.EmptyPreferences(InstrumentationRegistry.getInstrumentation().getContext()); - AnalyticsMessages messages = AnalyticsMessages.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)); - messages.hardKill(); - Thread.sleep(2000); - - try { - SystemInformation systemInformation = SystemInformation.getInstance(InstrumentationRegistry.getInstrumentation().getContext()); - - final StringBuilder queryBuilder = new StringBuilder(); - queryBuilder.append("&properties="); - JSONObject properties = new JSONObject(); - properties.putOpt("$android_lib_version", MPConfig.VERSION); - properties.putOpt("$android_app_version", systemInformation.getAppVersionName()); - properties.putOpt("$android_version", Build.VERSION.RELEASE); - properties.putOpt("$android_app_release", systemInformation.getAppVersionCode()); - properties.putOpt("$android_device_model", Build.MODEL); - queryBuilder.append(URLEncoder.encode(properties.toString(), "utf-8")); - mAppProperties = queryBuilder.toString(); - } catch (Exception e) {} - } // end of setUp() method definition - - @Test - public void testVersionsMatch() { - assertEquals(BuildConfig.MIXPANEL_VERSION, MPConfig.VERSION); + @Before + public void setUp() throws Exception { + mMockPreferences = + new TestUtils.EmptyPreferences(InstrumentationRegistry.getInstrumentation().getContext()); + AnalyticsMessages messages = + AnalyticsMessages.getInstance( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)); + messages.hardKill(); + Thread.sleep(2000); + + try { + SystemInformation systemInformation = + SystemInformation.getInstance(InstrumentationRegistry.getInstrumentation().getContext()); + + final StringBuilder queryBuilder = new StringBuilder(); + queryBuilder.append("&properties="); + JSONObject properties = new JSONObject(); + properties.putOpt("$android_lib_version", MPConfig.VERSION); + properties.putOpt("$android_app_version", systemInformation.getAppVersionName()); + properties.putOpt("$android_version", Build.VERSION.RELEASE); + properties.putOpt("$android_app_release", systemInformation.getAppVersionCode()); + properties.putOpt("$android_device_model", Build.MODEL); + queryBuilder.append(URLEncoder.encode(properties.toString(), "utf-8")); + mAppProperties = queryBuilder.toString(); + } catch (Exception e) { } - - @Test - public void testGeneratedDistinctId() { - String fakeToken = UUID.randomUUID().toString(); - MixpanelAPI mixpanel = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, fakeToken); - String generatedId1 = mixpanel.getDistinctId(); - assertThat(generatedId1, startsWith("$device:")); - assertEquals(generatedId1, "$device:" + mixpanel.getAnonymousId()); - - mixpanel.reset(); - String generatedId2 = mixpanel.getDistinctId(); - assertThat(generatedId2, startsWith("$device:")); - assertEquals(generatedId2, "$device:" + mixpanel.getAnonymousId()); - assertNotEquals(generatedId1, generatedId2); - } - - @Test - public void testDeleteDB() { - Map beforeMap = new HashMap(); - beforeMap.put("added", "before"); - JSONObject before = new JSONObject(beforeMap); - - Map afterMap = new HashMap(); - afterMap.put("added", "after"); - JSONObject after = new JSONObject(afterMap); - - MPDbAdapter adapter = new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), "DeleteTestDB", MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)); - adapter.addJSON(before, "ATOKEN", MPDbAdapter.Table.EVENTS); - adapter.addJSON(before, "ATOKEN", MPDbAdapter.Table.PEOPLE); - adapter.addJSON(before, "ATOKEN", MPDbAdapter.Table.GROUPS); - adapter.deleteDB(); - - String[] emptyEventsData = adapter.generateDataString(MPDbAdapter.Table.EVENTS, "ATOKEN"); - assertNull(emptyEventsData); - String[] emptyPeopleData = adapter.generateDataString(MPDbAdapter.Table.PEOPLE, "ATOKEN"); - assertNull(emptyPeopleData); - String[] emptyGroupsData = adapter.generateDataString(MPDbAdapter.Table.GROUPS, "ATOKEN"); - assertNull(emptyGroupsData); - - adapter.addJSON(after, "ATOKEN", MPDbAdapter.Table.EVENTS); - adapter.addJSON(after, "ATOKEN", MPDbAdapter.Table.PEOPLE); - adapter.addJSON(after, "ATOKEN", MPDbAdapter.Table.GROUPS); - - try { - String[] someEventsData = adapter.generateDataString(MPDbAdapter.Table.EVENTS, "ATOKEN"); - JSONArray someEvents = new JSONArray(someEventsData[1]); - assertEquals(someEvents.length(), 1); - assertEquals(someEvents.getJSONObject(0).get("added"), "after"); - - String[] somePeopleData = adapter.generateDataString(MPDbAdapter.Table.PEOPLE, "ATOKEN"); - JSONArray somePeople = new JSONArray(somePeopleData[1]); - assertEquals(somePeople.length(), 1); - assertEquals(somePeople.getJSONObject(0).get("added"), "after"); - - String[] someGroupsData = adapter.generateDataString(MPDbAdapter.Table.GROUPS, "ATOKEN"); - JSONArray someGroups = new JSONArray(somePeopleData[1]); - assertEquals(someGroups.length(), 1); - assertEquals(someGroups.getJSONObject(0).get("added"), "after"); } catch (JSONException e) { - fail("Unexpected JSON or lack thereof in MPDbAdapter test"); - } + } // end of setUp() method definition + + @Test + public void testVersionsMatch() { + assertEquals(BuildConfig.MIXPANEL_VERSION, MPConfig.VERSION); + } + + @Test + public void testGeneratedDistinctId() { + String fakeToken = UUID.randomUUID().toString(); + MixpanelAPI mixpanel = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, fakeToken); + String generatedId1 = mixpanel.getDistinctId(); + assertThat(generatedId1, startsWith("$device:")); + assertEquals(generatedId1, "$device:" + mixpanel.getAnonymousId()); + + mixpanel.reset(); + String generatedId2 = mixpanel.getDistinctId(); + assertThat(generatedId2, startsWith("$device:")); + assertEquals(generatedId2, "$device:" + mixpanel.getAnonymousId()); + assertNotEquals(generatedId1, generatedId2); + } + + @Test + public void testDeleteDB() { + Map beforeMap = new HashMap(); + beforeMap.put("added", "before"); + JSONObject before = new JSONObject(beforeMap); + + Map afterMap = new HashMap(); + afterMap.put("added", "after"); + JSONObject after = new JSONObject(afterMap); + + MPDbAdapter adapter = + new MPDbAdapter( + InstrumentationRegistry.getInstrumentation().getContext(), + "DeleteTestDB", + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)); + adapter.addJSON(before, "ATOKEN", MPDbAdapter.Table.EVENTS); + adapter.addJSON(before, "ATOKEN", MPDbAdapter.Table.PEOPLE); + adapter.addJSON(before, "ATOKEN", MPDbAdapter.Table.GROUPS); + adapter.deleteDB(); + + String[] emptyEventsData = adapter.generateDataString(MPDbAdapter.Table.EVENTS, "ATOKEN"); + assertNull(emptyEventsData); + String[] emptyPeopleData = adapter.generateDataString(MPDbAdapter.Table.PEOPLE, "ATOKEN"); + assertNull(emptyPeopleData); + String[] emptyGroupsData = adapter.generateDataString(MPDbAdapter.Table.GROUPS, "ATOKEN"); + assertNull(emptyGroupsData); + + adapter.addJSON(after, "ATOKEN", MPDbAdapter.Table.EVENTS); + adapter.addJSON(after, "ATOKEN", MPDbAdapter.Table.PEOPLE); + adapter.addJSON(after, "ATOKEN", MPDbAdapter.Table.GROUPS); + + try { + String[] someEventsData = adapter.generateDataString(MPDbAdapter.Table.EVENTS, "ATOKEN"); + JSONArray someEvents = new JSONArray(someEventsData[1]); + assertEquals(someEvents.length(), 1); + assertEquals(someEvents.getJSONObject(0).get("added"), "after"); + + String[] somePeopleData = adapter.generateDataString(MPDbAdapter.Table.PEOPLE, "ATOKEN"); + JSONArray somePeople = new JSONArray(somePeopleData[1]); + assertEquals(somePeople.length(), 1); + assertEquals(somePeople.getJSONObject(0).get("added"), "after"); + + String[] someGroupsData = adapter.generateDataString(MPDbAdapter.Table.GROUPS, "ATOKEN"); + JSONArray someGroups = new JSONArray(somePeopleData[1]); + assertEquals(someGroups.length(), 1); + assertEquals(someGroups.getJSONObject(0).get("added"), "after"); + } catch (JSONException e) { + fail("Unexpected JSON or lack thereof in MPDbAdapter test"); } - - @Test - public void testLooperDestruction() { - final BlockingQueue messages = new LinkedBlockingQueue(); - - final MPDbAdapter explodingDb = new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public int addJSON(JSONObject message, String token, MPDbAdapter.Table table) { - messages.add(message); - throw new RuntimeException("BANG!"); - } + } + + @Test + public void testLooperDestruction() { + final BlockingQueue messages = new LinkedBlockingQueue(); + + final MPDbAdapter explodingDb = + new MPDbAdapter( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public int addJSON(JSONObject message, String token, MPDbAdapter.Table table) { + messages.add(message); + throw new RuntimeException("BANG!"); + } }; - final AnalyticsMessages explodingMessages = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - // This will throw inside of our worker thread. - @Override - public MPDbAdapter makeDbAdapter(Context context) { - return explodingDb; - } + final AnalyticsMessages explodingMessages = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + // This will throw inside of our worker thread. + @Override + public MPDbAdapter makeDbAdapter(Context context) { + return explodingDb; + } }; - MixpanelAPI mixpanel = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "TEST TOKEN testLooperDisaster") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return explodingMessages; - } + MixpanelAPI mixpanel = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "TEST TOKEN testLooperDisaster") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return explodingMessages; + } }; - try { - mixpanel.reset(); - assertFalse(explodingMessages.isDead()); + try { + mixpanel.reset(); + assertFalse(explodingMessages.isDead()); - mixpanel.track("event1", null); - JSONObject found = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertNotNull("should found", found); + mixpanel.track("event1", null); + JSONObject found = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertNotNull("should found", found); - Thread.sleep(1000); - assertTrue(explodingMessages.isDead()); + Thread.sleep(1000); + assertTrue(explodingMessages.isDead()); - mixpanel.track("event2", null); - JSONObject shouldntFind = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertNull(shouldntFind); - assertTrue(explodingMessages.isDead()); - } catch (InterruptedException e) { - fail("Unexpected interruption"); - } + mixpanel.track("event2", null); + JSONObject shouldntFind = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertNull(shouldntFind); + assertTrue(explodingMessages.isDead()); + } catch (InterruptedException e) { + fail("Unexpected interruption"); } - - @Test - public void testEventOperations() throws JSONException { - final BlockingQueue messages = new LinkedBlockingQueue(); - - final MPDbAdapter eventOperationsAdapter = new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public int addJSON(JSONObject message, String token, MPDbAdapter.Table table) { - messages.add(message); - - return 1; - } + } + + @Test + public void testEventOperations() throws JSONException { + final BlockingQueue messages = new LinkedBlockingQueue(); + + final MPDbAdapter eventOperationsAdapter = + new MPDbAdapter( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public int addJSON(JSONObject message, String token, MPDbAdapter.Table table) { + messages.add(message); + + return 1; + } }; - final AnalyticsMessages eventOperationsMessages = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - // This will throw inside of our worker thread. - @Override - public MPDbAdapter makeDbAdapter(Context context) { - return eventOperationsAdapter; - } + final AnalyticsMessages eventOperationsMessages = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + // This will throw inside of our worker thread. + @Override + public MPDbAdapter makeDbAdapter(Context context) { + return eventOperationsAdapter; + } }; - MixpanelAPI mixpanel = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "Test event operations") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return eventOperationsMessages; - } + MixpanelAPI mixpanel = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "Test event operations") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return eventOperationsMessages; + } }; - JSONObject jsonObj1 = new JSONObject(); - JSONObject jsonObj2 = new JSONObject(); - JSONObject jsonObj3 = new JSONObject(); - JSONObject jsonObj4 = new JSONObject(); - JSONObject jsonObj5 = new JSONObject(); - - Map mapObj1 = new HashMap<>(); - Map mapObj2 = new HashMap<>(); - Map mapObj3 = new HashMap<>(); - Map mapObj4 = new HashMap<>(); - Map mapObj5 = new HashMap<>(); - - jsonObj1.put("TRACK JSON STRING", "TRACK JSON STRING VALUE"); - jsonObj2.put("TRACK JSON INT", 1); - jsonObj3.put("TRACK JSON STRING ONCE", "TRACK JSON STRING ONCE VALUE"); - jsonObj4.put("TRACK JSON STRING ONCE", "SHOULD NOT SEE ME"); - jsonObj5.put("TRACK JSON NULL", JSONObject.NULL); - - - mapObj1.put("TRACK MAP STRING", "TRACK MAP STRING VALUE"); - mapObj2.put("TRACK MAP INT", 1); - mapObj3.put("TRACK MAP STRING ONCE", "TRACK MAP STRING ONCE VALUE"); - mapObj4.put("TRACK MAP STRING ONCE", "SHOULD NOT SEE ME"); - mapObj5.put("TRACK MAP CUSTOM OBJECT", mixpanel); - - try { - JSONObject message; - JSONObject properties; - - mixpanel.track("event1", null); - message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("event1", message.getString("event")); - - mixpanel.track("event2", jsonObj1); - message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("event2", message.getString("event")); - properties = message.getJSONObject("properties"); - assertEquals(jsonObj1.getString("TRACK JSON STRING"), properties.getString("TRACK JSON STRING")); - - mixpanel.trackMap("event3", null); - message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("event3", message.getString("event")); - - mixpanel.trackMap("event4", mapObj1); - message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("event4", message.getString("event")); - properties = message.getJSONObject("properties"); - assertEquals(mapObj1.get("TRACK MAP STRING"), properties.getString("TRACK MAP STRING")); - - mixpanel.registerSuperProperties(jsonObj2); - mixpanel.registerSuperPropertiesOnce(jsonObj3); - mixpanel.registerSuperPropertiesOnce(jsonObj4); - mixpanel.registerSuperPropertiesMap(mapObj2); - mixpanel.registerSuperPropertiesOnceMap(mapObj3); - mixpanel.registerSuperPropertiesOnceMap(mapObj4); - - mixpanel.track("event5", null); - message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("event5", message.getString("event")); - properties = message.getJSONObject("properties"); - assertEquals(jsonObj2.getInt("TRACK JSON INT"), properties.getInt("TRACK JSON INT")); - assertEquals(jsonObj3.getString("TRACK JSON STRING ONCE"), properties.getString("TRACK JSON STRING ONCE")); - assertEquals(mapObj2.get("TRACK MAP INT"), properties.getInt("TRACK MAP INT")); - assertEquals(mapObj3.get("TRACK MAP STRING ONCE"), properties.getString("TRACK MAP STRING ONCE")); - - mixpanel.unregisterSuperProperty("TRACK JSON INT"); - mixpanel.track("event6", null); - message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("event6", message.getString("event")); - properties = message.getJSONObject("properties"); - assertFalse(properties.has("TRACK JSON INT")); - - mixpanel.clearSuperProperties(); - mixpanel.track("event7", null); - message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("event7", message.getString("event")); - properties = message.getJSONObject("properties"); - assertFalse(properties.has("TRACK JSON STRING ONCE")); - - mixpanel.track("event8", jsonObj5); - message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("event8", message.getString("event")); - properties = message.getJSONObject("properties"); - assertEquals(jsonObj5.get("TRACK JSON NULL"), properties.get("TRACK JSON NULL")); - - mixpanel.trackMap("event contains custom object", mapObj5); - message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("event contains custom object", message.getString("event")); - } catch (InterruptedException e) { - fail("Unexpected interruption"); - } + JSONObject jsonObj1 = new JSONObject(); + JSONObject jsonObj2 = new JSONObject(); + JSONObject jsonObj3 = new JSONObject(); + JSONObject jsonObj4 = new JSONObject(); + JSONObject jsonObj5 = new JSONObject(); + + Map mapObj1 = new HashMap<>(); + Map mapObj2 = new HashMap<>(); + Map mapObj3 = new HashMap<>(); + Map mapObj4 = new HashMap<>(); + Map mapObj5 = new HashMap<>(); + + jsonObj1.put("TRACK JSON STRING", "TRACK JSON STRING VALUE"); + jsonObj2.put("TRACK JSON INT", 1); + jsonObj3.put("TRACK JSON STRING ONCE", "TRACK JSON STRING ONCE VALUE"); + jsonObj4.put("TRACK JSON STRING ONCE", "SHOULD NOT SEE ME"); + jsonObj5.put("TRACK JSON NULL", JSONObject.NULL); + + mapObj1.put("TRACK MAP STRING", "TRACK MAP STRING VALUE"); + mapObj2.put("TRACK MAP INT", 1); + mapObj3.put("TRACK MAP STRING ONCE", "TRACK MAP STRING ONCE VALUE"); + mapObj4.put("TRACK MAP STRING ONCE", "SHOULD NOT SEE ME"); + mapObj5.put("TRACK MAP CUSTOM OBJECT", mixpanel); + + try { + JSONObject message; + JSONObject properties; + + mixpanel.track("event1", null); + message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("event1", message.getString("event")); + + mixpanel.track("event2", jsonObj1); + message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("event2", message.getString("event")); + properties = message.getJSONObject("properties"); + assertEquals( + jsonObj1.getString("TRACK JSON STRING"), properties.getString("TRACK JSON STRING")); + + mixpanel.trackMap("event3", null); + message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("event3", message.getString("event")); + + mixpanel.trackMap("event4", mapObj1); + message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("event4", message.getString("event")); + properties = message.getJSONObject("properties"); + assertEquals(mapObj1.get("TRACK MAP STRING"), properties.getString("TRACK MAP STRING")); + + mixpanel.registerSuperProperties(jsonObj2); + mixpanel.registerSuperPropertiesOnce(jsonObj3); + mixpanel.registerSuperPropertiesOnce(jsonObj4); + mixpanel.registerSuperPropertiesMap(mapObj2); + mixpanel.registerSuperPropertiesOnceMap(mapObj3); + mixpanel.registerSuperPropertiesOnceMap(mapObj4); + + mixpanel.track("event5", null); + message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("event5", message.getString("event")); + properties = message.getJSONObject("properties"); + assertEquals(jsonObj2.getInt("TRACK JSON INT"), properties.getInt("TRACK JSON INT")); + assertEquals( + jsonObj3.getString("TRACK JSON STRING ONCE"), + properties.getString("TRACK JSON STRING ONCE")); + assertEquals(mapObj2.get("TRACK MAP INT"), properties.getInt("TRACK MAP INT")); + assertEquals( + mapObj3.get("TRACK MAP STRING ONCE"), properties.getString("TRACK MAP STRING ONCE")); + + mixpanel.unregisterSuperProperty("TRACK JSON INT"); + mixpanel.track("event6", null); + message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("event6", message.getString("event")); + properties = message.getJSONObject("properties"); + assertFalse(properties.has("TRACK JSON INT")); + + mixpanel.clearSuperProperties(); + mixpanel.track("event7", null); + message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("event7", message.getString("event")); + properties = message.getJSONObject("properties"); + assertFalse(properties.has("TRACK JSON STRING ONCE")); + + mixpanel.track("event8", jsonObj5); + message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("event8", message.getString("event")); + properties = message.getJSONObject("properties"); + assertEquals(jsonObj5.get("TRACK JSON NULL"), properties.get("TRACK JSON NULL")); + + mixpanel.trackMap("event contains custom object", mapObj5); + message = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("event contains custom object", message.getString("event")); + } catch (InterruptedException e) { + fail("Unexpected interruption"); } - - @Test - public void testPeopleMessageOperations() throws JSONException { - final List messages = new ArrayList(); - - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public void peopleMessage(PeopleDescription heard) { - messages.add(heard); - } + } + + @Test + public void testPeopleMessageOperations() throws JSONException { + final List messages = + new ArrayList(); + + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public void peopleMessage(PeopleDescription heard) { + messages.add(heard); + } }; - MixpanelAPI mixpanel = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "TEST TOKEN testIdentifyAfterSet") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } + MixpanelAPI mixpanel = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "TEST TOKEN testIdentifyAfterSet") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } }; - Map mapObj1 = new HashMap<>(); - mapObj1.put("SET MAP INT", 1); - Map mapObj2 = new HashMap<>(); - mapObj2.put("SET ONCE MAP STR", "SET ONCE MAP VALUE"); - - mixpanel.identify("TEST IDENTITY"); - - mixpanel.getPeople().set("SET NAME", "SET VALUE"); - mixpanel.getPeople().setMap(mapObj1); - mixpanel.getPeople().increment("INCREMENT NAME", 1); - mixpanel.getPeople().append("APPEND NAME", "APPEND VALUE"); - mixpanel.getPeople().setOnce("SET ONCE NAME", "SET ONCE VALUE"); - mixpanel.getPeople().setOnceMap(mapObj2); - mixpanel.getPeople().union("UNION NAME", new JSONArray("[100]")); - mixpanel.getPeople().unset("UNSET NAME"); - mixpanel.getPeople().trackCharge(100, new JSONObject("{\"name\": \"val\"}")); - mixpanel.getPeople().clearCharges(); - mixpanel.getPeople().deleteUser(); - - JSONObject setMessage = messages.get(0).getMessage().getJSONObject("$set"); - assertEquals("SET VALUE", setMessage.getString("SET NAME")); - - JSONObject setMapMessage = messages.get(1).getMessage().getJSONObject("$set"); - assertEquals(mapObj1.get("SET MAP INT"), setMapMessage.getInt("SET MAP INT")); - - JSONObject addMessage = messages.get(2).getMessage().getJSONObject("$add"); - assertEquals(1, addMessage.getInt("INCREMENT NAME")); - - JSONObject appendMessage = messages.get(3).getMessage().getJSONObject("$append"); - assertEquals("APPEND VALUE", appendMessage.get("APPEND NAME")); - - JSONObject setOnceMessage = messages.get(4).getMessage().getJSONObject("$set_once"); - assertEquals("SET ONCE VALUE", setOnceMessage.getString("SET ONCE NAME")); - - JSONObject setOnceMapMessage = messages.get(5).getMessage().getJSONObject("$set_once"); - assertEquals(mapObj2.get("SET ONCE MAP STR"), setOnceMapMessage.getString("SET ONCE MAP STR")); - - JSONObject unionMessage = messages.get(6).getMessage().getJSONObject("$union"); - JSONArray unionValues = unionMessage.getJSONArray("UNION NAME"); - assertEquals(1, unionValues.length()); - assertEquals(100, unionValues.getInt(0)); - - JSONArray unsetMessage = messages.get(7).getMessage().getJSONArray("$unset"); - assertEquals(1, unsetMessage.length()); - assertEquals("UNSET NAME", unsetMessage.get(0)); - - JSONObject trackChargeMessage = messages.get(8).getMessage().getJSONObject("$append"); - JSONObject transaction = trackChargeMessage.getJSONObject("$transactions"); - assertEquals(100.0d, transaction.getDouble("$amount"), 0); - - JSONArray clearChargesMessage = messages.get(9).getMessage().getJSONArray("$unset"); - assertEquals(1, clearChargesMessage.length()); - assertEquals("$transactions", clearChargesMessage.getString(0)); - - assertTrue(messages.get(10).getMessage().has("$delete")); - } + Map mapObj1 = new HashMap<>(); + mapObj1.put("SET MAP INT", 1); + Map mapObj2 = new HashMap<>(); + mapObj2.put("SET ONCE MAP STR", "SET ONCE MAP VALUE"); + + mixpanel.identify("TEST IDENTITY"); + + mixpanel.getPeople().set("SET NAME", "SET VALUE"); + mixpanel.getPeople().setMap(mapObj1); + mixpanel.getPeople().increment("INCREMENT NAME", 1); + mixpanel.getPeople().append("APPEND NAME", "APPEND VALUE"); + mixpanel.getPeople().setOnce("SET ONCE NAME", "SET ONCE VALUE"); + mixpanel.getPeople().setOnceMap(mapObj2); + mixpanel.getPeople().union("UNION NAME", new JSONArray("[100]")); + mixpanel.getPeople().unset("UNSET NAME"); + mixpanel.getPeople().trackCharge(100, new JSONObject("{\"name\": \"val\"}")); + mixpanel.getPeople().clearCharges(); + mixpanel.getPeople().deleteUser(); + + JSONObject setMessage = messages.get(0).getMessage().getJSONObject("$set"); + assertEquals("SET VALUE", setMessage.getString("SET NAME")); + + JSONObject setMapMessage = messages.get(1).getMessage().getJSONObject("$set"); + assertEquals(mapObj1.get("SET MAP INT"), setMapMessage.getInt("SET MAP INT")); + + JSONObject addMessage = messages.get(2).getMessage().getJSONObject("$add"); + assertEquals(1, addMessage.getInt("INCREMENT NAME")); + + JSONObject appendMessage = messages.get(3).getMessage().getJSONObject("$append"); + assertEquals("APPEND VALUE", appendMessage.get("APPEND NAME")); + + JSONObject setOnceMessage = messages.get(4).getMessage().getJSONObject("$set_once"); + assertEquals("SET ONCE VALUE", setOnceMessage.getString("SET ONCE NAME")); + + JSONObject setOnceMapMessage = messages.get(5).getMessage().getJSONObject("$set_once"); + assertEquals(mapObj2.get("SET ONCE MAP STR"), setOnceMapMessage.getString("SET ONCE MAP STR")); + + JSONObject unionMessage = messages.get(6).getMessage().getJSONObject("$union"); + JSONArray unionValues = unionMessage.getJSONArray("UNION NAME"); + assertEquals(1, unionValues.length()); + assertEquals(100, unionValues.getInt(0)); + + JSONArray unsetMessage = messages.get(7).getMessage().getJSONArray("$unset"); + assertEquals(1, unsetMessage.length()); + assertEquals("UNSET NAME", unsetMessage.get(0)); + + JSONObject trackChargeMessage = messages.get(8).getMessage().getJSONObject("$append"); + JSONObject transaction = trackChargeMessage.getJSONObject("$transactions"); + assertEquals(100.0d, transaction.getDouble("$amount"), 0); + + JSONArray clearChargesMessage = messages.get(9).getMessage().getJSONArray("$unset"); + assertEquals(1, clearChargesMessage.length()); + assertEquals("$transactions", clearChargesMessage.getString(0)); + + assertTrue(messages.get(10).getMessage().has("$delete")); + } + + @Test + public void testGroupOperations() throws JSONException { + final List messages = + new ArrayList(); + + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public void groupMessage(GroupDescription heard) { + messages.add(heard); + } + }; - @Test - public void testGroupOperations() throws JSONException { - final List messages = new ArrayList(); + MixpanelAPI mixpanel = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "TEST TOKEN testGroupOperations") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } + }; - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public void groupMessage(GroupDescription heard) { - messages.add(heard); - } + Map mapObj1 = new HashMap<>(); + mapObj1.put("SET MAP INT", 1); + Map mapObj2 = new HashMap<>(); + mapObj2.put("SET ONCE MAP STR", "SET ONCE MAP VALUE"); + + String groupKey = "group key"; + String groupID = "group id"; + + mixpanel.getGroup(groupKey, groupID).set("SET NAME", "SET VALUE"); + mixpanel.getGroup(groupKey, groupID).setMap(mapObj1); + mixpanel.getGroup(groupKey, groupID).setOnce("SET ONCE NAME", "SET ONCE VALUE"); + mixpanel.getGroup(groupKey, groupID).setOnceMap(mapObj2); + mixpanel.getGroup(groupKey, groupID).union("UNION NAME", new JSONArray("[100]")); + mixpanel.getGroup(groupKey, groupID).unset("UNSET NAME"); + mixpanel.getGroup(groupKey, groupID).deleteGroup(); + + JSONObject setMessage = messages.get(0).getMessage(); + assertEquals(setMessage.getString("$group_key"), groupKey); + assertEquals(setMessage.getString("$group_id"), groupID); + assertEquals("SET VALUE", setMessage.getJSONObject("$set").getString("SET NAME")); + + JSONObject setMapMessage = messages.get(1).getMessage(); + assertEquals(setMapMessage.getString("$group_key"), groupKey); + assertEquals(setMapMessage.getString("$group_id"), groupID); + assertEquals( + mapObj1.get("SET MAP INT"), setMapMessage.getJSONObject("$set").getInt("SET MAP INT")); + + JSONObject setOnceMessage = messages.get(2).getMessage(); + assertEquals(setOnceMessage.getString("$group_key"), groupKey); + assertEquals(setOnceMessage.getString("$group_id"), groupID); + assertEquals( + "SET ONCE VALUE", setOnceMessage.getJSONObject("$set_once").getString("SET ONCE NAME")); + + JSONObject setOnceMapMessage = messages.get(3).getMessage(); + assertEquals(setOnceMapMessage.getString("$group_key"), groupKey); + assertEquals(setOnceMapMessage.getString("$group_id"), groupID); + assertEquals( + mapObj2.get("SET ONCE MAP STR"), + setOnceMapMessage.getJSONObject("$set_once").getString("SET ONCE MAP STR")); + + JSONObject unionMessage = messages.get(4).getMessage(); + assertEquals(unionMessage.getString("$group_key"), groupKey); + assertEquals(unionMessage.getString("$group_id"), groupID); + JSONArray unionValues = unionMessage.getJSONObject("$union").getJSONArray("UNION NAME"); + assertEquals(1, unionValues.length()); + assertEquals(100, unionValues.getInt(0)); + + JSONObject unsetMessage = messages.get(5).getMessage(); + assertEquals(unsetMessage.getString("$group_key"), groupKey); + assertEquals(unsetMessage.getString("$group_id"), groupID); + JSONArray unsetValues = unsetMessage.getJSONArray("$unset"); + assertEquals(1, unsetValues.length()); + assertEquals("UNSET NAME", unsetValues.get(0)); + + JSONObject deleteMessage = messages.get(6).getMessage(); + assertEquals(deleteMessage.getString("$group_key"), groupKey); + assertEquals(deleteMessage.getString("$group_id"), groupID); + assertTrue(deleteMessage.has("$delete")); + } + + @Test + public void testIdentifyAfterSet() throws InterruptedException, JSONException { + String token = "TEST TOKEN testIdentifyAfterSet"; + final List messages = + new ArrayList(); + final BlockingQueue anonymousUpdates = new LinkedBlockingQueue(); + final BlockingQueue peopleUpdates = new LinkedBlockingQueue(); + + final MPDbAdapter mockAdapter = + new MPDbAdapter( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public int addJSON(JSONObject j, String token, Table table) { + if (table == Table.ANONYMOUS_PEOPLE) { + anonymousUpdates.add(j); + } else if (table == Table.PEOPLE) { + peopleUpdates.add(j); + } + return super.addJSON(j, token, table); + } + }; + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public void peopleMessage(PeopleDescription heard) { + messages.add(heard); + super.peopleMessage(heard); + } + + @Override + public void pushAnonymousPeopleMessage( + PushAnonymousPeopleDescription pushAnonymousPeopleDescription) { + messages.add(pushAnonymousPeopleDescription); + super.pushAnonymousPeopleMessage(pushAnonymousPeopleDescription); + } + + @Override + protected MPDbAdapter makeDbAdapter(Context context) { + return mockAdapter; + } }; - MixpanelAPI mixpanel = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "TEST TOKEN testGroupOperations") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } + MixpanelAPI mixpanel = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, token) { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } }; - Map mapObj1 = new HashMap<>(); - mapObj1.put("SET MAP INT", 1); - Map mapObj2 = new HashMap<>(); - mapObj2.put("SET ONCE MAP STR", "SET ONCE MAP VALUE"); - - String groupKey = "group key"; - String groupID = "group id"; - - mixpanel.getGroup(groupKey, groupID).set("SET NAME", "SET VALUE"); - mixpanel.getGroup(groupKey, groupID).setMap(mapObj1); - mixpanel.getGroup(groupKey, groupID).setOnce("SET ONCE NAME", "SET ONCE VALUE"); - mixpanel.getGroup(groupKey, groupID).setOnceMap(mapObj2); - mixpanel.getGroup(groupKey, groupID).union("UNION NAME", new JSONArray("[100]")); - mixpanel.getGroup(groupKey, groupID).unset("UNSET NAME"); - mixpanel.getGroup(groupKey, groupID).deleteGroup(); - - JSONObject setMessage = messages.get(0).getMessage(); - assertEquals(setMessage.getString("$group_key"), groupKey); - assertEquals(setMessage.getString("$group_id"), groupID); - assertEquals("SET VALUE", - setMessage.getJSONObject("$set").getString("SET NAME")); - - JSONObject setMapMessage = messages.get(1).getMessage(); - assertEquals(setMapMessage.getString("$group_key"), groupKey); - assertEquals(setMapMessage.getString("$group_id"), groupID); - assertEquals(mapObj1.get("SET MAP INT"), - setMapMessage.getJSONObject("$set").getInt("SET MAP INT")); - - JSONObject setOnceMessage = messages.get(2).getMessage(); - assertEquals(setOnceMessage.getString("$group_key"), groupKey); - assertEquals(setOnceMessage.getString("$group_id"), groupID); - assertEquals("SET ONCE VALUE", - setOnceMessage.getJSONObject("$set_once").getString("SET ONCE NAME")); - - JSONObject setOnceMapMessage = messages.get(3).getMessage(); - assertEquals(setOnceMapMessage.getString("$group_key"), groupKey); - assertEquals(setOnceMapMessage.getString("$group_id"), groupID); - assertEquals(mapObj2.get("SET ONCE MAP STR"), - setOnceMapMessage.getJSONObject("$set_once").getString("SET ONCE MAP STR")); - - JSONObject unionMessage = messages.get(4).getMessage(); - assertEquals(unionMessage.getString("$group_key"), groupKey); - assertEquals(unionMessage.getString("$group_id"), groupID); - JSONArray unionValues = unionMessage.getJSONObject("$union").getJSONArray("UNION NAME"); - assertEquals(1, unionValues.length()); - assertEquals(100, unionValues.getInt(0)); - - JSONObject unsetMessage = messages.get(5).getMessage(); - assertEquals(unsetMessage.getString("$group_key"), groupKey); - assertEquals(unsetMessage.getString("$group_id"), groupID); - JSONArray unsetValues = unsetMessage.getJSONArray("$unset"); - assertEquals(1, unsetValues.length()); - assertEquals("UNSET NAME", unsetValues.get(0)); - - JSONObject deleteMessage = messages.get(6).getMessage(); - assertEquals(deleteMessage.getString("$group_key"), groupKey); - assertEquals(deleteMessage.getString("$group_id"), groupID); - assertTrue(deleteMessage.has("$delete")); + MixpanelAPI.People people = mixpanel.getPeople(); + people.increment("the prop", 0L); + people.append("the prop", 1); + people.set("the prop", 2); + people.increment("the prop", 3L); + people.append("the prop", 5); + + assertEquals( + 0L, + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$add") + .getLong("the prop")); + assertEquals( + 1, + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$append") + .get("the prop")); + assertEquals( + 2, + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$set") + .get("the prop")); + assertEquals( + 3L, + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$add") + .getLong("the prop")); + assertEquals( + 5, + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$append") + .get("the prop")); + assertNull(anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); + assertNull(peopleUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); + + String deviceId = mixpanel.getAnonymousId(); + mixpanel.identify("Personal Identity"); + people.set("the prop identified", "prop value identified"); + + assertEquals( + "prop value identified", + peopleUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$set") + .getString("the prop identified")); + assertNull(peopleUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); + assertNull(anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); + + String[] storedAnonymous = + mockAdapter.generateDataString(MPDbAdapter.Table.ANONYMOUS_PEOPLE, token); + assertNull(storedAnonymous); + + String[] storedPeople = mockAdapter.generateDataString(MPDbAdapter.Table.PEOPLE, token); + assertEquals(6, Integer.valueOf(storedPeople[2]).intValue()); + JSONArray data = new JSONArray(storedPeople[1]); + for (int i = 0; i < data.length(); i++) { + JSONObject j = data.getJSONObject(i); + assertEquals("Personal Identity", j.getString("$distinct_id")); + assertEquals(deviceId, j.getString("$device_id")); } - - @Test - public void testIdentifyAfterSet() throws InterruptedException, JSONException { - String token = "TEST TOKEN testIdentifyAfterSet"; - final List messages = new ArrayList(); - final BlockingQueue anonymousUpdates = new LinkedBlockingQueue(); - final BlockingQueue peopleUpdates = new LinkedBlockingQueue(); - - final MPDbAdapter mockAdapter = new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public int addJSON(JSONObject j, String token, Table table) { - if (table == Table.ANONYMOUS_PEOPLE) { - anonymousUpdates.add(j); - } else if (table == Table.PEOPLE) { - peopleUpdates.add(j); - } - return super.addJSON(j, token, table); - } + } + + @Test + public void testIdentifyAfterSetToAnonymousId() throws InterruptedException, JSONException { + String token = "TEST TOKEN testIdentifyAfterSet"; + final List messages = + new ArrayList(); + final BlockingQueue anonymousUpdates = new LinkedBlockingQueue(); + final BlockingQueue peopleUpdates = new LinkedBlockingQueue(); + + final MPDbAdapter mockAdapter = + new MPDbAdapter( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public int addJSON(JSONObject j, String token, Table table) { + if (table == Table.ANONYMOUS_PEOPLE) { + anonymousUpdates.add(j); + } else if (table == Table.PEOPLE) { + peopleUpdates.add(j); + } + return super.addJSON(j, token, table); + } }; - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public void peopleMessage(PeopleDescription heard) { - messages.add(heard); - super.peopleMessage(heard); - } - - @Override - public void pushAnonymousPeopleMessage(PushAnonymousPeopleDescription pushAnonymousPeopleDescription) { - messages.add(pushAnonymousPeopleDescription); - super.pushAnonymousPeopleMessage(pushAnonymousPeopleDescription); - } - - @Override - protected MPDbAdapter makeDbAdapter(Context context) { - return mockAdapter; - } + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public void peopleMessage(PeopleDescription heard) { + messages.add(heard); + super.peopleMessage(heard); + } + + @Override + public void pushAnonymousPeopleMessage( + PushAnonymousPeopleDescription pushAnonymousPeopleDescription) { + messages.add(pushAnonymousPeopleDescription); + super.pushAnonymousPeopleMessage(pushAnonymousPeopleDescription); + } + + @Override + protected MPDbAdapter makeDbAdapter(Context context) { + return mockAdapter; + } }; - MixpanelAPI mixpanel = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, token) { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } + MixpanelAPI mixpanel = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, token) { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } }; - MixpanelAPI.People people = mixpanel.getPeople(); - people.increment("the prop", 0L); - people.append("the prop", 1); - people.set("the prop", 2); - people.increment("the prop", 3L); - people.append("the prop", 5); - - assertEquals(0L, anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$add").getLong("the prop")); - assertEquals(1, anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$append").get("the prop")); - assertEquals(2, anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$set").get("the prop")); - assertEquals(3L, anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$add").getLong("the prop")); - assertEquals(5, anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$append").get("the prop")); - assertNull(anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); - assertNull(peopleUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); - - String deviceId = mixpanel.getAnonymousId(); - mixpanel.identify("Personal Identity"); - people.set("the prop identified", "prop value identified"); - - assertEquals("prop value identified", peopleUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$set").getString("the prop identified")); - assertNull(peopleUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); - assertNull(anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); - - String[] storedAnonymous = mockAdapter.generateDataString(MPDbAdapter.Table.ANONYMOUS_PEOPLE, token); - assertNull(storedAnonymous); - - String[] storedPeople = mockAdapter.generateDataString(MPDbAdapter.Table.PEOPLE, token); - assertEquals(6, Integer.valueOf(storedPeople[2]).intValue()); - JSONArray data = new JSONArray(storedPeople[1]); - for (int i=0; i < data.length(); i++) { - JSONObject j = data.getJSONObject(i); - assertEquals("Personal Identity", j.getString("$distinct_id")); - assertEquals(deviceId, j.getString("$device_id")); - } + MixpanelAPI.People people = mixpanel.getPeople(); + people.increment("the prop", 0L); + people.append("the prop", 1); + people.set("the prop", 2); + people.increment("the prop", 3L); + people.append("the prop", 5); + + assertEquals( + 0L, + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$add") + .getLong("the prop")); + assertEquals( + 1, + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$append") + .get("the prop")); + assertEquals( + 2, + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$set") + .get("the prop")); + assertEquals( + 3L, + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$add") + .getLong("the prop")); + assertEquals( + 5, + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$append") + .get("the prop")); + assertNull(anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); + assertNull(peopleUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); + + String deviceId = mixpanel.getAnonymousId(); + mixpanel.identify(mixpanel.getDistinctId()); + people.set("the prop identified", "prop value identified"); + assertNull(mixpanel.getUserId()); + + assertEquals( + "prop value identified", + peopleUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$set") + .getString("the prop identified")); + assertNull(peopleUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); + assertNull(anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); + + String[] storedAnonymous = + mockAdapter.generateDataString(MPDbAdapter.Table.ANONYMOUS_PEOPLE, token); + assertNull(storedAnonymous); + + String[] storedPeople = mockAdapter.generateDataString(MPDbAdapter.Table.PEOPLE, token); + assertEquals(6, Integer.valueOf(storedPeople[2]).intValue()); + JSONArray data = new JSONArray(storedPeople[1]); + for (int i = 0; i < data.length(); i++) { + JSONObject j = data.getJSONObject(i); + assertEquals("$device:" + deviceId, j.getString("$distinct_id")); + assertEquals(deviceId, j.getString("$device_id")); } - - @Test - public void testIdentifyAfterSetToAnonymousId() throws InterruptedException, JSONException { - String token = "TEST TOKEN testIdentifyAfterSet"; - final List messages = new ArrayList(); - final BlockingQueue anonymousUpdates = new LinkedBlockingQueue(); - final BlockingQueue peopleUpdates = new LinkedBlockingQueue(); - - final MPDbAdapter mockAdapter = new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public int addJSON(JSONObject j, String token, Table table) { - if (table == Table.ANONYMOUS_PEOPLE) { - anonymousUpdates.add(j); - } else if (table == Table.PEOPLE) { - peopleUpdates.add(j); - } - return super.addJSON(j, token, table); - } + } + + @Test + public void testIdentifyAndGetDistinctId() { + MixpanelAPI metrics = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "Identify Test Token"); + + String generatedId = metrics.getDistinctId(); + assertThat(generatedId, startsWith("$device:")); + assertEquals(generatedId, "$device:" + metrics.getAnonymousId()); + + assertNull(metrics.getUserId()); + assertNull(metrics.getPeople().getDistinctId()); + + metrics.identify("Events Id"); + assertEquals("Events Id", metrics.getDistinctId()); + assertEquals("Events Id", metrics.getUserId()); + assertEquals("Events Id", metrics.getPeople().getDistinctId()); + } + + @Test + public void testIdentifyToCurrentAnonymousDistinctId() { + MixpanelAPI metrics = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "Identify Test Token"); + + String generatedId = metrics.getDistinctId(); + assertThat(generatedId, startsWith("$device:")); + assertEquals(generatedId, "$device:" + metrics.getAnonymousId()); + + assertNull(metrics.getUserId()); + assertNull(metrics.getPeople().getDistinctId()); + + metrics.identify(metrics.getDistinctId()); + assertEquals(generatedId, metrics.getDistinctId()); + assertNull(metrics.getUserId()); + assertEquals(generatedId, metrics.getPeople().getDistinctId()); + } + + @Test + public void testIdentifyAndCheckUserIDAndDeviceID() { + MixpanelAPI metrics = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "Identify Test Token"); + + String generatedId = metrics.getAnonymousId(); + assertNotNull(metrics.getAnonymousId()); + String eventsDistinctId = metrics.getDistinctId(); + assertEquals("$device:" + generatedId, eventsDistinctId); + assertNull(metrics.getUserId()); + assertNull(metrics.getPeople().getDistinctId()); + + metrics.identify("Distinct Id"); + assertEquals("Distinct Id", metrics.getDistinctId()); + assertEquals(generatedId, metrics.getAnonymousId()); + assertEquals("Distinct Id", metrics.getPeople().getDistinctId()); + + // once its reset we will only have generated id but user id should be null + metrics.reset(); + String generatedId2 = metrics.getAnonymousId(); + assertNotNull(generatedId2); + assertNotSame(generatedId, generatedId2); + assertEquals("$device:" + generatedId2, metrics.getDistinctId()); + assertNull(metrics.getUserId()); + } + + @Test + public void testMessageQueuing() { + final BlockingQueue messages = new LinkedBlockingQueue(); + final SynchronizedReference isIdentifiedRef = new SynchronizedReference(); + isIdentifiedRef.set(false); + + final MPDbAdapter mockAdapter = + new MPDbAdapter( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public int addJSON(JSONObject message, String token, MPDbAdapter.Table table) { + try { + messages.put("TABLE " + table.getName()); + messages.put(message.toString()); + } catch (Exception e) { + throw new RuntimeException(e); + } + + return super.addJSON(message, token, table); + } }; - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public void peopleMessage(PeopleDescription heard) { - messages.add(heard); - super.peopleMessage(heard); - } - - @Override - public void pushAnonymousPeopleMessage(PushAnonymousPeopleDescription pushAnonymousPeopleDescription) { - messages.add(pushAnonymousPeopleDescription); - super.pushAnonymousPeopleMessage(pushAnonymousPeopleDescription); - } - - @Override - protected MPDbAdapter makeDbAdapter(Context context) { - return mockAdapter; - } + mockAdapter.cleanupEvents(Long.MAX_VALUE, MPDbAdapter.Table.EVENTS); + mockAdapter.cleanupEvents(Long.MAX_VALUE, MPDbAdapter.Table.PEOPLE); + + final RemoteService mockPoster = + new HttpService() { + @Override + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) { + final boolean isIdentified = isIdentifiedRef.get(); + assertTrue(params.containsKey("data")); + final String decoded = Base64Coder.decodeString(params.get("data").toString()); + + try { + messages.put("SENT FLUSH " + endpointUrl); + messages.put(decoded); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + return TestUtils.bytes("1\n"); + } }; - MixpanelAPI mixpanel = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, token) { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } + final MPConfig mockConfig = + new MPConfig( + new Bundle(), InstrumentationRegistry.getInstrumentation().getContext(), null) { + @Override + public int getFlushInterval() { + return -1; + } + + @Override + public int getBulkUploadLimit() { + return 40; + } + + @Override + public String getEventsEndpoint() { + return "EVENTS_ENDPOINT"; + } + + @Override + public String getPeopleEndpoint() { + return "PEOPLE_ENDPOINT"; + } + + @Override + public String getGroupsEndpoint() { + return "GROUPS_ENDPOINT"; + } + + @Override + public boolean getDisableAppOpenEvent() { + return true; + } }; - MixpanelAPI.People people = mixpanel.getPeople(); - people.increment("the prop", 0L); - people.append("the prop", 1); - people.set("the prop", 2); - people.increment("the prop", 3L); - people.append("the prop", 5); - - assertEquals(0L, anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$add").getLong("the prop")); - assertEquals(1, anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$append").get("the prop")); - assertEquals(2, anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$set").get("the prop")); - assertEquals(3L, anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$add").getLong("the prop")); - assertEquals(5, anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$append").get("the prop")); - assertNull(anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); - assertNull(peopleUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); - - String deviceId = mixpanel.getAnonymousId(); - mixpanel.identify(mixpanel.getDistinctId()); - people.set("the prop identified", "prop value identified"); - assertNull(mixpanel.getUserId()); - - assertEquals("prop value identified", peopleUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$set").getString("the prop identified")); - assertNull(peopleUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); - assertNull(anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); - - String[] storedAnonymous = mockAdapter.generateDataString(MPDbAdapter.Table.ANONYMOUS_PEOPLE, token); - assertNull(storedAnonymous); - - String[] storedPeople = mockAdapter.generateDataString(MPDbAdapter.Table.PEOPLE, token); - assertEquals(6, Integer.valueOf(storedPeople[2]).intValue()); - JSONArray data = new JSONArray(storedPeople[1]); - for (int i=0; i < data.length(); i++) { - JSONObject j = data.getJSONObject(i); - assertEquals("$device:" + deviceId, j.getString("$distinct_id")); - assertEquals(deviceId, j.getString("$device_id")); - } - } - - @Test - public void testIdentifyAndGetDistinctId() { - MixpanelAPI metrics = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "Identify Test Token"); + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), mockConfig) { + @Override + protected MPDbAdapter makeDbAdapter(Context context) { + return mockAdapter; + } + + @Override + protected RemoteService getPoster() { + return mockPoster; + } + }; - String generatedId = metrics.getDistinctId(); - assertThat(generatedId, startsWith("$device:")); - assertEquals(generatedId, "$device:" + metrics.getAnonymousId()); + MixpanelAPI metrics = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "Test Message Queuing") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } + }; - assertNull(metrics.getUserId()); - assertNull(metrics.getPeople().getDistinctId()); + metrics.identify("EVENTS ID"); - metrics.identify("Events Id"); - assertEquals("Events Id", metrics.getDistinctId()); - assertEquals("Events Id", metrics.getUserId()); - assertEquals("Events Id", metrics.getPeople().getDistinctId()); + // Test filling up the message queue + for (int i = 0; i < mockConfig.getBulkUploadLimit() - 2; i++) { + metrics.track("frequent event", null); } - @Test - public void testIdentifyToCurrentAnonymousDistinctId() { - MixpanelAPI metrics = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "Identify Test Token"); + metrics.track("final event", null); + String expectedJSONMessage = ""; - String generatedId = metrics.getDistinctId(); - assertThat(generatedId, startsWith("$device:")); - assertEquals(generatedId, "$device:" + metrics.getAnonymousId()); + try { + String messageTable = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("TABLE " + MPDbAdapter.Table.EVENTS.getName(), messageTable); - assertNull(metrics.getUserId()); - assertNull(metrics.getPeople().getDistinctId()); + expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + JSONObject message = new JSONObject(expectedJSONMessage); + assertEquals("$identify", message.getString("event")); - metrics.identify(metrics.getDistinctId()); - assertEquals(generatedId, metrics.getDistinctId()); - assertNull(metrics.getUserId()); - assertEquals(generatedId, metrics.getPeople().getDistinctId()); - } + for (int i = 0; i < mockConfig.getBulkUploadLimit() - 2; i++) { + messageTable = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("TABLE " + MPDbAdapter.Table.EVENTS.getName(), messageTable); - @Test - public void testIdentifyAndCheckUserIDAndDeviceID() { - MixpanelAPI metrics = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "Identify Test Token"); - - String generatedId = metrics.getAnonymousId(); - assertNotNull(metrics.getAnonymousId()); - String eventsDistinctId = metrics.getDistinctId(); - assertEquals("$device:" + generatedId, eventsDistinctId); - assertNull(metrics.getUserId()); - assertNull(metrics.getPeople().getDistinctId()); - - metrics.identify("Distinct Id"); - assertEquals("Distinct Id", metrics.getDistinctId()); - assertEquals(generatedId, metrics.getAnonymousId()); - assertEquals("Distinct Id", metrics.getPeople().getDistinctId()); - - // once its reset we will only have generated id but user id should be null - metrics.reset(); - String generatedId2 = metrics.getAnonymousId(); - assertNotNull(generatedId2); - assertNotSame(generatedId, generatedId2); - assertEquals("$device:" + generatedId2, metrics.getDistinctId()); - assertNull(metrics.getUserId()); - } + expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + message = new JSONObject(expectedJSONMessage); + assertEquals("frequent event", message.getString("event")); + } - @Test - public void testMessageQueuing() { - final BlockingQueue messages = new LinkedBlockingQueue(); - final SynchronizedReference isIdentifiedRef = new SynchronizedReference(); - isIdentifiedRef.set(false); - - final MPDbAdapter mockAdapter = new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public int addJSON(JSONObject message, String token, MPDbAdapter.Table table) { - try { - messages.put("TABLE " + table.getName()); - messages.put(message.toString()); - } catch (Exception e) { - throw new RuntimeException(e); - } + messageTable = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("TABLE " + MPDbAdapter.Table.EVENTS.getName(), messageTable); - return super.addJSON(message, token, table); - } - }; - mockAdapter.cleanupEvents(Long.MAX_VALUE, MPDbAdapter.Table.EVENTS); - mockAdapter.cleanupEvents(Long.MAX_VALUE, MPDbAdapter.Table.PEOPLE); - - final RemoteService mockPoster = new HttpService() { - @Override - public byte[] performRequest( - @NonNull String endpointUrl, - @Nullable ProxyServerInteractor interactor, - @Nullable Map params, // Used only if requestBodyBytes is null - @Nullable Map headers, - @Nullable byte[] requestBodyBytes, // If provided, send this as raw body - @Nullable SSLSocketFactory socketFactory) - { - final boolean isIdentified = isIdentifiedRef.get(); - assertTrue(params.containsKey("data")); - final String decoded = Base64Coder.decodeString(params.get("data").toString()); - - try { - messages.put("SENT FLUSH " + endpointUrl); - messages.put(decoded); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - - return TestUtils.bytes("1\n"); - } - }; + expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + message = new JSONObject(expectedJSONMessage); + assertEquals("final event", message.getString("event")); + String messageFlush = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("SENT FLUSH EVENTS_ENDPOINT", messageFlush); - final MPConfig mockConfig = new MPConfig(new Bundle(), InstrumentationRegistry.getInstrumentation().getContext(), null) { - @Override - public int getFlushInterval() { - return -1; - } + expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + JSONArray bigFlush = new JSONArray(expectedJSONMessage); + assertEquals(mockConfig.getBulkUploadLimit(), bigFlush.length()); - @Override - public int getBulkUploadLimit() { - return 40; - } + metrics.track("next wave", null); + metrics.flush(); - @Override - public String getEventsEndpoint() { - return "EVENTS_ENDPOINT"; - } + String nextWaveTable = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("TABLE " + MPDbAdapter.Table.EVENTS.getName(), nextWaveTable); - @Override - public String getPeopleEndpoint() { - return "PEOPLE_ENDPOINT"; - } + expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + JSONObject nextWaveMessage = new JSONObject(expectedJSONMessage); + assertEquals("next wave", nextWaveMessage.getString("event")); - @Override - public String getGroupsEndpoint() { - return "GROUPS_ENDPOINT"; - } + String manualFlush = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("SENT FLUSH EVENTS_ENDPOINT", manualFlush); - @Override - public boolean getDisableAppOpenEvent() { return true; } - }; + expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + JSONArray nextWave = new JSONArray(expectedJSONMessage); + assertEquals(1, nextWave.length()); - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), mockConfig) { - @Override - protected MPDbAdapter makeDbAdapter(Context context) { - return mockAdapter; - } + JSONObject nextWaveEvent = nextWave.getJSONObject(0); + assertEquals("next wave", nextWaveEvent.getString("event")); + isIdentifiedRef.set(true); + metrics.identify("PEOPLE ID"); + metrics.getPeople().set("prop", "yup"); + metrics.flush(); - @Override - protected RemoteService getPoster() { - return mockPoster; - } - }; + String peopleTable = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("TABLE " + MPDbAdapter.Table.EVENTS.getName(), peopleTable); + messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + JSONObject peopleMessage = new JSONObject(expectedJSONMessage); - MixpanelAPI metrics = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "Test Message Queuing") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } - }; + assertEquals("PEOPLE ID", peopleMessage.getString("$distinct_id")); + assertEquals("yup", peopleMessage.getJSONObject("$set").getString("prop")); - metrics.identify("EVENTS ID"); + messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + String peopleFlush = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("SENT FLUSH PEOPLE_ENDPOINT", peopleFlush); - // Test filling up the message queue - for (int i=0; i < mockConfig.getBulkUploadLimit() - 2; i++) { - metrics.track("frequent event", null); - } + expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + JSONArray peopleSent = new JSONArray(expectedJSONMessage); + assertEquals(1, peopleSent.length()); - metrics.track("final event", null); - String expectedJSONMessage = ""; + metrics.getGroup("testKey", "testID").set("prop", "yup"); + metrics.flush(); - try { - String messageTable = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("TABLE " + MPDbAdapter.Table.EVENTS.getName(), messageTable); + String groupsTable = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("TABLE " + MPDbAdapter.Table.GROUPS.getName(), groupsTable); - expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - JSONObject message = new JSONObject(expectedJSONMessage); - assertEquals("$identify", message.getString("event")); + expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + JSONObject groupsMessage = new JSONObject(expectedJSONMessage); - for (int i=0; i < mockConfig.getBulkUploadLimit() - 2; i++) { - messageTable = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("TABLE " + MPDbAdapter.Table.EVENTS.getName(), messageTable); + assertEquals("testKey", groupsMessage.getString("$group_key")); + assertEquals("testID", groupsMessage.getString("$group_id")); + assertEquals("yup", groupsMessage.getJSONObject("$set").getString("prop")); - expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - message = new JSONObject(expectedJSONMessage); - assertEquals("frequent event", message.getString("event")); - } + String groupsFlush = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertEquals("SENT FLUSH GROUPS_ENDPOINT", groupsFlush); - messageTable = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("TABLE " + MPDbAdapter.Table.EVENTS.getName(), messageTable); + expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + JSONArray groupsSent = new JSONArray(expectedJSONMessage); + assertEquals(1, groupsSent.length()); + } catch (InterruptedException e) { + fail("Expected a log message about mixpanel communication but did not receive it."); + } catch (JSONException e) { + fail( + "Expected a JSON object message and got something silly instead: " + expectedJSONMessage); + } + } + + @Test + public void testTrackCharge() { + final List messages = new ArrayList<>(); + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public void eventsMessage(EventDescription heard) { + if (!heard.isAutomatic()) { + throw new RuntimeException("Should not be called during this test"); + } + } + + @Override + public void peopleMessage(PeopleDescription heard) { + messages.add(heard); + } + }; - expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - message = new JSONObject(expectedJSONMessage); - assertEquals("final event", message.getString("event")); + class ListeningAPI extends TestUtils.CleanMixpanelAPI { + public ListeningAPI(Context c, Future referrerPrefs, String token) { + super(c, referrerPrefs, token); + } - String messageFlush = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("SENT FLUSH EVENTS_ENDPOINT", messageFlush); + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } + } - expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - JSONArray bigFlush = new JSONArray(expectedJSONMessage); - assertEquals(mockConfig.getBulkUploadLimit(), bigFlush.length()); + MixpanelAPI api = + new ListeningAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "TRACKCHARGE TEST TOKEN"); + api.getPeople().identify("TRACKCHARGE PERSON"); + + JSONObject props; + try { + props = new JSONObject("{'$time':'Should override', 'Orange':'Banana'}"); + } catch (JSONException e) { + throw new RuntimeException("Can't construct fixture for trackCharge test"); + } - metrics.track("next wave", null); - metrics.flush(); + api.getPeople().trackCharge(2.13, props); + assertEquals(messages.size(), 1); - String nextWaveTable = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("TABLE " + MPDbAdapter.Table.EVENTS.getName(), nextWaveTable); + JSONObject message = messages.get(0).getMessage(); - expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - JSONObject nextWaveMessage = new JSONObject(expectedJSONMessage); - assertEquals("next wave", nextWaveMessage.getString("event")); + try { + JSONObject append = message.getJSONObject("$append"); + JSONObject newTransaction = append.getJSONObject("$transactions"); + assertEquals(newTransaction.optString("Orange"), "Banana"); + assertEquals(newTransaction.optString("$time"), "Should override"); + assertEquals(newTransaction.optDouble("$amount"), 2.13, 0); + } catch (JSONException e) { + fail("Transaction message had unexpected layout:\n" + message.toString()); + } + } + + @Test + public void testTrackWithSavedDistinctId() { + final String savedDistinctID = "saved_distinct_id"; + final List messages = new ArrayList(); + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public void eventsMessage(EventDescription heard) { + if (!heard.isAutomatic() && !heard.getEventName().equals("$identify")) { + messages.add(heard); + } + } + + @Override + public void peopleMessage(PeopleDescription heard) { + messages.add(heard); + } + }; - String manualFlush = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("SENT FLUSH EVENTS_ENDPOINT", manualFlush); + class TestMixpanelAPI extends MixpanelAPI { + public TestMixpanelAPI(Context c, Future prefs, String token) { + super(c, prefs, token, false, null, true); + } + + @Override + /* package */ PersistentIdentity getPersistentIdentity( + final Context context, + final Future referrerPreferences, + final String token, + final String instanceName) { + String instanceKey = instanceName != null ? instanceName : token; + final String mixpanelPrefsName = "com.mixpanel.android.mpmetrics.Mixpanel"; + final SharedPreferences mpSharedPrefs = + context.getSharedPreferences(mixpanelPrefsName, Context.MODE_PRIVATE); + mpSharedPrefs + .edit() + .clear() + .putBoolean(token, true) + .putBoolean("has_launched", true) + .commit(); + final String prefsName = "com.mixpanel.android.mpmetrics.MixpanelAPI_" + instanceKey; + final SharedPreferences loadstorePrefs = + context.getSharedPreferences(prefsName, Context.MODE_PRIVATE); + loadstorePrefs + .edit() + .clear() + .putString("events_distinct_id", savedDistinctID) + .putString("people_distinct_id", savedDistinctID) + .commit(); + return super.getPersistentIdentity(context, referrerPreferences, token, instanceName); + } + + @Override + /* package */ boolean sendAppOpen() { + return false; + } + + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } + } - expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - JSONArray nextWave = new JSONArray(expectedJSONMessage); - assertEquals(1, nextWave.length()); + TestMixpanelAPI mpMetrics = + new TestMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "SAME TOKEN"); + assertEquals(mpMetrics.getDistinctId(), savedDistinctID); + mpMetrics.identify("new_user"); + + mpMetrics.track("eventname", null); + mpMetrics.getPeople().set("people prop name", "Indeed"); + + assertEquals(2, messages.size()); + + AnalyticsMessages.EventDescription eventMessage = + (AnalyticsMessages.EventDescription) messages.get(0); + JSONObject peopleMessage = ((AnalyticsMessages.PeopleDescription) messages.get(1)).getMessage(); + + try { + JSONObject eventProps = eventMessage.getProperties(); + String deviceId = eventProps.getString("$device_id"); + assertEquals(savedDistinctID, deviceId); + boolean hadPersistedDistinctId = eventProps.getBoolean("$had_persisted_distinct_id"); + assertEquals(true, hadPersistedDistinctId); + } catch (JSONException e) { + fail("Event message has an unexpected shape " + e); + } - JSONObject nextWaveEvent = nextWave.getJSONObject(0); - assertEquals("next wave", nextWaveEvent.getString("event")); + try { + String deviceId = peopleMessage.getString("$device_id"); + boolean hadPersistedDistinctId = peopleMessage.getBoolean("$had_persisted_distinct_id"); + assertEquals(savedDistinctID, deviceId); + assertEquals(true, hadPersistedDistinctId); + } catch (JSONException e) { + fail("Event message has an unexpected shape " + e); + } + messages.clear(); + } + + @Test + public void testSetAddRemoveGroup() { + final List messages = new ArrayList(); + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public void eventsMessage(EventDescription heard) { + if (!heard.isAutomatic() + && !heard.getEventName().equals("$identify") + && !heard.getEventName().equals("Integration")) { + messages.add(heard); + } + } + + @Override + public void peopleMessage(PeopleDescription heard) { + messages.add(heard); + } + }; - isIdentifiedRef.set(true); - metrics.identify("PEOPLE ID"); - metrics.getPeople().set("prop", "yup"); - metrics.flush(); + class TestMixpanelAPI extends MixpanelAPI { + public TestMixpanelAPI(Context c, Future prefs, String token) { + super(c, prefs, token, false, null, true); + } - String peopleTable = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("TABLE " + MPDbAdapter.Table.EVENTS.getName(), peopleTable); - messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - JSONObject peopleMessage = new JSONObject(expectedJSONMessage); + @Override + /* package */ boolean sendAppOpen() { + return false; + } - assertEquals("PEOPLE ID", peopleMessage.getString("$distinct_id")); - assertEquals("yup", peopleMessage.getJSONObject("$set").getString("prop")); + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } + } - messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - String peopleFlush = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("SENT FLUSH PEOPLE_ENDPOINT", peopleFlush); + TestMixpanelAPI mpMetrics = + new TestMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "SAME TOKEN"); + mpMetrics.identify("new_user"); + + int groupID = 42; + mpMetrics.setGroup("group_key", groupID); + mpMetrics.track("eventname", null); + + assertEquals(2, messages.size()); + + JSONObject peopleMessage = ((AnalyticsMessages.PeopleDescription) messages.get(0)).getMessage(); + AnalyticsMessages.EventDescription eventMessage = + (AnalyticsMessages.EventDescription) messages.get(1); + + try { + JSONObject eventProps = eventMessage.getProperties(); + JSONArray groupIDs = eventProps.getJSONArray("group_key"); + assertEquals((new JSONArray()).put(groupID), groupIDs); + } catch (JSONException e) { + fail("Event message has an unexpected shape " + e); + } - expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - JSONArray peopleSent = new JSONArray(expectedJSONMessage); - assertEquals(1, peopleSent.length()); + try { + JSONObject setMessage = peopleMessage.getJSONObject("$set"); + assertEquals((new JSONArray()).put(groupID), setMessage.getJSONArray("group_key")); + } catch (JSONException e) { + fail("People message has an unexpected shape " + e); + } - metrics.getGroup("testKey", "testID").set("prop", "yup"); - metrics.flush(); + messages.clear(); + + int groupID2 = 77; + mpMetrics.addGroup("group_key", groupID2); + mpMetrics.track("eventname", null); + JSONArray expectedGroupIDs = new JSONArray(); + expectedGroupIDs.put(groupID); + expectedGroupIDs.put(groupID2); + + assertEquals(2, messages.size()); + peopleMessage = ((AnalyticsMessages.PeopleDescription) messages.get(0)).getMessage(); + eventMessage = (AnalyticsMessages.EventDescription) messages.get(1); + + try { + JSONObject eventProps = eventMessage.getProperties(); + JSONArray groupIDs = eventProps.getJSONArray("group_key"); + assertEquals(expectedGroupIDs, groupIDs); + } catch (JSONException e) { + fail("Event message has an unexpected shape " + e); + } - String groupsTable = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("TABLE " + MPDbAdapter.Table.GROUPS.getName(), groupsTable); + try { + JSONObject unionMessage = peopleMessage.getJSONObject("$union"); + assertEquals((new JSONArray()).put(groupID2), unionMessage.getJSONArray("group_key")); + } catch (JSONException e) { + fail("People message has an unexpected shape " + e); + } - expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - JSONObject groupsMessage = new JSONObject(expectedJSONMessage); + messages.clear(); + mpMetrics.removeGroup("group_key", groupID2); + mpMetrics.track("eventname", null); - assertEquals("testKey", groupsMessage.getString("$group_key")); - assertEquals("testID", groupsMessage.getString("$group_id")); - assertEquals("yup", groupsMessage.getJSONObject("$set").getString("prop")); + assertEquals(2, messages.size()); + peopleMessage = ((AnalyticsMessages.PeopleDescription) messages.get(0)).getMessage(); + eventMessage = (AnalyticsMessages.EventDescription) messages.get(1); - String groupsFlush = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertEquals("SENT FLUSH GROUPS_ENDPOINT", groupsFlush); + try { + JSONObject eventProps = eventMessage.getProperties(); + JSONArray groupIDs = eventProps.getJSONArray("group_key"); + assertEquals((new JSONArray()).put(groupID), groupIDs); + } catch (JSONException e) { + fail("Event message has an unexpected shape " + e); + } - expectedJSONMessage = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - JSONArray groupsSent = new JSONArray(expectedJSONMessage); - assertEquals(1, groupsSent.length()); - } catch (InterruptedException e) { - fail("Expected a log message about mixpanel communication but did not receive it."); - } catch (JSONException e) { - fail("Expected a JSON object message and got something silly instead: " + expectedJSONMessage); - } + try { + JSONObject removeMessage = peopleMessage.getJSONObject("$remove"); + assertEquals(groupID2, removeMessage.getInt("group_key")); + } catch (JSONException e) { + fail("People message has an unexpected shape " + e); } - @Test - public void testTrackCharge() { - final List messages = new ArrayList<>(); - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public void eventsMessage(EventDescription heard) { - if (!heard.isAutomatic()) { - throw new RuntimeException("Should not be called during this test"); - } - } + messages.clear(); + mpMetrics.removeGroup("group_key", groupID); + mpMetrics.track("eventname", null); - @Override - public void peopleMessage(PeopleDescription heard) { - messages.add(heard); - } - }; + assertEquals(2, messages.size()); + peopleMessage = ((AnalyticsMessages.PeopleDescription) messages.get(0)).getMessage(); + eventMessage = (AnalyticsMessages.EventDescription) messages.get(1); - class ListeningAPI extends TestUtils.CleanMixpanelAPI { - public ListeningAPI(Context c, Future referrerPrefs, String token) { - super(c, referrerPrefs, token); - } + JSONObject eventProps = eventMessage.getProperties(); + assertFalse(eventProps.has("group_key")); - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } - } - - MixpanelAPI api = new ListeningAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "TRACKCHARGE TEST TOKEN"); - api.getPeople().identify("TRACKCHARGE PERSON"); - - JSONObject props; - try { - props = new JSONObject("{'$time':'Should override', 'Orange':'Banana'}"); - } catch (JSONException e) { - throw new RuntimeException("Can't construct fixture for trackCharge test"); - } - - api.getPeople().trackCharge(2.13, props); - assertEquals(messages.size(), 1); - - JSONObject message = messages.get(0).getMessage(); - - try { - JSONObject append = message.getJSONObject("$append"); - JSONObject newTransaction = append.getJSONObject("$transactions"); - assertEquals(newTransaction.optString("Orange"), "Banana"); - assertEquals(newTransaction.optString("$time"), "Should override"); - assertEquals(newTransaction.optDouble("$amount"), 2.13, 0); - } catch (JSONException e) { - fail("Transaction message had unexpected layout:\n" + message.toString()); - } + try { + JSONArray unsetMessage = peopleMessage.getJSONArray("$unset"); + assertEquals(1, unsetMessage.length()); + assertEquals("group_key", unsetMessage.get(0)); + } catch (JSONException e) { + fail("People message has an unexpected shape " + e); } - @Test - public void testTrackWithSavedDistinctId(){ - final String savedDistinctID = "saved_distinct_id"; - final List messages = new ArrayList(); - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public void eventsMessage(EventDescription heard) { - if (!heard.isAutomatic() && !heard.getEventName().equals("$identify")) { - messages.add(heard); - } - } - - @Override - public void peopleMessage(PeopleDescription heard) { - messages.add(heard); - } + messages.clear(); + } + + @Test + public void testIdentifyCall() throws JSONException { + String newDistinctId = "New distinct ID"; + final List messages = + new ArrayList(); + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public void eventsMessage(EventDescription heard) { + if (!heard.isAutomatic()) { + messages.add(heard); + } + } }; - class TestMixpanelAPI extends MixpanelAPI { - public TestMixpanelAPI(Context c, Future prefs, String token) { - super(c, prefs, token, false, null, true); - } - - @Override - /* package */ PersistentIdentity getPersistentIdentity(final Context context, final Future referrerPreferences, final String token, final String instanceName) { - String instanceKey = instanceName != null ? instanceName : token; - final String mixpanelPrefsName = "com.mixpanel.android.mpmetrics.Mixpanel"; - final SharedPreferences mpSharedPrefs = context.getSharedPreferences(mixpanelPrefsName, Context.MODE_PRIVATE); - mpSharedPrefs.edit().clear().putBoolean(token, true).putBoolean("has_launched", true).commit(); - final String prefsName = "com.mixpanel.android.mpmetrics.MixpanelAPI_" + instanceKey; - final SharedPreferences loadstorePrefs = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE); - loadstorePrefs.edit().clear().putString("events_distinct_id", savedDistinctID).putString("people_distinct_id", savedDistinctID).commit(); - return super.getPersistentIdentity(context, referrerPreferences, token, instanceName); + // Track calls to the flags endpoint + final List flagsEndpointCalls = new ArrayList<>(); + + MixpanelAPI metrics = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "Test Identify Call") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } + + @Override + protected RemoteService getHttpService() { + // Return a mock RemoteService that tracks calls to the flags endpoint + return new HttpService() { + @Override + public byte[] performRequest( + String endpointUrl, + ProxyServerInteractor interactor, + Map params, + Map headers, + byte[] requestBodyBytes, + SSLSocketFactory socketFactory) + throws ServiceUnavailableException, IOException { + // Track calls to the flags endpoint + if (endpointUrl != null && endpointUrl.contains("/flags/")) { + flagsEndpointCalls.add(endpointUrl); } - - @Override - /* package */ boolean sendAppOpen() { - return false; - } - - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } - } - - TestMixpanelAPI mpMetrics = new TestMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "SAME TOKEN"); - assertEquals(mpMetrics.getDistinctId(), savedDistinctID); - mpMetrics.identify("new_user"); - - mpMetrics.track("eventname", null); - mpMetrics.getPeople().set("people prop name", "Indeed"); - - assertEquals(2, messages.size()); - - AnalyticsMessages.EventDescription eventMessage = (AnalyticsMessages.EventDescription) messages.get(0); - JSONObject peopleMessage = ((AnalyticsMessages.PeopleDescription)messages.get(1)).getMessage(); - - try { - JSONObject eventProps = eventMessage.getProperties(); - String deviceId = eventProps.getString("$device_id"); - assertEquals(savedDistinctID, deviceId); - boolean hadPersistedDistinctId = eventProps.getBoolean("$had_persisted_distinct_id"); - assertEquals(true, hadPersistedDistinctId); - } catch (JSONException e) { - fail("Event message has an unexpected shape " + e); - } - - try { - String deviceId = peopleMessage.getString("$device_id"); - boolean hadPersistedDistinctId = peopleMessage.getBoolean("$had_persisted_distinct_id"); - assertEquals(savedDistinctID, deviceId); - assertEquals(true, hadPersistedDistinctId); - } catch (JSONException e) { - fail("Event message has an unexpected shape " + e); - } - messages.clear(); - } - - @Test - public void testSetAddRemoveGroup(){ - final List messages = new ArrayList(); - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public void eventsMessage(EventDescription heard) { - if (!heard.isAutomatic() && - !heard.getEventName().equals("$identify") && - !heard.getEventName().equals("Integration")) { - messages.add(heard); - } - } - - @Override - public void peopleMessage(PeopleDescription heard) { - messages.add(heard); - } + // Return empty flags response + return "{\"flags\":{}}".getBytes(); + } + }; + } }; - class TestMixpanelAPI extends MixpanelAPI { - public TestMixpanelAPI(Context c, Future prefs, String token) { - super(c, prefs, token, false, null, true); - } + String oldDistinctId = metrics.getDistinctId(); - @Override - /* package */ boolean sendAppOpen() { - return false; - } + // Clear any flags calls from constructor + flagsEndpointCalls.clear(); - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } - } - - TestMixpanelAPI mpMetrics = new TestMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "SAME TOKEN"); - mpMetrics.identify("new_user"); - - int groupID = 42; - mpMetrics.setGroup("group_key", groupID); - mpMetrics.track("eventname", null); - - assertEquals(2, messages.size()); - - JSONObject peopleMessage = ((AnalyticsMessages.PeopleDescription)messages.get(0)).getMessage(); - AnalyticsMessages.EventDescription eventMessage = (AnalyticsMessages.EventDescription) messages.get(1); - - try { - JSONObject eventProps = eventMessage.getProperties(); - JSONArray groupIDs = eventProps.getJSONArray("group_key"); - assertEquals((new JSONArray()).put(groupID), groupIDs); - } catch (JSONException e) { - fail("Event message has an unexpected shape " + e); - } - - try { - JSONObject setMessage = peopleMessage.getJSONObject("$set"); - assertEquals((new JSONArray()).put(groupID), setMessage.getJSONArray("group_key")); - } catch (JSONException e) { - fail("People message has an unexpected shape " + e); - } - - messages.clear(); - - int groupID2 = 77; - mpMetrics.addGroup("group_key", groupID2); - mpMetrics.track("eventname", null); - JSONArray expectedGroupIDs = new JSONArray(); - expectedGroupIDs.put(groupID); - expectedGroupIDs.put(groupID2); - - assertEquals(2, messages.size()); - peopleMessage = ((AnalyticsMessages.PeopleDescription)messages.get(0)).getMessage(); - eventMessage = (AnalyticsMessages.EventDescription) messages.get(1); - - try { - JSONObject eventProps = eventMessage.getProperties(); - JSONArray groupIDs = eventProps.getJSONArray("group_key"); - assertEquals(expectedGroupIDs, groupIDs); - } catch (JSONException e) { - fail("Event message has an unexpected shape " + e); - } - - try { - JSONObject unionMessage = peopleMessage.getJSONObject("$union"); - assertEquals((new JSONArray()).put(groupID2), unionMessage.getJSONArray("group_key")); - } catch (JSONException e) { - fail("People message has an unexpected shape " + e); - } - - messages.clear(); - mpMetrics.removeGroup("group_key", groupID2); - mpMetrics.track("eventname", null); - - assertEquals(2, messages.size()); - peopleMessage = ((AnalyticsMessages.PeopleDescription)messages.get(0)).getMessage(); - eventMessage = (AnalyticsMessages.EventDescription) messages.get(1); - - try { - JSONObject eventProps = eventMessage.getProperties(); - JSONArray groupIDs = eventProps.getJSONArray("group_key"); - assertEquals((new JSONArray()).put(groupID), groupIDs); - } catch (JSONException e) { - fail("Event message has an unexpected shape " + e); - } - - try { - JSONObject removeMessage = peopleMessage.getJSONObject("$remove"); - assertEquals(groupID2, removeMessage.getInt("group_key")); - } catch (JSONException e) { - fail("People message has an unexpected shape " + e); - } - - messages.clear(); - mpMetrics.removeGroup("group_key", groupID); - mpMetrics.track("eventname", null); - - assertEquals(2, messages.size()); - peopleMessage = ((AnalyticsMessages.PeopleDescription)messages.get(0)).getMessage(); - eventMessage = (AnalyticsMessages.EventDescription) messages.get(1); - - JSONObject eventProps = eventMessage.getProperties(); - assertFalse(eventProps.has("group_key")); - - try { - JSONArray unsetMessage = peopleMessage.getJSONArray("$unset"); - assertEquals(1, unsetMessage.length()); - assertEquals("group_key", unsetMessage.get(0)); - } catch (JSONException e) { - fail("People message has an unexpected shape " + e); - } - - messages.clear(); - } - - @Test - public void testIdentifyCall() throws JSONException { - String newDistinctId = "New distinct ID"; - final List messages = new ArrayList(); - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public void eventsMessage(EventDescription heard) { - if (!heard.isAutomatic()) { - messages.add(heard); - } - } - }; + // First identify should trigger loadFlags since distinctId changes + metrics.identify(newDistinctId); - // Track calls to the flags endpoint - final List flagsEndpointCalls = new ArrayList<>(); - - MixpanelAPI metrics = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "Test Identify Call") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } - - @Override - protected RemoteService getHttpService() { - // Return a mock RemoteService that tracks calls to the flags endpoint - return new HttpService() { - @Override - public byte[] performRequest(String endpointUrl, ProxyServerInteractor interactor, - Map params, Map headers, - byte[] requestBodyBytes, SSLSocketFactory socketFactory) - throws ServiceUnavailableException, IOException { - // Track calls to the flags endpoint - if (endpointUrl != null && endpointUrl.contains("/flags/")) { - flagsEndpointCalls.add(endpointUrl); - } - // Return empty flags response - return "{\"flags\":{}}".getBytes(); - } - }; - } - }; - - String oldDistinctId = metrics.getDistinctId(); - - // Clear any flags calls from constructor - flagsEndpointCalls.clear(); - - // First identify should trigger loadFlags since distinctId changes - metrics.identify(newDistinctId); - - // Give the async flag loading some time to execute - try { - Thread.sleep(500); - } catch (InterruptedException e) { - // Ignore - } - - // Second and third identify should NOT trigger loadFlags since distinctId doesn't change - metrics.identify(newDistinctId); - metrics.identify(newDistinctId); - - // Verify that only one $identify event was tracked - assertEquals(1, messages.size()); - AnalyticsMessages.EventDescription identifyEventDescription = messages.get(0); - assertEquals("$identify", identifyEventDescription.getEventName()); - String newDistinctIdIdentifyTrack = identifyEventDescription.getProperties().getString("distinct_id"); - String anonDistinctIdIdentifyTrack = identifyEventDescription.getProperties().getString("$anon_distinct_id"); - - assertEquals(newDistinctId, newDistinctIdIdentifyTrack); - assertEquals(oldDistinctId, anonDistinctIdIdentifyTrack); - - // Assert that loadFlags was called (flags endpoint was hit) when distinctId changed - assertTrue("loadFlags should have been called when distinctId changed", - flagsEndpointCalls.size() >= 1); + // Give the async flag loading some time to execute + try { + Thread.sleep(500); + } catch (InterruptedException e) { + // Ignore } - @Test - public void testIdentifyResetCall() throws JSONException { - String newDistinctId = "New distinct ID"; - final List messages = new ArrayList(); - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public void eventsMessage(EventDescription heard) { - if (!heard.isAutomatic()) { - messages.add(heard); - } - } + // Second and third identify should NOT trigger loadFlags since distinctId doesn't change + metrics.identify(newDistinctId); + metrics.identify(newDistinctId); + + // Verify that only one $identify event was tracked + assertEquals(1, messages.size()); + AnalyticsMessages.EventDescription identifyEventDescription = messages.get(0); + assertEquals("$identify", identifyEventDescription.getEventName()); + String newDistinctIdIdentifyTrack = + identifyEventDescription.getProperties().getString("distinct_id"); + String anonDistinctIdIdentifyTrack = + identifyEventDescription.getProperties().getString("$anon_distinct_id"); + + assertEquals(newDistinctId, newDistinctIdIdentifyTrack); + assertEquals(oldDistinctId, anonDistinctIdIdentifyTrack); + + // Assert that loadFlags was called (flags endpoint was hit) when distinctId changed + assertTrue( + "loadFlags should have been called when distinctId changed", + flagsEndpointCalls.size() >= 1); + } + + @Test + public void testIdentifyResetCall() throws JSONException { + String newDistinctId = "New distinct ID"; + final List messages = + new ArrayList(); + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public void eventsMessage(EventDescription heard) { + if (!heard.isAutomatic()) { + messages.add(heard); + } + } }; - MixpanelAPI metrics = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "Test Identify Call") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } + MixpanelAPI metrics = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "Test Identify Call") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } }; - ArrayList oldDistinctIds = new ArrayList<>(); - oldDistinctIds.add(metrics.getDistinctId()); - metrics.identify(newDistinctId + "0"); - metrics.reset(); - - assertThat(oldDistinctIds, not(hasItem(metrics.getDistinctId()))); - oldDistinctIds.add(metrics.getDistinctId()); - metrics.identify(newDistinctId + "1"); - metrics.reset(); - - assertThat(oldDistinctIds, not(hasItem(metrics.getDistinctId()))); - oldDistinctIds.add(metrics.getDistinctId()); - metrics.identify(newDistinctId + "2"); - - assertEquals(messages.size(), 3); - for (int i=0; i < 3; i++) { - AnalyticsMessages.EventDescription identifyEventDescription = messages.get(i); - assertEquals(identifyEventDescription.getEventName(), "$identify"); - String newDistinctIdIdentifyTrack = identifyEventDescription.getProperties().getString("distinct_id"); - String anonDistinctIdIdentifyTrack = identifyEventDescription.getProperties().getString("$anon_distinct_id"); - - assertEquals(newDistinctIdIdentifyTrack, newDistinctId + i); - assertEquals(anonDistinctIdIdentifyTrack, oldDistinctIds.get(i)); - } + ArrayList oldDistinctIds = new ArrayList<>(); + oldDistinctIds.add(metrics.getDistinctId()); + metrics.identify(newDistinctId + "0"); + metrics.reset(); + + assertThat(oldDistinctIds, not(hasItem(metrics.getDistinctId()))); + oldDistinctIds.add(metrics.getDistinctId()); + metrics.identify(newDistinctId + "1"); + metrics.reset(); + + assertThat(oldDistinctIds, not(hasItem(metrics.getDistinctId()))); + oldDistinctIds.add(metrics.getDistinctId()); + metrics.identify(newDistinctId + "2"); + + assertEquals(messages.size(), 3); + for (int i = 0; i < 3; i++) { + AnalyticsMessages.EventDescription identifyEventDescription = messages.get(i); + assertEquals(identifyEventDescription.getEventName(), "$identify"); + String newDistinctIdIdentifyTrack = + identifyEventDescription.getProperties().getString("distinct_id"); + String anonDistinctIdIdentifyTrack = + identifyEventDescription.getProperties().getString("$anon_distinct_id"); + + assertEquals(newDistinctIdIdentifyTrack, newDistinctId + i); + assertEquals(anonDistinctIdIdentifyTrack, oldDistinctIds.get(i)); + } + } + + @Test + public void testPersistence() { + MixpanelAPI metricsOne = + new MixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "SAME TOKEN", + false, + null, + true); + metricsOne.reset(); + + JSONObject props; + try { + props = new JSONObject("{ 'a' : 'value of a', 'b' : 'value of b' }"); + } catch (JSONException e) { + throw new RuntimeException("Can't construct fixture for super properties test."); } - @Test - public void testPersistence() { - MixpanelAPI metricsOne = new MixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "SAME TOKEN", false, null, true); - metricsOne.reset(); - - JSONObject props; - try { - props = new JSONObject("{ 'a' : 'value of a', 'b' : 'value of b' }"); - } catch (JSONException e) { - throw new RuntimeException("Can't construct fixture for super properties test."); - } - - metricsOne.clearSuperProperties(); - metricsOne.registerSuperProperties(props); - metricsOne.identify("Expected Events Identity"); - - // We exploit the fact that any metrics object with the same token - // will get their values from the same persistent store. - - final List messages = new ArrayList(); - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public void eventsMessage(EventDescription heard) { - if (!heard.isAutomatic()) { - messages.add(heard); - } - } - - @Override - public void peopleMessage(PeopleDescription heard) { - messages.add(heard); - } + metricsOne.clearSuperProperties(); + metricsOne.registerSuperProperties(props); + metricsOne.identify("Expected Events Identity"); + + // We exploit the fact that any metrics object with the same token + // will get their values from the same persistent store. + + final List messages = new ArrayList(); + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public void eventsMessage(EventDescription heard) { + if (!heard.isAutomatic()) { + messages.add(heard); + } + } + + @Override + public void peopleMessage(PeopleDescription heard) { + messages.add(heard); + } }; - class ListeningAPI extends MixpanelAPI { - public ListeningAPI(Context c, Future prefs, String token) { - super(c, prefs, token, false, null, true); - } - - @Override - /* package */ PersistentIdentity getPersistentIdentity(final Context context, final Future referrerPreferences, final String token, final String instanceName) { - String instanceKey = instanceName != null ? instanceName : token; - final String mixpanelPrefsName = "com.mixpanel.android.mpmetrics.Mixpanel"; - final SharedPreferences mpSharedPrefs = context.getSharedPreferences(mixpanelPrefsName, Context.MODE_PRIVATE); - mpSharedPrefs.edit().clear().putBoolean(instanceKey, true).putBoolean("has_launched", true).commit(); - - return super.getPersistentIdentity(context, referrerPreferences, token, instanceName); - } - - @Override - /* package */ boolean sendAppOpen() { - return false; - } - - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } - } + class ListeningAPI extends MixpanelAPI { + public ListeningAPI(Context c, Future prefs, String token) { + super(c, prefs, token, false, null, true); + } + + @Override + /* package */ PersistentIdentity getPersistentIdentity( + final Context context, + final Future referrerPreferences, + final String token, + final String instanceName) { + String instanceKey = instanceName != null ? instanceName : token; + final String mixpanelPrefsName = "com.mixpanel.android.mpmetrics.Mixpanel"; + final SharedPreferences mpSharedPrefs = + context.getSharedPreferences(mixpanelPrefsName, Context.MODE_PRIVATE); + mpSharedPrefs + .edit() + .clear() + .putBoolean(instanceKey, true) + .putBoolean("has_launched", true) + .commit(); + + return super.getPersistentIdentity(context, referrerPreferences, token, instanceName); + } + + @Override + /* package */ boolean sendAppOpen() { + return false; + } + + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } + } - MixpanelAPI differentToken = new ListeningAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "DIFFERENT TOKEN"); + MixpanelAPI differentToken = + new ListeningAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "DIFFERENT TOKEN"); - differentToken.track("other event", null); - differentToken.getPeople().set("other people prop", "Word"); // should be queued up. + differentToken.track("other event", null); + differentToken.getPeople().set("other people prop", "Word"); // should be queued up. - assertEquals(2, messages.size()); + assertEquals(2, messages.size()); - AnalyticsMessages.EventDescription eventMessage = (AnalyticsMessages.EventDescription) messages.get(0); + AnalyticsMessages.EventDescription eventMessage = + (AnalyticsMessages.EventDescription) messages.get(0); - try { - JSONObject eventProps = eventMessage.getProperties(); - String sentId = eventProps.getString("distinct_id"); - String sentA = eventProps.optString("a"); - String sentB = eventProps.optString("b"); + try { + JSONObject eventProps = eventMessage.getProperties(); + String sentId = eventProps.getString("distinct_id"); + String sentA = eventProps.optString("a"); + String sentB = eventProps.optString("b"); - assertFalse("Expected Events Identity".equals(sentId)); - assertEquals("", sentA); - assertEquals("", sentB); - } catch (JSONException e) { - fail("Event message has an unexpected shape " + e); - } + assertFalse("Expected Events Identity".equals(sentId)); + assertEquals("", sentA); + assertEquals("", sentB); + } catch (JSONException e) { + fail("Event message has an unexpected shape " + e); + } - messages.clear(); + messages.clear(); - MixpanelAPI metricsTwo = new ListeningAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "SAME TOKEN"); + MixpanelAPI metricsTwo = + new ListeningAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "SAME TOKEN"); - metricsTwo.track("eventname", null); - metricsTwo.getPeople().set("people prop name", "Indeed"); + metricsTwo.track("eventname", null); + metricsTwo.getPeople().set("people prop name", "Indeed"); - assertEquals(2, messages.size()); + assertEquals(2, messages.size()); - eventMessage = (AnalyticsMessages.EventDescription) messages.get(0); - JSONObject peopleMessage = ((AnalyticsMessages.PeopleDescription)messages.get(1)).getMessage(); + eventMessage = (AnalyticsMessages.EventDescription) messages.get(0); + JSONObject peopleMessage = ((AnalyticsMessages.PeopleDescription) messages.get(1)).getMessage(); - try { - JSONObject eventProps = eventMessage.getProperties(); - String sentId = eventProps.getString("distinct_id"); - String sentA = eventProps.getString("a"); - String sentB = eventProps.getString("b"); + try { + JSONObject eventProps = eventMessage.getProperties(); + String sentId = eventProps.getString("distinct_id"); + String sentA = eventProps.getString("a"); + String sentB = eventProps.getString("b"); - assertEquals("Expected Events Identity", sentId); - assertEquals("value of a", sentA); - assertEquals("value of b", sentB); - } catch (JSONException e) { - fail("Event message has an unexpected shape " + e); - } + assertEquals("Expected Events Identity", sentId); + assertEquals("value of a", sentA); + assertEquals("value of b", sentB); + } catch (JSONException e) { + fail("Event message has an unexpected shape " + e); + } - try { - String sentId = peopleMessage.getString("$distinct_id"); - assertEquals("Expected Events Identity", sentId); - } catch (JSONException e) { - fail("Event message has an unexpected shape: " + peopleMessage.toString()); - } + try { + String sentId = peopleMessage.getString("$distinct_id"); + assertEquals("Expected Events Identity", sentId); + } catch (JSONException e) { + fail("Event message has an unexpected shape: " + peopleMessage.toString()); } + } - @Test - public void testTrackInThread() throws InterruptedException, JSONException { - class TestThread extends Thread { - final BlockingQueue mMessages; + @Test + public void testTrackInThread() throws InterruptedException, JSONException { + class TestThread extends Thread { + final BlockingQueue mMessages; - public TestThread(BlockingQueue messages) { - this.mMessages = messages; - } + public TestThread(BlockingQueue messages) { + this.mMessages = messages; + } - @Override - public void run() { - - final MPDbAdapter dbMock = new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public int addJSON(JSONObject message, String token, MPDbAdapter.Table table) { - mMessages.add(message); - - return 1; - } - }; - - final AnalyticsMessages analyticsMessages = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public MPDbAdapter makeDbAdapter(Context context) { - return dbMock; - } - }; - - MixpanelAPI mixpanel = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "TEST TOKEN") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return analyticsMessages; - } - }; - mixpanel.reset(); - mixpanel.track("test in thread", new JSONObject()); - } - } + @Override + public void run() { - ////////////////////////////// + final MPDbAdapter dbMock = + new MPDbAdapter( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance( + InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public int addJSON(JSONObject message, String token, MPDbAdapter.Table table) { + mMessages.add(message); - final BlockingQueue messages = new LinkedBlockingQueue(); - TestThread testThread = new TestThread(messages); - testThread.start(); - JSONObject found = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); - assertNotNull(found); - assertEquals(found.getString("event"), "test in thread"); - assertTrue(found.getJSONObject("properties").has("$bluetooth_version")); + return 1; + } + }; + + final AnalyticsMessages analyticsMessages = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance( + InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public MPDbAdapter makeDbAdapter(Context context) { + return dbMock; + } + }; + + MixpanelAPI mixpanel = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "TEST TOKEN") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return analyticsMessages; + } + }; + mixpanel.reset(); + mixpanel.track("test in thread", new JSONObject()); + } } - @Test - public void testAlias() { - final RemoteService mockPoster = new HttpService() { - @Override - public byte[] performRequest( - @NonNull String endpointUrl, - @Nullable ProxyServerInteractor interactor, - @Nullable Map params, // Used only if requestBodyBytes is null - @Nullable Map headers, - @Nullable byte[] requestBodyBytes, // If provided, send this as raw body - @Nullable SSLSocketFactory socketFactory) - { - try { - assertTrue(params.containsKey("data")); - final String jsonData = Base64Coder.decodeString(params.get("data").toString()); - JSONArray msg = new JSONArray(jsonData); - JSONObject event = msg.getJSONObject(0); - JSONObject properties = event.getJSONObject("properties"); - - assertEquals(event.getString("event"), "$create_alias"); - assertEquals(properties.getString("distinct_id"), "old id"); - assertEquals(properties.getString("alias"), "new id"); - } catch (JSONException e) { - throw new RuntimeException("Malformed data passed to test mock", e); - } - return TestUtils.bytes("1\n"); - } + ////////////////////////////// + + final BlockingQueue messages = new LinkedBlockingQueue(); + TestThread testThread = new TestThread(messages); + testThread.start(); + JSONObject found = messages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS); + assertNotNull(found); + assertEquals(found.getString("event"), "test in thread"); + assertTrue(found.getJSONObject("properties").has("$bluetooth_version")); + } + + @Test + public void testAlias() { + final RemoteService mockPoster = + new HttpService() { + @Override + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) { + try { + assertTrue(params.containsKey("data")); + final String jsonData = Base64Coder.decodeString(params.get("data").toString()); + JSONArray msg = new JSONArray(jsonData); + JSONObject event = msg.getJSONObject(0); + JSONObject properties = event.getJSONObject("properties"); + + assertEquals(event.getString("event"), "$create_alias"); + assertEquals(properties.getString("distinct_id"), "old id"); + assertEquals(properties.getString("alias"), "new id"); + } catch (JSONException e) { + throw new RuntimeException("Malformed data passed to test mock", e); + } + return TestUtils.bytes("1\n"); + } }; - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - protected RemoteService getPoster() { - return mockPoster; - } + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + protected RemoteService getPoster() { + return mockPoster; + } }; - MixpanelAPI metrics = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "Test Message Queuing") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } + MixpanelAPI metrics = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "Test Message Queuing") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } }; - // Check that we post the alias immediately - metrics.identify("old id"); - metrics.alias("new id", "old id"); - } - - @Test - public void testMultiInstancesWithInstanceName() throws InterruptedException, JSONException { - final BlockingQueue anonymousUpdates = new LinkedBlockingQueue(); - final BlockingQueue identifiedUpdates = new LinkedBlockingQueue(); - - final MPDbAdapter mockAdapter = new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public int addJSON(JSONObject j, String token, Table table) { - if (table == Table.ANONYMOUS_PEOPLE) { - anonymousUpdates.add(j); - } else if (table == Table.PEOPLE) { - identifiedUpdates.add(j); - } - return super.addJSON(j, token, table); - } + // Check that we post the alias immediately + metrics.identify("old id"); + metrics.alias("new id", "old id"); + } + + @Test + public void testMultiInstancesWithInstanceName() throws InterruptedException, JSONException { + final BlockingQueue anonymousUpdates = new LinkedBlockingQueue(); + final BlockingQueue identifiedUpdates = new LinkedBlockingQueue(); + + final MPDbAdapter mockAdapter = + new MPDbAdapter( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public int addJSON(JSONObject j, String token, Table table) { + if (table == Table.ANONYMOUS_PEOPLE) { + anonymousUpdates.add(j); + } else if (table == Table.PEOPLE) { + identifiedUpdates.add(j); + } + return super.addJSON(j, token, table); + } }; - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - protected MPDbAdapter makeDbAdapter(Context context) { - return mockAdapter; - } + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + protected MPDbAdapter makeDbAdapter(Context context) { + return mockAdapter; + } }; - MixpanelAPI mixpanel1 = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "testAnonymousPeopleUpdates", "instance1") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } + MixpanelAPI mixpanel1 = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "testAnonymousPeopleUpdates", + "instance1") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } }; - MixpanelAPI mixpanel2 = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "testAnonymousPeopleUpdates", "instance2") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } + MixpanelAPI mixpanel2 = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "testAnonymousPeopleUpdates", + "instance2") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } }; - // mixpanel1 and mixpanel2 are treated as different instances because of the their instance names are different - assertTrue(!mixpanel1.getDistinctId().equals(mixpanel2.getDistinctId())); - mixpanel1.getPeople().set("firstProperty", "firstValue"); - assertEquals("firstValue", anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$set").getString("firstProperty")); - mixpanel1.identify("mixpanel_distinct_id"); - mixpanel1.getPeople().set("firstPropertyIdentified", "firstValue"); - assertEquals("firstValue", identifiedUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$set").getString("firstPropertyIdentified")); - assertEquals(0, anonymousUpdates.size()); - assertTrue(mixpanel1.getDistinctId().equals("mixpanel_distinct_id")); - - - mixpanel2.getPeople().set("firstProperty2", "firstValue2"); - assertEquals("firstValue2", anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$set").getString("firstProperty2")); - mixpanel2.identify("mixpanel_distinct_id2"); - mixpanel2.getPeople().set("firstPropertyIdentified2", "firstValue2"); - assertEquals("firstValue2", identifiedUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$set").getString("firstPropertyIdentified2")); - assertEquals(0, anonymousUpdates.size()); - assertTrue(!mixpanel1.getDistinctId().equals(mixpanel2.getDistinctId())); - assertTrue(mixpanel2.getDistinctId().equals("mixpanel_distinct_id2")); - } - - @Test - public void testAnonymousPeopleUpdates() throws InterruptedException, JSONException { - final BlockingQueue anonymousUpdates = new LinkedBlockingQueue(); - final BlockingQueue identifiedUpdates = new LinkedBlockingQueue(); - - final MPDbAdapter mockAdapter = new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public int addJSON(JSONObject j, String token, Table table) { - if (table == Table.ANONYMOUS_PEOPLE) { - anonymousUpdates.add(j); - } else if (table == Table.PEOPLE) { - identifiedUpdates.add(j); - } - return super.addJSON(j, token, table); - } + // mixpanel1 and mixpanel2 are treated as different instances because of the their instance + // names are different + assertTrue(!mixpanel1.getDistinctId().equals(mixpanel2.getDistinctId())); + mixpanel1.getPeople().set("firstProperty", "firstValue"); + assertEquals( + "firstValue", + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$set") + .getString("firstProperty")); + mixpanel1.identify("mixpanel_distinct_id"); + mixpanel1.getPeople().set("firstPropertyIdentified", "firstValue"); + assertEquals( + "firstValue", + identifiedUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$set") + .getString("firstPropertyIdentified")); + assertEquals(0, anonymousUpdates.size()); + assertTrue(mixpanel1.getDistinctId().equals("mixpanel_distinct_id")); + + mixpanel2.getPeople().set("firstProperty2", "firstValue2"); + assertEquals( + "firstValue2", + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$set") + .getString("firstProperty2")); + mixpanel2.identify("mixpanel_distinct_id2"); + mixpanel2.getPeople().set("firstPropertyIdentified2", "firstValue2"); + assertEquals( + "firstValue2", + identifiedUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$set") + .getString("firstPropertyIdentified2")); + assertEquals(0, anonymousUpdates.size()); + assertTrue(!mixpanel1.getDistinctId().equals(mixpanel2.getDistinctId())); + assertTrue(mixpanel2.getDistinctId().equals("mixpanel_distinct_id2")); + } + + @Test + public void testAnonymousPeopleUpdates() throws InterruptedException, JSONException { + final BlockingQueue anonymousUpdates = new LinkedBlockingQueue(); + final BlockingQueue identifiedUpdates = new LinkedBlockingQueue(); + + final MPDbAdapter mockAdapter = + new MPDbAdapter( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public int addJSON(JSONObject j, String token, Table table) { + if (table == Table.ANONYMOUS_PEOPLE) { + anonymousUpdates.add(j); + } else if (table == Table.PEOPLE) { + identifiedUpdates.add(j); + } + return super.addJSON(j, token, table); + } }; - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - protected MPDbAdapter makeDbAdapter(Context context) { - return mockAdapter; - } + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + protected MPDbAdapter makeDbAdapter(Context context) { + return mockAdapter; + } }; - MixpanelAPI mixpanel = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "testAnonymousPeopleUpdates") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } + MixpanelAPI mixpanel = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "testAnonymousPeopleUpdates") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } + }; + mixpanel.getPeople().set("firstProperty", "firstValue"); + mixpanel.getPeople().increment("incrementProperty", 3L); + mixpanel.getPeople().append("appendProperty", "appendPropertyValue"); + mixpanel.getPeople().unset("unSetProperty"); + assertEquals( + "firstValue", + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$set") + .getString("firstProperty")); + assertEquals( + 3L, + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$add") + .getLong("incrementProperty")); + assertEquals( + "appendPropertyValue", + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$append") + .getString("appendProperty")); + assertEquals( + "[\"unSetProperty\"]", + anonymousUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONArray("$unset") + .toString()); + assertEquals(0, anonymousUpdates.size()); + assertEquals(0, identifiedUpdates.size()); + + mixpanel.identify("mixpanel_distinct_id"); + mixpanel.getPeople().set("firstPropertyIdentified", "firstValue"); + mixpanel.getPeople().increment("incrementPropertyIdentified", 3L); + mixpanel.getPeople().append("appendPropertyIdentified", "appendPropertyValue"); + mixpanel.getPeople().unset("unSetPropertyIdentified"); + assertEquals( + "firstValue", + identifiedUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$set") + .getString("firstPropertyIdentified")); + assertEquals( + 3L, + identifiedUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$add") + .getLong("incrementPropertyIdentified")); + assertEquals( + "appendPropertyValue", + identifiedUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONObject("$append") + .getString("appendPropertyIdentified")); + assertEquals( + "[\"unSetPropertyIdentified\"]", + identifiedUpdates + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getJSONArray("$unset") + .toString()); + assertEquals(0, anonymousUpdates.size()); + } + + @Test + public void testEventTiming() throws InterruptedException { + final int MAX_TIMEOUT_POLL = 6500; + Future mMockReferrerPreferences; + final BlockingQueue mStoredEvents = new LinkedBlockingQueue<>(); + mMockReferrerPreferences = + new TestUtils.EmptyPreferences(InstrumentationRegistry.getInstrumentation().getContext()); + MixpanelAPI mMixpanelAPI = + new MixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockReferrerPreferences, + "TESTTOKEN", + false, + null, + true) { + @Override + PersistentIdentity getPersistentIdentity( + Context context, + Future referrerPreferences, + String token, + String instanceName) { + mPersistentIdentity = + super.getPersistentIdentity(context, referrerPreferences, token, instanceName); + return mPersistentIdentity; + } }; - mixpanel.getPeople().set("firstProperty", "firstValue"); - mixpanel.getPeople().increment("incrementProperty", 3L); - mixpanel.getPeople().append("appendProperty", "appendPropertyValue"); - mixpanel.getPeople().unset("unSetProperty"); - assertEquals("firstValue", anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$set").getString("firstProperty")); - assertEquals(3L, anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$add").getLong("incrementProperty")); - assertEquals("appendPropertyValue", anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$append").getString("appendProperty")); - assertEquals("[\"unSetProperty\"]", anonymousUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONArray("$unset").toString()); - assertEquals(0, anonymousUpdates.size()); - assertEquals(0, identifiedUpdates.size()); - - mixpanel.identify("mixpanel_distinct_id"); - mixpanel.getPeople().set("firstPropertyIdentified", "firstValue"); - mixpanel.getPeople().increment("incrementPropertyIdentified", 3L); - mixpanel.getPeople().append("appendPropertyIdentified", "appendPropertyValue"); - mixpanel.getPeople().unset("unSetPropertyIdentified"); - assertEquals("firstValue", identifiedUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$set").getString("firstPropertyIdentified")); - assertEquals(3L, identifiedUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$add").getLong("incrementPropertyIdentified")); - assertEquals("appendPropertyValue", identifiedUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$append").getString("appendPropertyIdentified")); - assertEquals("[\"unSetPropertyIdentified\"]", identifiedUpdates.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONArray("$unset").toString()); - assertEquals(0, anonymousUpdates.size()); - } - - @Test - public void testEventTiming() throws InterruptedException { - final int MAX_TIMEOUT_POLL = 6500; - Future mMockReferrerPreferences; - final BlockingQueue mStoredEvents = new LinkedBlockingQueue<>(); - mMockReferrerPreferences = new TestUtils.EmptyPreferences(InstrumentationRegistry.getInstrumentation().getContext()); - MixpanelAPI mMixpanelAPI = new MixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockReferrerPreferences, "TESTTOKEN", false, null, true) { - @Override - PersistentIdentity getPersistentIdentity(Context context, Future referrerPreferences, String token, String instanceName) { - mPersistentIdentity = super.getPersistentIdentity(context, referrerPreferences, token, instanceName); - return mPersistentIdentity; - } + mMixpanelAPI.timeEvent("Time Event"); + assertEquals(1, mPersistentIdentity.getTimeEvents().size()); + + mMixpanelAPI.track("Time Event"); + assertEquals(0, mPersistentIdentity.getTimeEvents().size()); + mMixpanelAPI.timeEvent("Time Event1"); + mMixpanelAPI.timeEvent("Time Event2"); + assertEquals(2, mPersistentIdentity.getTimeEvents().size()); + mMixpanelAPI.clearTimedEvents(); + assertEquals(0, mPersistentIdentity.getTimeEvents().size()); + mMixpanelAPI.timeEvent("Time Event3"); + mMixpanelAPI.timeEvent("Time Event4"); + mMixpanelAPI.clearTimedEvent("Time Event3"); + assertEquals(1, mPersistentIdentity.getTimeEvents().size()); + assertTrue(mPersistentIdentity.getTimeEvents().containsKey("Time Event4")); + assertFalse(mPersistentIdentity.getTimeEvents().containsKey("Time Event3")); + mMixpanelAPI.clearTimedEvent(null); + assertEquals(1, mPersistentIdentity.getTimeEvents().size()); + } + + @Test + public void testSessionMetadata() throws InterruptedException, JSONException { + final BlockingQueue storedJsons = new LinkedBlockingQueue<>(); + final BlockingQueue eventsMessages = + new LinkedBlockingQueue<>(); + final BlockingQueue peopleMessages = + new LinkedBlockingQueue<>(); + final MPDbAdapter mockAdapter = + new MPDbAdapter( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + + @Override + public int addJSON(JSONObject j, String token, Table table) { + storedJsons.add(j); + return super.addJSON(j, token, table); + } + }; + final AnalyticsMessages listener = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + public void eventsMessage(EventDescription eventDescription) { + if (!eventDescription.isAutomatic()) { + eventsMessages.add(eventDescription); + super.eventsMessage(eventDescription); + } + } + + @Override + public void peopleMessage(PeopleDescription peopleDescription) { + peopleMessages.add(peopleDescription); + super.peopleMessage(peopleDescription); + } + + @Override + protected MPDbAdapter makeDbAdapter(Context context) { + return mockAdapter; + } + }; + MixpanelAPI metrics = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockPreferences, + "Test Session Metadata") { + @Override + protected AnalyticsMessages getAnalyticsMessages() { + return listener; + } + + @Override + protected void track(String eventName, JSONObject properties, boolean isAutomaticEvent) { + if (!isAutomaticEvent) { + super.track(eventName, properties, isAutomaticEvent); + } + } }; - mMixpanelAPI.timeEvent("Time Event"); - assertEquals(1, mPersistentIdentity.getTimeEvents().size()); - - mMixpanelAPI.track("Time Event"); - assertEquals(0, mPersistentIdentity.getTimeEvents().size()); - mMixpanelAPI.timeEvent("Time Event1"); - mMixpanelAPI.timeEvent("Time Event2"); - assertEquals(2, mPersistentIdentity.getTimeEvents().size()); - mMixpanelAPI.clearTimedEvents(); - assertEquals(0, mPersistentIdentity.getTimeEvents().size()); - mMixpanelAPI.timeEvent("Time Event3"); - mMixpanelAPI.timeEvent("Time Event4"); - mMixpanelAPI.clearTimedEvent("Time Event3"); - assertEquals(1, mPersistentIdentity.getTimeEvents().size()); - assertTrue(mPersistentIdentity.getTimeEvents().containsKey("Time Event4")); - assertFalse(mPersistentIdentity.getTimeEvents().containsKey("Time Event3")); - mMixpanelAPI.clearTimedEvent(null); - assertEquals(1, mPersistentIdentity.getTimeEvents().size()); - } + metrics.track("First Event"); + metrics.track("Second Event"); + metrics.track("Third Event"); + metrics.track("Fourth Event"); + metrics.identify("Mixpanel"); + metrics.getPeople().set("setProperty", "setValue"); + metrics.getPeople().append("appendProperty", "appendValue"); + metrics.getPeople().deleteUser(); - @Test - public void testSessionMetadata() throws InterruptedException, JSONException { - final BlockingQueue storedJsons = new LinkedBlockingQueue<>(); - final BlockingQueue eventsMessages = new LinkedBlockingQueue<>(); - final BlockingQueue peopleMessages = new LinkedBlockingQueue<>(); - final MPDbAdapter mockAdapter = new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + for (int i = 0; i < 4; i++) { + JSONObject sessionMetadata = + eventsMessages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getSessionMetadata(); + assertTrue(sessionMetadata.has("$mp_event_id")); + assertTrue(sessionMetadata.has("$mp_session_id")); + assertTrue(sessionMetadata.has("$mp_session_start_sec")); - @Override - public int addJSON(JSONObject j, String token, Table table) { - storedJsons.add(j); - return super.addJSON(j, token, table); - } - }; - final AnalyticsMessages listener = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - public void eventsMessage(EventDescription eventDescription) { - if (!eventDescription.isAutomatic()) { - eventsMessages.add(eventDescription); - super.eventsMessage(eventDescription); - } - } + assertEquals(i, sessionMetadata.getInt("$mp_session_seq_id")); + } + eventsMessages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getSessionMetadata(); + assertNull(eventsMessages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); + + for (int i = 0; i < 3; i++) { + JSONObject sessionMetadata = + peopleMessages + .poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS) + .getMessage() + .getJSONObject("$mp_metadata"); + assertTrue(sessionMetadata.has("$mp_event_id")); + assertTrue(sessionMetadata.has("$mp_session_id")); + assertTrue(sessionMetadata.has("$mp_session_start_sec")); + + assertEquals(i, sessionMetadata.getInt("$mp_session_seq_id")); + } + assertNull(peopleMessages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); - @Override - public void peopleMessage(PeopleDescription peopleDescription) { - peopleMessages.add(peopleDescription); - super.peopleMessage(peopleDescription); - } + for (int i = 0; i < 4; i++) { + JSONObject sessionMetadata = + storedJsons.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$mp_metadata"); + assertTrue(sessionMetadata.has("$mp_event_id")); + assertTrue(sessionMetadata.has("$mp_session_id")); + assertTrue(sessionMetadata.has("$mp_session_start_sec")); - @Override - protected MPDbAdapter makeDbAdapter(Context context) { - return mockAdapter; - } - }; - MixpanelAPI metrics = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockPreferences, "Test Session Metadata") { - @Override - protected AnalyticsMessages getAnalyticsMessages() { - return listener; - } + assertEquals(i, sessionMetadata.getInt("$mp_session_seq_id")); + } + storedJsons.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$mp_metadata"); - @Override - protected void track(String eventName, JSONObject properties, boolean isAutomaticEvent) { - if (!isAutomaticEvent) { - super.track(eventName, properties, isAutomaticEvent); - } - } - }; + for (int i = 0; i < 3; i++) { + JSONObject sessionMetadata = + storedJsons.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$mp_metadata"); + assertTrue(sessionMetadata.has("$mp_event_id")); + assertTrue(sessionMetadata.has("$mp_session_id")); + assertTrue(sessionMetadata.has("$mp_session_start_sec")); - metrics.track("First Event"); - metrics.track("Second Event"); - metrics.track("Third Event"); - metrics.track("Fourth Event"); - - metrics.identify("Mixpanel"); - metrics.getPeople().set("setProperty", "setValue"); - metrics.getPeople().append("appendProperty", "appendValue"); - metrics.getPeople().deleteUser(); - - for (int i = 0; i < 4; i++) { - JSONObject sessionMetadata = eventsMessages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getSessionMetadata(); - assertTrue(sessionMetadata.has("$mp_event_id")); - assertTrue(sessionMetadata.has("$mp_session_id")); - assertTrue(sessionMetadata.has("$mp_session_start_sec")); - - assertEquals(i, sessionMetadata.getInt("$mp_session_seq_id")); - } - eventsMessages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getSessionMetadata(); - assertNull(eventsMessages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); - - for (int i = 0; i < 3; i++) { - JSONObject sessionMetadata = peopleMessages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getMessage().getJSONObject("$mp_metadata"); - assertTrue(sessionMetadata.has("$mp_event_id")); - assertTrue(sessionMetadata.has("$mp_session_id")); - assertTrue(sessionMetadata.has("$mp_session_start_sec")); - - assertEquals(i, sessionMetadata.getInt("$mp_session_seq_id")); - } - assertNull(peopleMessages.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); - - for (int i = 0; i < 4; i++) { - JSONObject sessionMetadata = storedJsons.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$mp_metadata"); - assertTrue(sessionMetadata.has("$mp_event_id")); - assertTrue(sessionMetadata.has("$mp_session_id")); - assertTrue(sessionMetadata.has("$mp_session_start_sec")); - - assertEquals(i, sessionMetadata.getInt("$mp_session_seq_id")); - } - storedJsons.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$mp_metadata"); - - for (int i = 0; i < 3; i++) { - JSONObject sessionMetadata = storedJsons.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS).getJSONObject("$mp_metadata"); - assertTrue(sessionMetadata.has("$mp_event_id")); - assertTrue(sessionMetadata.has("$mp_session_id")); - assertTrue(sessionMetadata.has("$mp_session_start_sec")); - - assertEquals(i, sessionMetadata.getInt("$mp_session_seq_id")); - } - assertNull(storedJsons.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); + assertEquals(i, sessionMetadata.getInt("$mp_session_seq_id")); } + assertNull(storedJsons.poll(POLL_WAIT_SECONDS, TimeUnit.SECONDS)); + } - private Future mMockPreferences; + private Future mMockPreferences; - private static final int POLL_WAIT_SECONDS = 10; + private static final int POLL_WAIT_SECONDS = 10; - private String mAppProperties; + private String mAppProperties; - private PersistentIdentity mPersistentIdentity; + private PersistentIdentity mPersistentIdentity; } diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/OptOutTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/OptOutTest.java index d88a44202..24943ca88 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/OptOutTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/OptOutTest.java @@ -1,26 +1,21 @@ package com.mixpanel.android.mpmetrics; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + import android.content.Context; import android.content.SharedPreferences; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; - import com.mixpanel.android.util.Base64Coder; import com.mixpanel.android.util.HttpService; import com.mixpanel.android.util.ProxyServerInteractor; import com.mixpanel.android.util.RemoteService; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - import java.util.HashMap; import java.util.Map; import java.util.concurrent.BlockingQueue; @@ -28,374 +23,441 @@ import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; - import javax.net.ssl.SSLSocketFactory; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class OptOutTest { - private MixpanelAPI mMixpanelAPI; - private static final String TOKEN = "Opt Out Test Token"; - final private BlockingQueue mPerformRequestEvents = new LinkedBlockingQueue<>(); - final private BlockingQueue mStoredEvents = new LinkedBlockingQueue<>(); - final private BlockingQueue mStoredPeopleUpdates = new LinkedBlockingQueue<>(); - final private BlockingQueue mStoredAnonymousPeopleUpdates = new LinkedBlockingQueue<>(); - private CountDownLatch mCleanUpCalls = new CountDownLatch(1); - - private MPDbAdapter mMockAdapter; - private Future mMockReferrerPreferences; - private AnalyticsMessages mAnalyticsMessages; - private PersistentIdentity mPersistentIdentity; - private static final int MAX_TIMEOUT_POLL = 6500; - - @Before - public void setUp() { - mMockReferrerPreferences = new TestUtils.EmptyPreferences(InstrumentationRegistry.getInstrumentation().getContext()); - - final RemoteService mockPoster = new HttpService() { - @Override - public byte[] performRequest( - @NonNull String endpointUrl, - @Nullable ProxyServerInteractor interactor, - @Nullable Map params, // Used only if requestBodyBytes is null - @Nullable Map headers, - @Nullable byte[] requestBodyBytes, // If provided, send this as raw body - @Nullable SSLSocketFactory socketFactory) - { - if (params != null) { - final String jsonData = Base64Coder.decodeString(params.get("data").toString()); - assertTrue(params.containsKey("data")); - - try { - JSONArray jsonArray = new JSONArray(jsonData); - for (int i = 0; i < jsonArray.length(); i++) { - mPerformRequestEvents.put(jsonArray.getJSONObject(i).toString()); - } - return TestUtils.bytes("1\n"); - } catch (JSONException e) { - throw new RuntimeException("Malformed data passed to test mock", e); - } catch (InterruptedException e) { - throw new RuntimeException("Could not write message to reporting queue for tests.", e); - } - + private MixpanelAPI mMixpanelAPI; + private static final String TOKEN = "Opt Out Test Token"; + private final BlockingQueue mPerformRequestEvents = new LinkedBlockingQueue<>(); + private final BlockingQueue mStoredEvents = new LinkedBlockingQueue<>(); + private final BlockingQueue mStoredPeopleUpdates = new LinkedBlockingQueue<>(); + private final BlockingQueue mStoredAnonymousPeopleUpdates = new LinkedBlockingQueue<>(); + private CountDownLatch mCleanUpCalls = new CountDownLatch(1); + + private MPDbAdapter mMockAdapter; + private Future mMockReferrerPreferences; + private AnalyticsMessages mAnalyticsMessages; + private PersistentIdentity mPersistentIdentity; + private static final int MAX_TIMEOUT_POLL = 6500; + + @Before + public void setUp() { + mMockReferrerPreferences = + new TestUtils.EmptyPreferences(InstrumentationRegistry.getInstrumentation().getContext()); + + final RemoteService mockPoster = + new HttpService() { + @Override + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) { + if (params != null) { + final String jsonData = Base64Coder.decodeString(params.get("data").toString()); + assertTrue(params.containsKey("data")); + + try { + JSONArray jsonArray = new JSONArray(jsonData); + for (int i = 0; i < jsonArray.length(); i++) { + mPerformRequestEvents.put(jsonArray.getJSONObject(i).toString()); } - - return TestUtils.bytes("{\"automatic_events\": false}"); - } - }; - - mMockAdapter = getMockDBAdapter(); - mAnalyticsMessages = new AnalyticsMessages(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { - @Override - protected RemoteService getPoster() { - return mockPoster; + return TestUtils.bytes("1\n"); + } catch (JSONException e) { + throw new RuntimeException("Malformed data passed to test mock", e); + } catch (InterruptedException e) { + throw new RuntimeException( + "Could not write message to reporting queue for tests.", e); + } } - @Override - protected MPDbAdapter makeDbAdapter(Context context) { - return mMockAdapter; - } + return TestUtils.bytes("{\"automatic_events\": false}"); + } }; - } - - @After - public void tearDown() throws Exception { - if (mPersistentIdentity != null) { - mPersistentIdentity.clearPreferences(); - mPersistentIdentity.removeOptOutFlag(TOKEN); - mPersistentIdentity = null; - } - mMockAdapter.deleteDB(); - } - /** - * Init Mixpanel without tracking. - *

- * Make sure that after initialization no events are stored nor flushed. - * Check that super properties, unidentified people updates or people distinct ID are - * not stored in the device. - * - * @throws InterruptedException - */ - @Test - public void testOptOutDefaultFlag() throws InterruptedException { - mCleanUpCalls = new CountDownLatch(2); // optOutTrack calls - mMixpanelAPI = new MixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockReferrerPreferences, TOKEN, true, null, true) { - @Override - PersistentIdentity getPersistentIdentity(Context context, Future referrerPreferences, String token, String instanceName) { - mPersistentIdentity = super.getPersistentIdentity(context, referrerPreferences, token, instanceName); - return mPersistentIdentity; - } - - @Override - AnalyticsMessages getAnalyticsMessages() { - return mAnalyticsMessages; - } + mMockAdapter = getMockDBAdapter(); + mAnalyticsMessages = + new AnalyticsMessages( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + @Override + protected RemoteService getPoster() { + return mockPoster; + } + + @Override + protected MPDbAdapter makeDbAdapter(Context context) { + return mMockAdapter; + } }; - mMixpanelAPI.flush(); - assertEquals(null, mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals(null, mStoredPeopleUpdates.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals(null, mStoredAnonymousPeopleUpdates.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals(0, mMixpanelAPI.getSuperProperties().length()); - assertNull(mMixpanelAPI.getPeople().getDistinctId()); - assertTrue(mCleanUpCalls.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + } + + @After + public void tearDown() throws Exception { + if (mPersistentIdentity != null) { + mPersistentIdentity.clearPreferences(); + mPersistentIdentity.removeOptOutFlag(TOKEN); + mPersistentIdentity = null; } - - /** - * Check that calls to optInTracking()/optOutTracking() updates hasOptedOutTracking() - * - * @throws InterruptedException - */ - @Test - public void testHasOptOutTrackingOrNot() throws InterruptedException { - mCleanUpCalls = new CountDownLatch(4); // optOutTrack calls - MixpanelAPI mixpanel = new MixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockReferrerPreferences, "TOKEN", true, null, true) { - @Override - PersistentIdentity getPersistentIdentity(Context context, Future referrerPreferences, String token, String instanceName) { - mPersistentIdentity = super.getPersistentIdentity(context, referrerPreferences, token, instanceName); - return mPersistentIdentity; - } - - @Override - AnalyticsMessages getAnalyticsMessages() { - return mAnalyticsMessages; - } + mMockAdapter.deleteDB(); + } + + /** + * Init Mixpanel without tracking. + * + *

Make sure that after initialization no events are stored nor flushed. Check that super + * properties, unidentified people updates or people distinct ID are not stored in the device. + * + * @throws InterruptedException + */ + @Test + public void testOptOutDefaultFlag() throws InterruptedException { + mCleanUpCalls = new CountDownLatch(2); // optOutTrack calls + mMixpanelAPI = + new MixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockReferrerPreferences, + TOKEN, + true, + null, + true) { + @Override + PersistentIdentity getPersistentIdentity( + Context context, + Future referrerPreferences, + String token, + String instanceName) { + mPersistentIdentity = + super.getPersistentIdentity(context, referrerPreferences, token, instanceName); + return mPersistentIdentity; + } + + @Override + AnalyticsMessages getAnalyticsMessages() { + return mAnalyticsMessages; + } }; - - mixpanel.optInTracking(); - assertFalse(mixpanel.hasOptedOutTracking()); - mixpanel.optOutTracking(); - assertTrue(mixpanel.hasOptedOutTracking()); - } - - /** - * Test People updates when opt out/in: - * 1. Not identified user: Updates stored in SharedPreferences should be removed after opting out - * Following updates should be dropped. - * 2. Identified user: Updates stored in DB should be removed after opting out and never sent - * to Mixpanel. Following updates should be dropped as well. - * - * @throws InterruptedException - */ - @Test - public void testPeopleUpdates() throws InterruptedException, JSONException { - mCleanUpCalls = new CountDownLatch(2); - mMixpanelAPI = new MixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockReferrerPreferences, TOKEN,false, null, true) { - @Override - PersistentIdentity getPersistentIdentity(Context context, Future referrerPreferences, String token, String instanceName) { - mPersistentIdentity = super.getPersistentIdentity(context, referrerPreferences, token, instanceName); - return mPersistentIdentity; - } - - @Override - AnalyticsMessages getAnalyticsMessages() { - return mAnalyticsMessages; - } + mMixpanelAPI.flush(); + assertEquals(null, mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals(null, mStoredPeopleUpdates.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals(null, mStoredAnonymousPeopleUpdates.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals(0, mMixpanelAPI.getSuperProperties().length()); + assertNull(mMixpanelAPI.getPeople().getDistinctId()); + assertTrue(mCleanUpCalls.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + } + + /** + * Check that calls to optInTracking()/optOutTracking() updates hasOptedOutTracking() + * + * @throws InterruptedException + */ + @Test + public void testHasOptOutTrackingOrNot() throws InterruptedException { + mCleanUpCalls = new CountDownLatch(4); // optOutTrack calls + MixpanelAPI mixpanel = + new MixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockReferrerPreferences, + "TOKEN", + true, + null, + true) { + @Override + PersistentIdentity getPersistentIdentity( + Context context, + Future referrerPreferences, + String token, + String instanceName) { + mPersistentIdentity = + super.getPersistentIdentity(context, referrerPreferences, token, instanceName); + return mPersistentIdentity; + } + + @Override + AnalyticsMessages getAnalyticsMessages() { + return mAnalyticsMessages; + } }; - mMixpanelAPI.getPeople().set("optOutProperty", "optOutPropertyValue"); - assertEquals("optOutPropertyValue", new JSONObject(mStoredAnonymousPeopleUpdates.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)).getJSONObject("$set").getString("optOutProperty")); - assertEquals(0, mStoredAnonymousPeopleUpdates.size()); - - mMixpanelAPI.optOutTracking(); - mMixpanelAPI.getPeople().set("optOutProperty", "optOutPropertyValue"); - mMixpanelAPI.getPeople().increment("optOutPropertyIncrement", 1); - mMixpanelAPI.getPeople().append("optOutPropertyAppend", "append"); - mMixpanelAPI.getPeople().merge("optOutPropertyMerge", new JSONObject("{'key':'value'}")); - mMixpanelAPI.getPeople().union("optOutPropertyUnion", new JSONArray("[{'key':'value'},{'key2':'value2'}]")); - mMixpanelAPI.getPeople().unset("optOutPropertyUnset"); - mMixpanelAPI.getPeople().setOnce("optOutPropertySetOnce", "setOnceValue"); - assertEquals(true, mStoredAnonymousPeopleUpdates.isEmpty()); - assertTrue(mCleanUpCalls.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - - mMixpanelAPI.optInTracking(); - mMixpanelAPI.identify("identity"); - mMixpanelAPI.getPeople().identify("identity"); - mMixpanelAPI.getPeople().set("optOutProperty", "optOutPropertyValue"); - mMixpanelAPI.getPeople().increment("optOutPropertyIncrement", 1); - mMixpanelAPI.getPeople().append("optOutPropertyAppend", "append"); - mMixpanelAPI.getPeople().merge("optOutPropertyMerge", new JSONObject("{'key':'value'}")); - mMixpanelAPI.getPeople().union("optOutPropertyUnion", new JSONArray("[{'key':'value'},{'key2':'value2'}]")); - mMixpanelAPI.getPeople().unset("optOutPropertyUnset"); - mMixpanelAPI.getPeople().setOnce("optOutPropertySetOnce", "setOnceValue"); - for (int i = 0; i < 7; i++) { - assertNotNull(mStoredPeopleUpdates.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - } - assertEquals(0, mStoredPeopleUpdates.size()); - mMockAdapter = getMockDBAdapter(); - assertNotNull(mMockAdapter.generateDataString(MPDbAdapter.Table.PEOPLE, TOKEN)); - - mCleanUpCalls = new CountDownLatch(2); - mMixpanelAPI.optOutTracking(); - assertTrue(mCleanUpCalls.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - for (int i = 0; i < 2; i++) { - String test = mStoredPeopleUpdates.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS); - assertNotNull(test); - } - String[] data = mMockAdapter.generateDataString(MPDbAdapter.Table.PEOPLE, TOKEN); - JSONArray pendingPeopleUpdatesArray = new JSONArray(data[1]); - assertEquals(2, pendingPeopleUpdatesArray.length()); - assertTrue(pendingPeopleUpdatesArray.getJSONObject(0).has("$delete")); // deleteUser - assertTrue(pendingPeopleUpdatesArray.getJSONObject(1).has("$unset")); // clearCharges - mMixpanelAPI.getPeople().set("optOutProperty", "optOutPropertyValue"); - mMixpanelAPI.getPeople().increment("optOutPropertyIncrement", 1); - mMixpanelAPI.getPeople().append("optOutPropertyAppend", "append"); - - data = mMockAdapter.generateDataString(MPDbAdapter.Table.PEOPLE, TOKEN); - pendingPeopleUpdatesArray = new JSONArray(data[1]); - assertEquals(2, pendingPeopleUpdatesArray.length()); - - forceFlush(); - for (int i = 0; i < 2; i++) { - assertNotNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - } - assertNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - } - - /** - * Test that events are dropped when a user opts out. After opting in, an event should be sent. - * - * @throws InterruptedException - */ - @Test - public void testDropEventsAndOptInEvent() throws InterruptedException { - mMixpanelAPI = new TestUtils.CleanMixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockReferrerPreferences, TOKEN) { - @Override - PersistentIdentity getPersistentIdentity(Context context, Future referrerPreferences, String token, String instanceName) { - mPersistentIdentity = super.getPersistentIdentity(context, referrerPreferences, token, instanceName); - return mPersistentIdentity; - } - - @Override - AnalyticsMessages getAnalyticsMessages() { - return mAnalyticsMessages; - } + mixpanel.optInTracking(); + assertFalse(mixpanel.hasOptedOutTracking()); + mixpanel.optOutTracking(); + assertTrue(mixpanel.hasOptedOutTracking()); + } + + /** + * Test People updates when opt out/in: 1. Not identified user: Updates stored in + * SharedPreferences should be removed after opting out Following updates should be dropped. 2. + * Identified user: Updates stored in DB should be removed after opting out and never sent to + * Mixpanel. Following updates should be dropped as well. + * + * @throws InterruptedException + */ + @Test + public void testPeopleUpdates() throws InterruptedException, JSONException { + mCleanUpCalls = new CountDownLatch(2); + mMixpanelAPI = + new MixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockReferrerPreferences, + TOKEN, + false, + null, + true) { + @Override + PersistentIdentity getPersistentIdentity( + Context context, + Future referrerPreferences, + String token, + String instanceName) { + mPersistentIdentity = + super.getPersistentIdentity(context, referrerPreferences, token, instanceName); + return mPersistentIdentity; + } + + @Override + AnalyticsMessages getAnalyticsMessages() { + return mAnalyticsMessages; + } }; - for (int i = 0; i < 20; i++) { - mMixpanelAPI.track("An Event"); - } - for (int i = 0; i < 20; i++) { - assertEquals("An Event", mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - } - - mCleanUpCalls = new CountDownLatch(2); - mMixpanelAPI.optOutTracking(); - mMockAdapter = getMockDBAdapter(); - assertTrue(mCleanUpCalls.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertNull(mMockAdapter.generateDataString(MPDbAdapter.Table.EVENTS, TOKEN)); - - mMixpanelAPI.optInTracking(); - assertEquals("$opt_in", mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - forceFlush(); - assertNotNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + mMixpanelAPI.getPeople().set("optOutProperty", "optOutPropertyValue"); + assertEquals( + "optOutPropertyValue", + new JSONObject(mStoredAnonymousPeopleUpdates.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)) + .getJSONObject("$set") + .getString("optOutProperty")); + assertEquals(0, mStoredAnonymousPeopleUpdates.size()); + + mMixpanelAPI.optOutTracking(); + mMixpanelAPI.getPeople().set("optOutProperty", "optOutPropertyValue"); + mMixpanelAPI.getPeople().increment("optOutPropertyIncrement", 1); + mMixpanelAPI.getPeople().append("optOutPropertyAppend", "append"); + mMixpanelAPI.getPeople().merge("optOutPropertyMerge", new JSONObject("{'key':'value'}")); + mMixpanelAPI + .getPeople() + .union("optOutPropertyUnion", new JSONArray("[{'key':'value'},{'key2':'value2'}]")); + mMixpanelAPI.getPeople().unset("optOutPropertyUnset"); + mMixpanelAPI.getPeople().setOnce("optOutPropertySetOnce", "setOnceValue"); + assertEquals(true, mStoredAnonymousPeopleUpdates.isEmpty()); + assertTrue(mCleanUpCalls.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + + mMixpanelAPI.optInTracking(); + mMixpanelAPI.identify("identity"); + mMixpanelAPI.getPeople().identify("identity"); + mMixpanelAPI.getPeople().set("optOutProperty", "optOutPropertyValue"); + mMixpanelAPI.getPeople().increment("optOutPropertyIncrement", 1); + mMixpanelAPI.getPeople().append("optOutPropertyAppend", "append"); + mMixpanelAPI.getPeople().merge("optOutPropertyMerge", new JSONObject("{'key':'value'}")); + mMixpanelAPI + .getPeople() + .union("optOutPropertyUnion", new JSONArray("[{'key':'value'},{'key2':'value2'}]")); + mMixpanelAPI.getPeople().unset("optOutPropertyUnset"); + mMixpanelAPI.getPeople().setOnce("optOutPropertySetOnce", "setOnceValue"); + for (int i = 0; i < 7; i++) { + assertNotNull(mStoredPeopleUpdates.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); } - - /** - * Track calls before and after opting out - */ - @Test - public void testTrackCalls() throws InterruptedException, JSONException { - mMixpanelAPI = new MixpanelAPI(InstrumentationRegistry.getInstrumentation().getContext(), mMockReferrerPreferences, TOKEN, false, null, true) { - @Override - PersistentIdentity getPersistentIdentity(Context context, Future referrerPreferences, String token, String instanceName) { - mPersistentIdentity = super.getPersistentIdentity(context, referrerPreferences, token, instanceName); - return mPersistentIdentity; - } - - @Override - AnalyticsMessages getAnalyticsMessages() { - return mAnalyticsMessages; - } + assertEquals(0, mStoredPeopleUpdates.size()); + mMockAdapter = getMockDBAdapter(); + assertNotNull(mMockAdapter.generateDataString(MPDbAdapter.Table.PEOPLE, TOKEN)); + + mCleanUpCalls = new CountDownLatch(2); + mMixpanelAPI.optOutTracking(); + assertTrue(mCleanUpCalls.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + for (int i = 0; i < 2; i++) { + String test = mStoredPeopleUpdates.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS); + assertNotNull(test); + } + String[] data = mMockAdapter.generateDataString(MPDbAdapter.Table.PEOPLE, TOKEN); + JSONArray pendingPeopleUpdatesArray = new JSONArray(data[1]); + assertEquals(2, pendingPeopleUpdatesArray.length()); + assertTrue(pendingPeopleUpdatesArray.getJSONObject(0).has("$delete")); // deleteUser + assertTrue(pendingPeopleUpdatesArray.getJSONObject(1).has("$unset")); // clearCharges + mMixpanelAPI.getPeople().set("optOutProperty", "optOutPropertyValue"); + mMixpanelAPI.getPeople().increment("optOutPropertyIncrement", 1); + mMixpanelAPI.getPeople().append("optOutPropertyAppend", "append"); + + data = mMockAdapter.generateDataString(MPDbAdapter.Table.PEOPLE, TOKEN); + pendingPeopleUpdatesArray = new JSONArray(data[1]); + assertEquals(2, pendingPeopleUpdatesArray.length()); + + forceFlush(); + for (int i = 0; i < 2; i++) { + assertNotNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + } + assertNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + } + + /** + * Test that events are dropped when a user opts out. After opting in, an event should be sent. + * + * @throws InterruptedException + */ + @Test + public void testDropEventsAndOptInEvent() throws InterruptedException { + mMixpanelAPI = + new TestUtils.CleanMixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockReferrerPreferences, + TOKEN) { + @Override + PersistentIdentity getPersistentIdentity( + Context context, + Future referrerPreferences, + String token, + String instanceName) { + mPersistentIdentity = + super.getPersistentIdentity(context, referrerPreferences, token, instanceName); + return mPersistentIdentity; + } + + @Override + AnalyticsMessages getAnalyticsMessages() { + return mAnalyticsMessages; + } }; - mMixpanelAPI.timeEvent("Time Event"); - mMixpanelAPI.trackMap("Event with map", new HashMap()); - mMixpanelAPI.track("Event with properties", new JSONObject()); - assertEquals(1, mPersistentIdentity.getTimeEvents().size()); - - mCleanUpCalls = new CountDownLatch(2); - mMixpanelAPI.optOutTracking(); - assertTrue(mCleanUpCalls.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - mStoredEvents.clear(); - assertEquals(0, mPersistentIdentity.getTimeEvents().size()); - - mMixpanelAPI.timeEvent("Time Event"); - assertEquals(0, mPersistentIdentity.getTimeEvents().size()); - mMixpanelAPI.track("Time Event"); - - mMixpanelAPI.optInTracking(); - mMixpanelAPI.track("Time Event"); - mMixpanelAPI.timeEvent("Time Event"); - assertEquals(1, mPersistentIdentity.getTimeEvents().size()); - mMixpanelAPI.track("Time Event"); - - mMockAdapter = getMockDBAdapter(); - assertEquals("$opt_in", mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals("Time Event", mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals("Time Event", mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertNull(mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - - String[] data = mMockAdapter.generateDataString(MPDbAdapter.Table.EVENTS, TOKEN); - JSONArray pendingEventsArray = new JSONArray(data[1]); - assertEquals(3, pendingEventsArray.length()); - assertEquals("$opt_in", pendingEventsArray.getJSONObject(0).getString("event")); - assertEquals("Time Event", pendingEventsArray.getJSONObject(1).getString("event")); - assertEquals("Time Event", pendingEventsArray.getJSONObject(2).getString("event")); - assertFalse(pendingEventsArray.getJSONObject(1).getJSONObject("properties").has("$duration")); - assertTrue(pendingEventsArray.getJSONObject(2).getJSONObject("properties").has("$duration")); - - forceFlush(); - for (int i = 0; i < 3; i++) { - assertNotNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - } - assertNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); - assertEquals(0, mPersistentIdentity.getTimeEvents().size()); + for (int i = 0; i < 20; i++) { + mMixpanelAPI.track("An Event"); } - - private void forceFlush() { - mAnalyticsMessages.postToServer(new AnalyticsMessages.MixpanelDescription(TOKEN)); + for (int i = 0; i < 20; i++) { + assertEquals("An Event", mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); } - private MPDbAdapter getMockDBAdapter() { - return new MPDbAdapter(InstrumentationRegistry.getInstrumentation().getContext(), MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + mCleanUpCalls = new CountDownLatch(2); + mMixpanelAPI.optOutTracking(); + mMockAdapter = getMockDBAdapter(); + assertTrue(mCleanUpCalls.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertNull(mMockAdapter.generateDataString(MPDbAdapter.Table.EVENTS, TOKEN)); + + mMixpanelAPI.optInTracking(); + assertEquals("$opt_in", mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + forceFlush(); + assertNotNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + } + + /** Track calls before and after opting out */ + @Test + public void testTrackCalls() throws InterruptedException, JSONException { + mMixpanelAPI = + new MixpanelAPI( + InstrumentationRegistry.getInstrumentation().getContext(), + mMockReferrerPreferences, + TOKEN, + false, + null, + true) { + @Override + PersistentIdentity getPersistentIdentity( + Context context, + Future referrerPreferences, + String token, + String instanceName) { + mPersistentIdentity = + super.getPersistentIdentity(context, referrerPreferences, token, instanceName); + return mPersistentIdentity; + } + + @Override + AnalyticsMessages getAnalyticsMessages() { + return mAnalyticsMessages; + } + }; - @Override - public void cleanupAllEvents(Table table, String token) { - if (token.equalsIgnoreCase(TOKEN)) { - mCleanUpCalls.countDown(); - super.cleanupAllEvents(table, token); - } + mMixpanelAPI.timeEvent("Time Event"); + mMixpanelAPI.trackMap("Event with map", new HashMap()); + mMixpanelAPI.track("Event with properties", new JSONObject()); + assertEquals(1, mPersistentIdentity.getTimeEvents().size()); + + mCleanUpCalls = new CountDownLatch(2); + mMixpanelAPI.optOutTracking(); + assertTrue(mCleanUpCalls.await(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + mStoredEvents.clear(); + assertEquals(0, mPersistentIdentity.getTimeEvents().size()); + + mMixpanelAPI.timeEvent("Time Event"); + assertEquals(0, mPersistentIdentity.getTimeEvents().size()); + mMixpanelAPI.track("Time Event"); + + mMixpanelAPI.optInTracking(); + mMixpanelAPI.track("Time Event"); + mMixpanelAPI.timeEvent("Time Event"); + assertEquals(1, mPersistentIdentity.getTimeEvents().size()); + mMixpanelAPI.track("Time Event"); + + mMockAdapter = getMockDBAdapter(); + assertEquals("$opt_in", mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals("Time Event", mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals("Time Event", mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertNull(mStoredEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + + String[] data = mMockAdapter.generateDataString(MPDbAdapter.Table.EVENTS, TOKEN); + JSONArray pendingEventsArray = new JSONArray(data[1]); + assertEquals(3, pendingEventsArray.length()); + assertEquals("$opt_in", pendingEventsArray.getJSONObject(0).getString("event")); + assertEquals("Time Event", pendingEventsArray.getJSONObject(1).getString("event")); + assertEquals("Time Event", pendingEventsArray.getJSONObject(2).getString("event")); + assertFalse(pendingEventsArray.getJSONObject(1).getJSONObject("properties").has("$duration")); + assertTrue(pendingEventsArray.getJSONObject(2).getJSONObject("properties").has("$duration")); + + forceFlush(); + for (int i = 0; i < 3; i++) { + assertNotNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + } + assertNull(mPerformRequestEvents.poll(MAX_TIMEOUT_POLL, TimeUnit.MILLISECONDS)); + assertEquals(0, mPersistentIdentity.getTimeEvents().size()); + } + + private void forceFlush() { + mAnalyticsMessages.postToServer(new AnalyticsMessages.MixpanelDescription(TOKEN)); + } + + private MPDbAdapter getMockDBAdapter() { + return new MPDbAdapter( + InstrumentationRegistry.getInstrumentation().getContext(), + MPConfig.getInstance(InstrumentationRegistry.getInstrumentation().getContext(), null)) { + + @Override + public void cleanupAllEvents(Table table, String token) { + if (token.equalsIgnoreCase(TOKEN)) { + mCleanUpCalls.countDown(); + super.cleanupAllEvents(table, token); + } + } + + @Override + public int addJSON(JSONObject j, String token, Table table) { + int result = 1; + if (token.equalsIgnoreCase(TOKEN)) { + result = super.addJSON(j, token, table); + try { + if (Table.EVENTS == table) { + mStoredEvents.put(j.getString("event")); + } else if (Table.PEOPLE == table) { + mStoredPeopleUpdates.put(j.toString()); + } else if (Table.ANONYMOUS_PEOPLE == table) { + mStoredAnonymousPeopleUpdates.put(j.toString()); } + } catch (Exception e) { + throw new RuntimeException("Malformed data passed to test mock adapter", e); + } + } - @Override - public int addJSON(JSONObject j, String token, Table table) { - int result = 1; - if (token.equalsIgnoreCase(TOKEN)) { - result = super.addJSON(j, token, table); - try { - if (Table.EVENTS == table) { - mStoredEvents.put(j.getString("event")); - } else if (Table.PEOPLE == table) { - mStoredPeopleUpdates.put(j.toString()); - } else if (Table.ANONYMOUS_PEOPLE == table) { - mStoredAnonymousPeopleUpdates.put(j.toString()); - } - } catch (Exception e) { - throw new RuntimeException("Malformed data passed to test mock adapter", e); - } - } - - return result; - } - }; - } + return result; + } + }; + } } diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/PersistentIdentityTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/PersistentIdentityTest.java index 822b3e676..6759255b0 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/PersistentIdentityTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/PersistentIdentityTest.java @@ -1,156 +1,198 @@ package com.mixpanel.android.mpmetrics; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + import android.content.Context; import android.content.SharedPreferences; - - import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - import java.util.HashMap; import java.util.Map; import java.util.concurrent.Future; import java.util.regex.Pattern; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNotSame; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class PersistentIdentityTest { - @Before - public void setUp() { - SharedPreferences referrerPrefs = InstrumentationRegistry.getInstrumentation().getContext().getSharedPreferences(TEST_REFERRER_PREFERENCES, Context.MODE_PRIVATE); - SharedPreferences.Editor referrerEditor = referrerPrefs.edit(); - referrerEditor.clear(); - referrerEditor.putString("referrer", "REFERRER"); - referrerEditor.putString("utm_source", "SOURCE VALUE"); - referrerEditor.putString("utm_medium", "MEDIUM VALUE"); - referrerEditor.putString("utm_campaign", "CAMPAIGN NAME VALUE"); - referrerEditor.putString("utm_content", "CONTENT VALUE"); - referrerEditor.putString("utm_term", "TERM VALUE"); - referrerEditor.commit(); - - SharedPreferences testPreferences = InstrumentationRegistry.getInstrumentation().getContext().getSharedPreferences(TEST_PREFERENCES, Context.MODE_PRIVATE); - SharedPreferences.Editor prefsEditor = testPreferences.edit(); - prefsEditor.clear(); - prefsEditor.putString("events_distinct_id", "EVENTS DISTINCT ID"); - prefsEditor.putString("people_distinct_id", "PEOPLE DISTINCT ID"); - prefsEditor.putString("push_id", "PUSH ID"); - prefsEditor.putString("super_properties", "{\"thing\": \"superprops\"}"); - prefsEditor.commit(); - - SharedPreferences timeEventsPreferences = InstrumentationRegistry.getInstrumentation().getContext().getSharedPreferences(TEST_TIME_EVENTS_PREFERENCES, Context.MODE_PRIVATE); - SharedPreferences.Editor timeEventsEditor = timeEventsPreferences.edit(); - timeEventsEditor.clear(); - timeEventsEditor.commit(); - - SharedPreferencesLoader loader = new SharedPreferencesLoader(); - Future referrerLoader = loader.loadPreferences(InstrumentationRegistry.getInstrumentation().getContext(), TEST_REFERRER_PREFERENCES, null); - Future testLoader = loader.loadPreferences(InstrumentationRegistry.getInstrumentation().getContext(), TEST_PREFERENCES, null); - Future timeEventsLoader = loader.loadPreferences(InstrumentationRegistry.getInstrumentation().getContext(), TEST_TIME_EVENTS_PREFERENCES, null); - Future mixpanelLoader = loader.loadPreferences(InstrumentationRegistry.getInstrumentation().getContext(), TEST_MIXPANEL_PREFERENCES, null); - - mPersistentIdentity = new PersistentIdentity(referrerLoader, testLoader, timeEventsLoader, mixpanelLoader); - } - - @Test - public void testReferrerProperties() { - final Map props = mPersistentIdentity.getReferrerProperties(); - assertEquals("REFERRER", props.get("referrer")); - assertEquals("SOURCE VALUE", props.get("utm_source")); - assertEquals("MEDIUM VALUE", props.get("utm_medium")); - assertEquals("CAMPAIGN NAME VALUE", props.get("utm_campaign")); - assertEquals("CONTENT VALUE", props.get("utm_content")); - assertEquals("TERM VALUE", props.get("utm_term")); - - final Map newPrefs = new HashMap(); - newPrefs.put("referrer", "BJORK"); - newPrefs.put("mystery", "BOO!"); - newPrefs.put("utm_term", "NEW TERM"); - PersistentIdentity.writeReferrerPrefs(InstrumentationRegistry.getInstrumentation().getContext(), TEST_REFERRER_PREFERENCES, newPrefs); - - final Map propsAfterChange = mPersistentIdentity.getReferrerProperties(); - assertFalse(propsAfterChange.containsKey("utm_medium")); - assertFalse(propsAfterChange.containsKey("utm_source")); - assertFalse(propsAfterChange.containsKey("utm_campaign")); - assertFalse(propsAfterChange.containsKey("utm_content")); - assertEquals("BJORK", propsAfterChange.get("referrer")); - assertEquals("NEW TERM", propsAfterChange.get("utm_term")); - assertEquals("BOO!", propsAfterChange.get("mystery")); - } - - @Test - public void testUnsetEventsId() { - final SharedPreferences testPreferences = InstrumentationRegistry.getInstrumentation().getContext().getSharedPreferences(TEST_PREFERENCES, Context.MODE_PRIVATE); - testPreferences.edit().clear().commit(); - final String eventsId = mPersistentIdentity.getEventsDistinctId(); - assertTrue(Pattern.matches("^\\$device:[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$", eventsId)); - - final String autoId = testPreferences.getString("events_distinct_id", "NOPE"); - assertEquals(autoId, eventsId); - - mPersistentIdentity.setEventsDistinctId("TEST ID TO SET"); - final String heardId = mPersistentIdentity.getEventsDistinctId(); - assertEquals("TEST ID TO SET", heardId); - - final String storedId = testPreferences.getString("events_distinct_id", "NOPE"); - assertEquals("TEST ID TO SET", storedId); - } - - @Test - public void testUnsetPeopleId() { - final SharedPreferences testPreferences = InstrumentationRegistry.getInstrumentation().getContext().getSharedPreferences(TEST_PREFERENCES, Context.MODE_PRIVATE); - testPreferences.edit().clear().commit(); - final String peopleId = mPersistentIdentity.getPeopleDistinctId(); - assertNull(peopleId); - - mPersistentIdentity.setPeopleDistinctId("TEST ID TO SET"); - final String heardId = mPersistentIdentity.getPeopleDistinctId(); - assertEquals("TEST ID TO SET", heardId); - - final String storedId = testPreferences.getString("people_distinct_id", "NOPE"); - assertEquals("TEST ID TO SET", storedId); - } - - public void testGeneratedAnonymousId() { - SharedPreferences testPreferences = InstrumentationRegistry.getInstrumentation().getContext().getSharedPreferences(TEST_PREFERENCES, Context.MODE_PRIVATE); - testPreferences.edit().remove("events_distinct_id").commit(); - - final String generatedAnonymousId = mPersistentIdentity.getAnonymousId(); - assertNotNull(generatedAnonymousId); - - // before identifying the anonymous identity is equal to generated distinct_id - final String eventsDistinctId = mPersistentIdentity.getEventsDistinctId(); - assertEquals("eventsDistinctId should be same as anonymousId before identify", generatedAnonymousId, eventsDistinctId); - - mPersistentIdentity.setEventsDistinctId("identified_id"); - assertNotSame("anonymous id doesn't differ from eventsDistinctId post identify", generatedAnonymousId, mPersistentIdentity.getEventsDistinctId()); - } - - @Test - public void testHadPersistedDistinctId() { - final String eventsDistinctId = mPersistentIdentity.getEventsDistinctId(); - assertNotNull("events distinct id is not null", eventsDistinctId); - assertNull("no anonymous id yet", mPersistentIdentity.getAnonymousId()); - - mPersistentIdentity.setAnonymousIdIfAbsent("anon_id"); - - assertNotNull("anonymous id cannot be null", mPersistentIdentity.getAnonymousId()); - assertTrue("hadPersistedDistinctId cannot be false", mPersistentIdentity.getHadPersistedDistinctId()); - } - - private PersistentIdentity mPersistentIdentity; - private static final String TEST_PREFERENCES = "TEST PERSISTENT PROPERTIES PREFS"; - private static final String TEST_REFERRER_PREFERENCES = "TEST REFERRER PREFS"; - private static final String TEST_TIME_EVENTS_PREFERENCES = "TEST TIME EVENTS PREFS"; - private static final String TEST_MIXPANEL_PREFERENCES = "TEST MIXPANELPREFS"; + @Before + public void setUp() { + SharedPreferences referrerPrefs = + InstrumentationRegistry.getInstrumentation() + .getContext() + .getSharedPreferences(TEST_REFERRER_PREFERENCES, Context.MODE_PRIVATE); + SharedPreferences.Editor referrerEditor = referrerPrefs.edit(); + referrerEditor.clear(); + referrerEditor.putString("referrer", "REFERRER"); + referrerEditor.putString("utm_source", "SOURCE VALUE"); + referrerEditor.putString("utm_medium", "MEDIUM VALUE"); + referrerEditor.putString("utm_campaign", "CAMPAIGN NAME VALUE"); + referrerEditor.putString("utm_content", "CONTENT VALUE"); + referrerEditor.putString("utm_term", "TERM VALUE"); + referrerEditor.commit(); + + SharedPreferences testPreferences = + InstrumentationRegistry.getInstrumentation() + .getContext() + .getSharedPreferences(TEST_PREFERENCES, Context.MODE_PRIVATE); + SharedPreferences.Editor prefsEditor = testPreferences.edit(); + prefsEditor.clear(); + prefsEditor.putString("events_distinct_id", "EVENTS DISTINCT ID"); + prefsEditor.putString("people_distinct_id", "PEOPLE DISTINCT ID"); + prefsEditor.putString("push_id", "PUSH ID"); + prefsEditor.putString("super_properties", "{\"thing\": \"superprops\"}"); + prefsEditor.commit(); + + SharedPreferences timeEventsPreferences = + InstrumentationRegistry.getInstrumentation() + .getContext() + .getSharedPreferences(TEST_TIME_EVENTS_PREFERENCES, Context.MODE_PRIVATE); + SharedPreferences.Editor timeEventsEditor = timeEventsPreferences.edit(); + timeEventsEditor.clear(); + timeEventsEditor.commit(); + + SharedPreferencesLoader loader = new SharedPreferencesLoader(); + Future referrerLoader = + loader.loadPreferences( + InstrumentationRegistry.getInstrumentation().getContext(), + TEST_REFERRER_PREFERENCES, + null); + Future testLoader = + loader.loadPreferences( + InstrumentationRegistry.getInstrumentation().getContext(), TEST_PREFERENCES, null); + Future timeEventsLoader = + loader.loadPreferences( + InstrumentationRegistry.getInstrumentation().getContext(), + TEST_TIME_EVENTS_PREFERENCES, + null); + Future mixpanelLoader = + loader.loadPreferences( + InstrumentationRegistry.getInstrumentation().getContext(), + TEST_MIXPANEL_PREFERENCES, + null); + + mPersistentIdentity = + new PersistentIdentity(referrerLoader, testLoader, timeEventsLoader, mixpanelLoader); + } + + @Test + public void testReferrerProperties() { + final Map props = mPersistentIdentity.getReferrerProperties(); + assertEquals("REFERRER", props.get("referrer")); + assertEquals("SOURCE VALUE", props.get("utm_source")); + assertEquals("MEDIUM VALUE", props.get("utm_medium")); + assertEquals("CAMPAIGN NAME VALUE", props.get("utm_campaign")); + assertEquals("CONTENT VALUE", props.get("utm_content")); + assertEquals("TERM VALUE", props.get("utm_term")); + + final Map newPrefs = new HashMap(); + newPrefs.put("referrer", "BJORK"); + newPrefs.put("mystery", "BOO!"); + newPrefs.put("utm_term", "NEW TERM"); + PersistentIdentity.writeReferrerPrefs( + InstrumentationRegistry.getInstrumentation().getContext(), + TEST_REFERRER_PREFERENCES, + newPrefs); + + final Map propsAfterChange = mPersistentIdentity.getReferrerProperties(); + assertFalse(propsAfterChange.containsKey("utm_medium")); + assertFalse(propsAfterChange.containsKey("utm_source")); + assertFalse(propsAfterChange.containsKey("utm_campaign")); + assertFalse(propsAfterChange.containsKey("utm_content")); + assertEquals("BJORK", propsAfterChange.get("referrer")); + assertEquals("NEW TERM", propsAfterChange.get("utm_term")); + assertEquals("BOO!", propsAfterChange.get("mystery")); + } + + @Test + public void testUnsetEventsId() { + final SharedPreferences testPreferences = + InstrumentationRegistry.getInstrumentation() + .getContext() + .getSharedPreferences(TEST_PREFERENCES, Context.MODE_PRIVATE); + testPreferences.edit().clear().commit(); + final String eventsId = mPersistentIdentity.getEventsDistinctId(); + assertTrue( + Pattern.matches( + "^\\$device:[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$", + eventsId)); + + final String autoId = testPreferences.getString("events_distinct_id", "NOPE"); + assertEquals(autoId, eventsId); + + mPersistentIdentity.setEventsDistinctId("TEST ID TO SET"); + final String heardId = mPersistentIdentity.getEventsDistinctId(); + assertEquals("TEST ID TO SET", heardId); + + final String storedId = testPreferences.getString("events_distinct_id", "NOPE"); + assertEquals("TEST ID TO SET", storedId); + } + + @Test + public void testUnsetPeopleId() { + final SharedPreferences testPreferences = + InstrumentationRegistry.getInstrumentation() + .getContext() + .getSharedPreferences(TEST_PREFERENCES, Context.MODE_PRIVATE); + testPreferences.edit().clear().commit(); + final String peopleId = mPersistentIdentity.getPeopleDistinctId(); + assertNull(peopleId); + + mPersistentIdentity.setPeopleDistinctId("TEST ID TO SET"); + final String heardId = mPersistentIdentity.getPeopleDistinctId(); + assertEquals("TEST ID TO SET", heardId); + + final String storedId = testPreferences.getString("people_distinct_id", "NOPE"); + assertEquals("TEST ID TO SET", storedId); + } + + public void testGeneratedAnonymousId() { + SharedPreferences testPreferences = + InstrumentationRegistry.getInstrumentation() + .getContext() + .getSharedPreferences(TEST_PREFERENCES, Context.MODE_PRIVATE); + testPreferences.edit().remove("events_distinct_id").commit(); + + final String generatedAnonymousId = mPersistentIdentity.getAnonymousId(); + assertNotNull(generatedAnonymousId); + + // before identifying the anonymous identity is equal to generated distinct_id + final String eventsDistinctId = mPersistentIdentity.getEventsDistinctId(); + assertEquals( + "eventsDistinctId should be same as anonymousId before identify", + generatedAnonymousId, + eventsDistinctId); + + mPersistentIdentity.setEventsDistinctId("identified_id"); + assertNotSame( + "anonymous id doesn't differ from eventsDistinctId post identify", + generatedAnonymousId, + mPersistentIdentity.getEventsDistinctId()); + } + + @Test + public void testHadPersistedDistinctId() { + final String eventsDistinctId = mPersistentIdentity.getEventsDistinctId(); + assertNotNull("events distinct id is not null", eventsDistinctId); + assertNull("no anonymous id yet", mPersistentIdentity.getAnonymousId()); + + mPersistentIdentity.setAnonymousIdIfAbsent("anon_id"); + + assertNotNull("anonymous id cannot be null", mPersistentIdentity.getAnonymousId()); + assertTrue( + "hadPersistedDistinctId cannot be false", mPersistentIdentity.getHadPersistedDistinctId()); + } + + private PersistentIdentity mPersistentIdentity; + private static final String TEST_PREFERENCES = "TEST PERSISTENT PROPERTIES PREFS"; + private static final String TEST_REFERRER_PREFERENCES = "TEST REFERRER PREFS"; + private static final String TEST_TIME_EVENTS_PREFERENCES = "TEST TIME EVENTS PREFS"; + private static final String TEST_MIXPANEL_PREFERENCES = "TEST MIXPANELPREFS"; } diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/ResourceReaderTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/ResourceReaderTest.java index 73d9f2b65..6a2ac9538 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/ResourceReaderTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/ResourceReaderTest.java @@ -1,63 +1,70 @@ package com.mixpanel.android.mpmetrics; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; - import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - @RunWith(AndroidJUnit4.class) public class ResourceReaderTest { - @Before - public void setUp() { - mDrawables = new ResourceReader.Drawables(TEST_PACKAGE_NAME, InstrumentationRegistry.getInstrumentation().getContext()); - mIds = new ResourceReader.Ids(TEST_PACKAGE_NAME, InstrumentationRegistry.getInstrumentation().getContext()); - } - - @Test - public void testLocalIdExists() { - assertTrue(mDrawables.knownIdName("TEST_DRAW_ZERO")); - assertEquals(mDrawables.idFromName("TEST_DRAW_ZERO"), TEST_DRAW_ZERO); - assertEquals(mDrawables.nameForId(TEST_DRAW_ZERO), "TEST_DRAW_ZERO"); - - assertTrue(mIds.knownIdName("TEST_ID_ZERO")); - assertEquals(mIds.idFromName("TEST_ID_ZERO"), TEST_ID_ZERO); - assertEquals(mIds.nameForId(TEST_ID_ZERO), "TEST_ID_ZERO"); - } - - @Test - public void testSystemIdExists() { - assertTrue(mDrawables.knownIdName("android:ic_lock_idle_alarm")); - assertEquals(mDrawables.idFromName("android:ic_lock_idle_alarm"), android.R.drawable.ic_lock_idle_alarm); - assertEquals(mDrawables.nameForId(android.R.drawable.ic_lock_idle_alarm), "android:ic_lock_idle_alarm"); - - assertTrue(mIds.knownIdName("android:primary")); - assertEquals(mIds.idFromName("android:primary"), android.R.id.primary); - assertEquals(mIds.nameForId(android.R.id.primary), "android:primary"); - } - - @Test - public void testIdDoesntExist() { - assertFalse(mDrawables.knownIdName("NO_SUCH_ID")); - assertNull(mDrawables.nameForId(0x7f098888)); - - assertFalse(mIds.knownIdName("NO_SUCH_ID")); - assertNull(mIds.nameForId(0x7f098888)); - } - - private ResourceReader.Drawables mDrawables; - private ResourceReader.Ids mIds; - - private static final String TEST_PACKAGE_NAME = "com.mixpanel.android.mpmetrics.test_r_package"; - private static final Class RESOURCES_CLASS = com.mixpanel.android.mpmetrics.test_r_package.R.class; - private static final int TEST_ID_ZERO = com.mixpanel.android.mpmetrics.test_r_package.R.id.TEST_ID_ZERO; - private static final int TEST_DRAW_ZERO = com.mixpanel.android.mpmetrics.test_r_package.R.drawable.TEST_DRAW_ZERO; + @Before + public void setUp() { + mDrawables = + new ResourceReader.Drawables( + TEST_PACKAGE_NAME, InstrumentationRegistry.getInstrumentation().getContext()); + mIds = + new ResourceReader.Ids( + TEST_PACKAGE_NAME, InstrumentationRegistry.getInstrumentation().getContext()); + } + + @Test + public void testLocalIdExists() { + assertTrue(mDrawables.knownIdName("TEST_DRAW_ZERO")); + assertEquals(mDrawables.idFromName("TEST_DRAW_ZERO"), TEST_DRAW_ZERO); + assertEquals(mDrawables.nameForId(TEST_DRAW_ZERO), "TEST_DRAW_ZERO"); + + assertTrue(mIds.knownIdName("TEST_ID_ZERO")); + assertEquals(mIds.idFromName("TEST_ID_ZERO"), TEST_ID_ZERO); + assertEquals(mIds.nameForId(TEST_ID_ZERO), "TEST_ID_ZERO"); + } + + @Test + public void testSystemIdExists() { + assertTrue(mDrawables.knownIdName("android:ic_lock_idle_alarm")); + assertEquals( + mDrawables.idFromName("android:ic_lock_idle_alarm"), android.R.drawable.ic_lock_idle_alarm); + assertEquals( + mDrawables.nameForId(android.R.drawable.ic_lock_idle_alarm), "android:ic_lock_idle_alarm"); + + assertTrue(mIds.knownIdName("android:primary")); + assertEquals(mIds.idFromName("android:primary"), android.R.id.primary); + assertEquals(mIds.nameForId(android.R.id.primary), "android:primary"); + } + + @Test + public void testIdDoesntExist() { + assertFalse(mDrawables.knownIdName("NO_SUCH_ID")); + assertNull(mDrawables.nameForId(0x7f098888)); + + assertFalse(mIds.knownIdName("NO_SUCH_ID")); + assertNull(mIds.nameForId(0x7f098888)); + } + + private ResourceReader.Drawables mDrawables; + private ResourceReader.Ids mIds; + + private static final String TEST_PACKAGE_NAME = "com.mixpanel.android.mpmetrics.test_r_package"; + private static final Class RESOURCES_CLASS = + com.mixpanel.android.mpmetrics.test_r_package.R.class; + private static final int TEST_ID_ZERO = + com.mixpanel.android.mpmetrics.test_r_package.R.id.TEST_ID_ZERO; + private static final int TEST_DRAW_ZERO = + com.mixpanel.android.mpmetrics.test_r_package.R.drawable.TEST_DRAW_ZERO; } diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/TestUtils.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/TestUtils.java index becf18f3f..f584c3121 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/TestUtils.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/TestUtils.java @@ -4,132 +4,157 @@ import android.content.SharedPreferences; import android.os.Handler; import android.os.Message; - import java.io.UnsupportedEncodingException; import java.util.Map; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; public class TestUtils { - public static byte[] bytes(String s) { - try { - return s.getBytes("UTF-8"); - } catch (UnsupportedEncodingException e) { - throw new RuntimeException("This is not an android device, or a compatible java. WHO ARE YOU?"); - } + public static byte[] bytes(String s) { + try { + return s.getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException( + "This is not an android device, or a compatible java. WHO ARE YOU?"); + } + } + + public static class CleanMixpanelAPI extends MixpanelAPI { + public CleanMixpanelAPI( + final Context context, + final Future referrerPreferences, + final String token, + final boolean trackAutomaticEvents) { + super( + context, + referrerPreferences, + token, + MPConfig.getInstance(context, null), + new MixpanelOptions.Builder().featureFlagsEnabled(true).build(), + trackAutomaticEvents); } - public static class CleanMixpanelAPI extends MixpanelAPI { - public CleanMixpanelAPI(final Context context, final Future referrerPreferences, final String token, final boolean trackAutomaticEvents) { - super(context, referrerPreferences, token, MPConfig.getInstance(context, null), - new MixpanelOptions.Builder().featureFlagsEnabled(true).build(), trackAutomaticEvents); - } - - public CleanMixpanelAPI(final Context context, final Future referrerPreferences, final String token) { - super(context, referrerPreferences, token, MPConfig.getInstance(context, null), - new MixpanelOptions.Builder().featureFlagsEnabled(true).build(), false); - } - - public CleanMixpanelAPI(final Context context, final Future referrerPreferences, final String token, final String instanceName) { - super(context, referrerPreferences, token, false, null, instanceName, false); - } - - @Override - /* package */ PersistentIdentity getPersistentIdentity(final Context context, final Future referrerPreferences, final String token, final String instanceName) { - String instanceKey = instanceName != null ? instanceName : token; - final String prefsName = "com.mixpanel.android.mpmetrics.MixpanelAPI_" + instanceKey; - final SharedPreferences ret = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE); - ret.edit().clear().commit(); - - final String timeEventsPrefsName = "com.mixpanel.android.mpmetrics.MixpanelAPI.TimeEvents_" + instanceKey; - final SharedPreferences timeSharedPrefs = context.getSharedPreferences(timeEventsPrefsName, Context.MODE_PRIVATE); - timeSharedPrefs.edit().clear().commit(); - - final String mixpanelPrefsName = "com.mixpanel.android.mpmetrics.Mixpanel"; - final SharedPreferences mpSharedPrefs = context.getSharedPreferences(mixpanelPrefsName, Context.MODE_PRIVATE); - mpSharedPrefs.edit().clear().putBoolean(token, true).putBoolean("has_launched", true).apply(); + public CleanMixpanelAPI( + final Context context, + final Future referrerPreferences, + final String token) { + super( + context, + referrerPreferences, + token, + MPConfig.getInstance(context, null), + new MixpanelOptions.Builder().featureFlagsEnabled(true).build(), + false); + } - return super.getPersistentIdentity(context, referrerPreferences, token, instanceName); - } + public CleanMixpanelAPI( + final Context context, + final Future referrerPreferences, + final String token, + final String instanceName) { + super(context, referrerPreferences, token, false, null, instanceName, false); + } - @Override - /* package */ boolean sendAppOpen() { - return false; - } + @Override + /* package */ PersistentIdentity getPersistentIdentity( + final Context context, + final Future referrerPreferences, + final String token, + final String instanceName) { + String instanceKey = instanceName != null ? instanceName : token; + final String prefsName = "com.mixpanel.android.mpmetrics.MixpanelAPI_" + instanceKey; + final SharedPreferences ret = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE); + ret.edit().clear().commit(); + + final String timeEventsPrefsName = + "com.mixpanel.android.mpmetrics.MixpanelAPI.TimeEvents_" + instanceKey; + final SharedPreferences timeSharedPrefs = + context.getSharedPreferences(timeEventsPrefsName, Context.MODE_PRIVATE); + timeSharedPrefs.edit().clear().commit(); + + final String mixpanelPrefsName = "com.mixpanel.android.mpmetrics.Mixpanel"; + final SharedPreferences mpSharedPrefs = + context.getSharedPreferences(mixpanelPrefsName, Context.MODE_PRIVATE); + mpSharedPrefs.edit().clear().putBoolean(token, true).putBoolean("has_launched", true).apply(); + + return super.getPersistentIdentity(context, referrerPreferences, token, instanceName); } - public static class TestResourceIds implements ResourceIds { - public TestResourceIds(final Map anIdMap) { - mIdMap = anIdMap; - } + @Override + /* package */ boolean sendAppOpen() { + return false; + } + } - @Override - public boolean knownIdName(String name) { - return mIdMap.containsKey(name); - } + public static class TestResourceIds implements ResourceIds { + public TestResourceIds(final Map anIdMap) { + mIdMap = anIdMap; + } - @Override - public int idFromName(String name) { - return mIdMap.get(name); - } + @Override + public boolean knownIdName(String name) { + return mIdMap.containsKey(name); + } - @Override - public String nameForId(int id) { - for (Map.Entry entry : mIdMap.entrySet()) { - if (entry.getValue() == id) { - return entry.getKey(); - } - } + @Override + public int idFromName(String name) { + return mIdMap.get(name); + } - return null; + @Override + public String nameForId(int id) { + for (Map.Entry entry : mIdMap.entrySet()) { + if (entry.getValue() == id) { + return entry.getKey(); } + } - private final Map mIdMap; + return null; } - public static class EmptyPreferences implements Future { - public EmptyPreferences(Context context) { - mPrefs = context.getSharedPreferences("MIXPANEL_TEST_PREFERENCES", Context.MODE_PRIVATE); - mPrefs.edit().clear().commit(); - } + private final Map mIdMap; + } - @Override - public boolean cancel(final boolean mayInterruptIfRunning) { - return false; - } - - @Override - public boolean isCancelled() { - return false; - } + public static class EmptyPreferences implements Future { + public EmptyPreferences(Context context) { + mPrefs = context.getSharedPreferences("MIXPANEL_TEST_PREFERENCES", Context.MODE_PRIVATE); + mPrefs.edit().clear().commit(); + } - @Override - public boolean isDone() { - return false; - } + @Override + public boolean cancel(final boolean mayInterruptIfRunning) { + return false; + } - @Override - public SharedPreferences get() { - return mPrefs; - } + @Override + public boolean isCancelled() { + return false; + } - @Override - public SharedPreferences get(final long timeout, final TimeUnit unit) { - return mPrefs; - } + @Override + public boolean isDone() { + return false; + } - private final SharedPreferences mPrefs; + @Override + public SharedPreferences get() { + return mPrefs; } - /** - * Stub/Mock handler that just runs stuff synchronously - */ - public static class SynchronousHandler extends Handler { - @Override - public boolean sendMessageAtTime(Message msg, long uptimeMillis) { - dispatchMessage(msg); - return true; - } + @Override + public SharedPreferences get(final long timeout, final TimeUnit unit) { + return mPrefs; } + private final SharedPreferences mPrefs; + } + + /** Stub/Mock handler that just runs stuff synchronously */ + public static class SynchronousHandler extends Handler { + @Override + public boolean sendMessageAtTime(Message msg, long uptimeMillis) { + dispatchMessage(msg); + return true; + } + } } diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/test_r_package/R.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/test_r_package/R.java index bddf91d62..944b8c638 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/test_r_package/R.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/test_r_package/R.java @@ -1,14 +1,12 @@ package com.mixpanel.android.mpmetrics.test_r_package; -/** - * Fake Resources class for use in tests - */ +/** Fake Resources class for use in tests */ public class R { - public static class id { - public final static int TEST_ID_ZERO=0x7f09000e; - } + public static class id { + public static final int TEST_ID_ZERO = 0x7f09000e; + } - public static class drawable { - public final static int TEST_DRAW_ZERO=0x7f020000; - } + public static class drawable { + public static final int TEST_DRAW_ZERO = 0x7f020000; + } } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/AnalyticsMessages.java b/src/main/java/com/mixpanel/android/mpmetrics/AnalyticsMessages.java index 69b51df34..d5f35ff8d 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/AnalyticsMessages.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/AnalyticsMessages.java @@ -8,17 +8,12 @@ import android.os.Message; import android.os.Process; import android.util.DisplayMetrics; - import com.mixpanel.android.util.Base64Coder; import com.mixpanel.android.util.HttpService; import com.mixpanel.android.util.LegacyVersionUtils; import com.mixpanel.android.util.MPLog; import com.mixpanel.android.util.MixpanelNetworkErrorListener; import com.mixpanel.android.util.RemoteService; - -import org.json.JSONException; -import org.json.JSONObject; - import java.io.File; import java.io.IOException; import java.io.UnsupportedEncodingException; @@ -26,688 +21,720 @@ import java.util.HashMap; import java.util.Iterator; import java.util.Map; - import javax.net.ssl.SSLSocketFactory; +import org.json.JSONException; +import org.json.JSONObject; /** * Manage communication of events with the internal database and the Mixpanel servers. * - *

This class straddles the thread boundary between user threads and - * a logical Mixpanel thread. + *

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, MPConfig config) { - mContext = context; - mConfig = config; - mInstanceName = config.getInstanceName(); - mWorker = createWorker(); - getPoster().checkIsMixpanelBlocked(); + /** Do not call directly. You should call AnalyticsMessages.getInstance() */ + /* package */ AnalyticsMessages(final Context context, MPConfig config) { + mContext = context; + mConfig = config; + mInstanceName = config.getInstanceName(); + 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. + * @param config The MPConfig configuration settings for the AnalyticsMessages instance. + */ + public static AnalyticsMessages getInstance(final Context messageContext, MPConfig config) { + synchronized (sInstances) { + final Context appContext = messageContext.getApplicationContext(); + AnalyticsMessages ret; + String instanceName = config.getInstanceName(); + if (!sInstances.containsKey(instanceName)) { + ret = new AnalyticsMessages(appContext, config); + sInstances.put(instanceName, ret); + } else { + ret = sInstances.get(instanceName); + } + return ret; } - - protected Worker createWorker() { - return new Worker(); + } + + public void setNetworkErrorListener(MixpanelNetworkErrorListener errorListener) { + mNetworkErrorListener = errorListener; + } + + 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 PeopleDescription peopleDescription) { + final Message m = Message.obtain(); + m.what = ENQUEUE_PEOPLE; + m.obj = peopleDescription; + + mWorker.runMessage(m); + } + + // Must be thread safe. + public void groupMessage(final GroupDescription groupDescription) { + final Message m = Message.obtain(); + m.what = ENQUEUE_GROUP; + m.obj = groupDescription; + + mWorker.runMessage(m); + } + + // Must be thread safe. + public void pushAnonymousPeopleMessage( + final PushAnonymousPeopleDescription pushAnonymousPeopleDescription) { + final Message m = Message.obtain(); + m.what = PUSH_ANONYMOUS_PEOPLE_RECORDS; + m.obj = pushAnonymousPeopleDescription; + + mWorker.runMessage(m); + } + + // Must be thread safe. + public void clearAnonymousUpdatesMessage( + final MixpanelDescription clearAnonymousUpdatesDescription) { + final Message m = Message.obtain(); + m.what = CLEAR_ANONYMOUS_UPDATES; + m.obj = clearAnonymousUpdatesDescription; + + mWorker.runMessage(m); + } + + public void postToServer(final MixpanelDescription flushDescription) { + final Message m = Message.obtain(); + m.what = FLUSH_QUEUE; + m.obj = flushDescription.getToken(); + m.arg1 = 0; + + mWorker.runMessage(m); + } + + public void emptyTrackingQueues(final MixpanelDescription mixpanelDescription) { + final Message m = Message.obtain(); + m.what = EMPTY_QUEUES; + m.obj = mixpanelDescription; + + mWorker.runMessage(m); + } + + public void updateEventProperties( + final UpdateEventsPropertiesDescription updateEventsProperties) { + final Message m = Message.obtain(); + m.what = REWRITE_EVENT_PROPERTIES; + m.obj = updateEventsProperties; + + mWorker.runMessage(m); + } + + public void removeResidualImageFiles(File fileOrDirectory) { + final Message m = Message.obtain(); + m.what = REMOVE_RESIDUAL_IMAGE_FILES; + m.obj = fileOrDirectory; + 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 MPDbAdapter.getInstance(context, mConfig); + } + + protected RemoteService getPoster() { + return new HttpService(mConfig.shouldGzipRequestPayload(), mNetworkErrorListener); + } + + //////////////////////////////////////////////////// + + static class EventDescription extends MixpanelMessageDescription { + public EventDescription(String eventName, JSONObject properties, String token) { + this(eventName, properties, token, false, new JSONObject()); } - /** - * 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. - * - * @param config The MPConfig configuration settings for the AnalyticsMessages instance. - * - */ - public static AnalyticsMessages getInstance(final Context messageContext, MPConfig config) { - synchronized (sInstances) { - final Context appContext = messageContext.getApplicationContext(); - AnalyticsMessages ret; - String instanceName = config.getInstanceName(); - if (!sInstances.containsKey(instanceName)) { - ret = new AnalyticsMessages(appContext, config); - sInstances.put(instanceName, ret); - } else { - ret = sInstances.get(instanceName); - } - return ret; - } + public EventDescription( + String eventName, + JSONObject properties, + String token, + boolean isAutomatic, + JSONObject sessionMetada) { + super(token, properties); + mEventName = eventName; + mIsAutomatic = isAutomatic; + mSessionMetadata = sessionMetada; } - public void setNetworkErrorListener(MixpanelNetworkErrorListener errorListener) { - mNetworkErrorListener = errorListener; + public String getEventName() { + return mEventName; } - public void eventsMessage(final EventDescription eventDescription) { - final Message m = Message.obtain(); - m.what = ENQUEUE_EVENTS; - m.obj = eventDescription; - mWorker.runMessage(m); + public JSONObject getProperties() { + return getMessage(); } - // Must be thread safe. - public void peopleMessage(final PeopleDescription peopleDescription) { - final Message m = Message.obtain(); - m.what = ENQUEUE_PEOPLE; - m.obj = peopleDescription; - - mWorker.runMessage(m); + public JSONObject getSessionMetadata() { + return mSessionMetadata; } - // Must be thread safe. - public void groupMessage(final GroupDescription groupDescription) { - final Message m = Message.obtain(); - m.what = ENQUEUE_GROUP; - m.obj = groupDescription; - - mWorker.runMessage(m); + public boolean isAutomatic() { + return mIsAutomatic; } - // Must be thread safe. - public void pushAnonymousPeopleMessage(final PushAnonymousPeopleDescription pushAnonymousPeopleDescription) { - final Message m = Message.obtain(); - m.what = PUSH_ANONYMOUS_PEOPLE_RECORDS; - m.obj = pushAnonymousPeopleDescription; + private final String mEventName; + private final JSONObject mSessionMetadata; + private final boolean mIsAutomatic; + } - mWorker.runMessage(m); + static class PeopleDescription extends MixpanelMessageDescription { + public PeopleDescription(JSONObject message, String token) { + super(token, message); } - // Must be thread safe. - public void clearAnonymousUpdatesMessage(final MixpanelDescription clearAnonymousUpdatesDescription) { - final Message m = Message.obtain(); - m.what = CLEAR_ANONYMOUS_UPDATES; - m.obj = clearAnonymousUpdatesDescription; - - mWorker.runMessage(m); + @Override + public String toString() { + return getMessage().toString(); } - public void postToServer(final MixpanelDescription flushDescription) { - final Message m = Message.obtain(); - m.what = FLUSH_QUEUE; - m.obj = flushDescription.getToken(); - m.arg1 = 0; - - mWorker.runMessage(m); + public boolean isAnonymous() { + return !getMessage().has("$distinct_id"); } + } - public void emptyTrackingQueues(final MixpanelDescription mixpanelDescription) { - final Message m = Message.obtain(); - m.what = EMPTY_QUEUES; - m.obj = mixpanelDescription; - - mWorker.runMessage(m); + static class GroupDescription extends MixpanelMessageDescription { + public GroupDescription(JSONObject message, String token) { + super(token, message); } - public void updateEventProperties(final UpdateEventsPropertiesDescription updateEventsProperties) { - final Message m = Message.obtain(); - m.what = REWRITE_EVENT_PROPERTIES; - m.obj = updateEventsProperties; - - mWorker.runMessage(m); + @Override + public String toString() { + return getMessage().toString(); } + } - public void removeResidualImageFiles(File fileOrDirectory) { - final Message m = Message.obtain(); - m.what = REMOVE_RESIDUAL_IMAGE_FILES; - m.obj = fileOrDirectory; - mWorker.runMessage(m); + static class PushAnonymousPeopleDescription extends MixpanelDescription { + public PushAnonymousPeopleDescription(String distinctId, String token) { + super(token); + this.mDistinctId = distinctId; } - public void hardKill() { - final Message m = Message.obtain(); - m.what = KILL_WORKER; - - mWorker.runMessage(m); + @Override + public String toString() { + return this.mDistinctId; } - ///////////////////////////////////////////////////////// - // For testing, to allow for Mocking. - - /* package */ boolean isDead() { - return mWorker.isDead(); + public String getDistinctId() { + return this.mDistinctId; } - protected MPDbAdapter makeDbAdapter(Context context) { - return MPDbAdapter.getInstance(context, mConfig); + private final String mDistinctId; + } + + static class MixpanelMessageDescription extends MixpanelDescription { + public MixpanelMessageDescription(String token, JSONObject message) { + super(token); + if (message != null && message.length() > 0) { + Iterator it = message.keys(); + while (it.hasNext()) { + String jsonKey = it.next(); + try { + message.get(jsonKey).toString(); + } catch (AssertionError e) { + // see https://github.com/mixpanel/mixpanel-android/issues/567 + message.remove(jsonKey); + MPLog.e( + LOGTAG, + "Removing people profile property from update (see" + + " https://github.com/mixpanel/mixpanel-android/issues/567)", + e); + } catch (JSONException e) { + } + } + } + this.mMessage = message; } - protected RemoteService getPoster() { - return new HttpService(mConfig.shouldGzipRequestPayload(), mNetworkErrorListener); + public JSONObject getMessage() { + return mMessage; } - //////////////////////////////////////////////////// - - static class EventDescription extends MixpanelMessageDescription { - public EventDescription(String eventName, - JSONObject properties, - String token) { - this(eventName, properties, token, false, new JSONObject()); - } - - public EventDescription(String eventName, - JSONObject properties, - String token, - boolean isAutomatic, - JSONObject sessionMetada) { - super(token, properties); - mEventName = eventName; - mIsAutomatic = isAutomatic; - mSessionMetadata = sessionMetada; - } + private final JSONObject mMessage; + } - public String getEventName() { - return mEventName; - } - - public JSONObject getProperties() { - return getMessage(); - } + static class UpdateEventsPropertiesDescription extends MixpanelDescription { + private final Map mProps; - public JSONObject getSessionMetadata() { - return mSessionMetadata; - } - - public boolean isAutomatic() { - return mIsAutomatic; - } - - private final String mEventName; - private final JSONObject mSessionMetadata; - private final boolean mIsAutomatic; + public UpdateEventsPropertiesDescription(String token, Map props) { + super(token); + mProps = props; } - static class PeopleDescription extends MixpanelMessageDescription { - public PeopleDescription(JSONObject message, String token) { - super(token, message); - } - - @Override - public String toString() { - return getMessage().toString(); - } - - public boolean isAnonymous() { - return !getMessage().has("$distinct_id"); - } + public Map getProperties() { + return mProps; } + } - static class GroupDescription extends MixpanelMessageDescription { - public GroupDescription(JSONObject message, String token) { - super(token, message); - } - - @Override - public String toString() { - return getMessage().toString(); - } + static class MixpanelDescription { + public MixpanelDescription(String token) { + this.mToken = token; } - static class PushAnonymousPeopleDescription extends MixpanelDescription { - public PushAnonymousPeopleDescription(String distinctId, String token) { - super(token); - this.mDistinctId = distinctId; - } - - @Override - public String toString() { - return this.mDistinctId; - } - - public String getDistinctId() { - return this.mDistinctId; - } - - private final String mDistinctId; + public String getToken() { + return mToken; } - static class MixpanelMessageDescription extends MixpanelDescription { - public MixpanelMessageDescription(String token, JSONObject message) { - super(token); - if (message != null && message.length() > 0) { - Iterator it = message.keys(); - while (it.hasNext()) { - String jsonKey = it.next(); - try { - message.get(jsonKey).toString(); - } catch (AssertionError e) { - // see https://github.com/mixpanel/mixpanel-android/issues/567 - message.remove(jsonKey); - MPLog.e(LOGTAG, "Removing people profile property from update (see https://github.com/mixpanel/mixpanel-android/issues/567)", e); - } catch (JSONException e) {} - } - } - this.mMessage = message; - } - - public JSONObject getMessage() { - return mMessage; - } - - private final JSONObject mMessage; + private final String mToken; + } + + // 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(); } - - static class UpdateEventsPropertiesDescription extends MixpanelDescription { - private final Map mProps; - - public UpdateEventsPropertiesDescription(String token, Map props) { - super(token); - mProps = props; - } - - public Map getProperties() { - return mProps; - } + public boolean isDead() { + synchronized (mHandlerLock) { + return mHandler == null; + } } - static class MixpanelDescription { - public MixpanelDescription(String token) { - this.mToken = token; - } - - public String getToken() { - return mToken; + 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); } - - private final String mToken; - } - - // 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); + // 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", Process.THREAD_PRIORITY_BACKGROUND); + thread.start(); + return new AnalyticsMessageHandler(thread.getLooper()); } - // 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(); + class AnalyticsMessageHandler extends Handler { + public AnalyticsMessageHandler(Looper looper) { + super(looper); + mDbAdapter = null; + mSystemInformation = SystemInformation.getInstance(mContext); + mFlushInterval = mConfig.getFlushInterval(); + } + + @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); } - public boolean isDead() { - synchronized(mHandlerLock) { - return mHandler == null; + try { + int returnCode = MPDbAdapter.DB_UNDEFINED_CODE; + String token = null; + + if (msg.what == ENQUEUE_PEOPLE) { + final PeopleDescription message = (PeopleDescription) msg.obj; + final MPDbAdapter.Table peopleTable = + message.isAnonymous() + ? MPDbAdapter.Table.ANONYMOUS_PEOPLE + : MPDbAdapter.Table.PEOPLE; + + logAboutMessageToMixpanel("Queuing people record for sending later"); + logAboutMessageToMixpanel(" " + message.toString()); + token = message.getToken(); + int numRowsTable = mDbAdapter.addJSON(message.getMessage(), token, peopleTable); + returnCode = message.isAnonymous() ? 0 : numRowsTable; + } else if (msg.what == ENQUEUE_GROUP) { + final GroupDescription message = (GroupDescription) msg.obj; + + logAboutMessageToMixpanel("Queuing group record for sending later"); + logAboutMessageToMixpanel(" " + message.toString()); + token = message.getToken(); + returnCode = mDbAdapter.addJSON(message.getMessage(), token, MPDbAdapter.Table.GROUPS); + } 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()); + token = eventDescription.getToken(); + returnCode = mDbAdapter.addJSON(message, token, MPDbAdapter.Table.EVENTS); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception tracking event " + eventDescription.getEventName(), e); } - } - - 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); - } + } else if (msg.what == PUSH_ANONYMOUS_PEOPLE_RECORDS) { + final PushAnonymousPeopleDescription pushAnonymousPeopleDescription = + (PushAnonymousPeopleDescription) msg.obj; + final String distinctId = pushAnonymousPeopleDescription.getDistinctId(); + token = pushAnonymousPeopleDescription.getToken(); + returnCode = mDbAdapter.pushAnonymousUpdatesToPeopleDb(token, distinctId); + } else if (msg.what == CLEAR_ANONYMOUS_UPDATES) { + final MixpanelDescription mixpanelDescription = (MixpanelDescription) msg.obj; + token = mixpanelDescription.getToken(); + mDbAdapter.cleanupAllEvents(MPDbAdapter.Table.ANONYMOUS_PEOPLE, token); + } else if (msg.what == REWRITE_EVENT_PROPERTIES) { + final UpdateEventsPropertiesDescription description = + (UpdateEventsPropertiesDescription) msg.obj; + int updatedEvents = + mDbAdapter.rewriteEventDataWithProperties( + description.getProperties(), description.getToken()); + MPLog.d(LOGTAG, updatedEvents + " stored events were updated with new properties."); + } else if (msg.what == FLUSH_QUEUE) { + logAboutMessageToMixpanel("Flushing queue due to scheduled or forced flush"); + updateFlushFrequency(); + token = (String) msg.obj; + sendAllData(mDbAdapter, token); + } else if (msg.what == EMPTY_QUEUES) { + final MixpanelDescription message = (MixpanelDescription) msg.obj; + token = message.getToken(); + mDbAdapter.cleanupAllEvents(MPDbAdapter.Table.EVENTS, token); + mDbAdapter.cleanupAllEvents(MPDbAdapter.Table.PEOPLE, token); + mDbAdapter.cleanupAllEvents(MPDbAdapter.Table.GROUPS, token); + mDbAdapter.cleanupAllEvents(MPDbAdapter.Table.ANONYMOUS_PEOPLE, token); + } 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(); } - } - - // 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", Process.THREAD_PRIORITY_BACKGROUND); - thread.start(); - return new AnalyticsMessageHandler(thread.getLooper()); - } - - class AnalyticsMessageHandler extends Handler { - public AnalyticsMessageHandler(Looper looper) { - super(looper); - mDbAdapter = null; - mSystemInformation = SystemInformation.getInstance(mContext); - mFlushInterval = mConfig.getFlushInterval(); + } else if (msg.what == REMOVE_RESIDUAL_IMAGE_FILES) { + final File file = (File) msg.obj; + LegacyVersionUtils.removeLegacyResidualImageFiles(file); + } else { + MPLog.e(LOGTAG, "Unexpected message received by Mixpanel worker: " + msg); + } + + /////////////////////////// + if ((returnCode >= mConfig.getBulkUploadLimit() + || returnCode == MPDbAdapter.DB_OUT_OF_MEMORY_ERROR) + && mFailedRetries <= 0 + && token != null) { + logAboutMessageToMixpanel( + "Flushing queue due to bulk upload limit (" + + returnCode + + ") for project " + + token); + updateFlushFrequency(); + sendAllData(mDbAdapter, token); + } else if (returnCode > 0 && !hasMessages(FLUSH_QUEUE, token)) { + // The !hasMessages(FLUSH_QUEUE, token) 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) { + final Message flushMessage = Message.obtain(); + flushMessage.what = FLUSH_QUEUE; + flushMessage.obj = token; + flushMessage.arg1 = 1; + sendMessageDelayed(flushMessage, mFlushInterval); } - - @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; - String token = null; - - if (msg.what == ENQUEUE_PEOPLE) { - final PeopleDescription message = (PeopleDescription) msg.obj; - final MPDbAdapter.Table peopleTable = message.isAnonymous() ? MPDbAdapter.Table.ANONYMOUS_PEOPLE : MPDbAdapter.Table.PEOPLE; - - logAboutMessageToMixpanel("Queuing people record for sending later"); - logAboutMessageToMixpanel(" " + message.toString()); - token = message.getToken(); - int numRowsTable = mDbAdapter.addJSON(message.getMessage(), token, peopleTable); - returnCode = message.isAnonymous() ? 0 : numRowsTable; - } else if (msg.what == ENQUEUE_GROUP) { - final GroupDescription message = (GroupDescription) msg.obj; - - logAboutMessageToMixpanel("Queuing group record for sending later"); - logAboutMessageToMixpanel(" " + message.toString()); - token = message.getToken(); - returnCode = mDbAdapter.addJSON(message.getMessage(), token, MPDbAdapter.Table.GROUPS); - } 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()); - token = eventDescription.getToken(); - returnCode = mDbAdapter.addJSON(message, token, MPDbAdapter.Table.EVENTS); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception tracking event " + eventDescription.getEventName(), e); - } - } else if (msg.what == PUSH_ANONYMOUS_PEOPLE_RECORDS) { - final PushAnonymousPeopleDescription pushAnonymousPeopleDescription = (PushAnonymousPeopleDescription) msg.obj; - final String distinctId = pushAnonymousPeopleDescription.getDistinctId(); - token = pushAnonymousPeopleDescription.getToken(); - returnCode = mDbAdapter.pushAnonymousUpdatesToPeopleDb(token, distinctId); - } else if (msg.what == CLEAR_ANONYMOUS_UPDATES) { - final MixpanelDescription mixpanelDescription = (MixpanelDescription) msg.obj; - token = mixpanelDescription.getToken(); - mDbAdapter.cleanupAllEvents(MPDbAdapter.Table.ANONYMOUS_PEOPLE, token); - } else if (msg.what == REWRITE_EVENT_PROPERTIES) { - final UpdateEventsPropertiesDescription description = (UpdateEventsPropertiesDescription) msg.obj; - int updatedEvents = mDbAdapter.rewriteEventDataWithProperties(description.getProperties(), description.getToken()); - MPLog.d(LOGTAG, updatedEvents + " stored events were updated with new properties."); - } else if (msg.what == FLUSH_QUEUE) { - logAboutMessageToMixpanel("Flushing queue due to scheduled or forced flush"); - updateFlushFrequency(); - token = (String) msg.obj; - sendAllData(mDbAdapter, token); - } else if (msg.what == EMPTY_QUEUES) { - final MixpanelDescription message = (MixpanelDescription) msg.obj; - token = message.getToken(); - mDbAdapter.cleanupAllEvents(MPDbAdapter.Table.EVENTS, token); - mDbAdapter.cleanupAllEvents(MPDbAdapter.Table.PEOPLE, token); - mDbAdapter.cleanupAllEvents(MPDbAdapter.Table.GROUPS, token); - mDbAdapter.cleanupAllEvents(MPDbAdapter.Table.ANONYMOUS_PEOPLE, token); - } 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 if (msg.what == REMOVE_RESIDUAL_IMAGE_FILES) { - final File file = (File) msg.obj; - LegacyVersionUtils.removeLegacyResidualImageFiles(file); - } else { - MPLog.e(LOGTAG, "Unexpected message received by Mixpanel worker: " + msg); - } - - /////////////////////////// - if ((returnCode >= mConfig.getBulkUploadLimit() || returnCode == MPDbAdapter.DB_OUT_OF_MEMORY_ERROR) && mFailedRetries <= 0 && token != null) { - logAboutMessageToMixpanel("Flushing queue due to bulk upload limit (" + returnCode + ") for project " + token); - updateFlushFrequency(); - sendAllData(mDbAdapter, token); - } else if (returnCode > 0 && !hasMessages(FLUSH_QUEUE, token)) { - // The !hasMessages(FLUSH_QUEUE, token) 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) { - final Message flushMessage = Message.obtain(); - flushMessage.what = FLUSH_QUEUE; - flushMessage.obj = token; - flushMessage.arg1 = 1; - sendMessageDelayed(flushMessage, 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; + } + } 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 sendAllData(MPDbAdapter dbAdapter, String token) { + 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; + } - private void sendAllData(MPDbAdapter dbAdapter, String token) { - 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; - } - - sendData(dbAdapter, token, MPDbAdapter.Table.EVENTS, mConfig.getEventsEndpoint()); - sendData(dbAdapter, token, MPDbAdapter.Table.PEOPLE, mConfig.getPeopleEndpoint()); - sendData(dbAdapter, token, MPDbAdapter.Table.GROUPS, mConfig.getGroupsEndpoint()); - } + sendData(dbAdapter, token, MPDbAdapter.Table.EVENTS, mConfig.getEventsEndpoint()); + sendData(dbAdapter, token, MPDbAdapter.Table.PEOPLE, mConfig.getPeopleEndpoint()); + sendData(dbAdapter, token, MPDbAdapter.Table.GROUPS, mConfig.getGroupsEndpoint()); + } + + private void sendData( + MPDbAdapter dbAdapter, String token, MPDbAdapter.Table table, String url) { + final RemoteService poster = getPoster(); + String[] eventsData = dbAdapter.generateDataString(table, token); + Integer queueCount = 0; + if (eventsData != null) { + queueCount = Integer.valueOf(eventsData[2]); + } - private void sendData(MPDbAdapter dbAdapter, String token, MPDbAdapter.Table table, String url) { - final RemoteService poster = getPoster(); - String[] eventsData = dbAdapter.generateDataString(table, token); - 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 params = new HashMap(); - params.put("data", encodedData); - if (MPConfig.DEBUG) { - params.put("verbose", "1"); - } - - boolean deleteEvents = true; - byte[] response; - try { - final SSLSocketFactory socketFactory = mConfig.getSSLSocketFactory(); - response = poster.performRequest(url, mConfig.getProxyServerInteractor(), params, null, null, 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, token); - } - - logAboutMessageToMixpanel("Successfully posted to " + url + ": \n" + rawMessage); - logAboutMessageToMixpanel("Response was " + parsedResponse); - } - } catch (final OutOfMemoryError e) { - MPLog.e(LOGTAG, "Out of memory when posting to " + url + ".", e); - } catch (final MalformedURLException e) { - MPLog.e(LOGTAG, "Cannot interpret " + url + " as a URL.", e); - } catch (final RemoteService.ServiceUnavailableException e) { - logAboutMessageToMixpanel("Cannot post message to " + url + ".", e); - deleteEvents = false; - mTrackEngageRetryAfter = e.getRetryAfter() * 1000; - } 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, token); - } else { - removeMessages(FLUSH_QUEUE, token); - mTrackEngageRetryAfter = Math.max((long)Math.pow(2, mFailedRetries) * 60000, mTrackEngageRetryAfter); - mTrackEngageRetryAfter = Math.min(mTrackEngageRetryAfter, 10 * 60 * 1000); // limit 10 min - final Message flushMessage = Message.obtain(); - flushMessage.what = FLUSH_QUEUE; - flushMessage.obj = token; - sendMessageDelayed(flushMessage, mTrackEngageRetryAfter); - mFailedRetries++; - logAboutMessageToMixpanel("Retrying this batch of events in " + mTrackEngageRetryAfter + " ms"); - break; - } - - eventsData = dbAdapter.generateDataString(table, token); - 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 params = new HashMap(); + params.put("data", encodedData); + if (MPConfig.DEBUG) { + params.put("verbose", "1"); + } + + boolean deleteEvents = true; + byte[] response; + try { + final SSLSocketFactory socketFactory = mConfig.getSSLSocketFactory(); + response = + poster.performRequest( + url, mConfig.getProxyServerInteractor(), params, null, null, 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, token); + } + + logAboutMessageToMixpanel("Successfully posted to " + url + ": \n" + rawMessage); + logAboutMessageToMixpanel("Response was " + parsedResponse); } + } catch (final OutOfMemoryError e) { + MPLog.e(LOGTAG, "Out of memory when posting to " + url + ".", e); + } catch (final MalformedURLException e) { + MPLog.e(LOGTAG, "Cannot interpret " + url + " as a URL.", e); + } catch (final RemoteService.ServiceUnavailableException e) { + logAboutMessageToMixpanel("Cannot post message to " + url + ".", e); + deleteEvents = false; + mTrackEngageRetryAfter = e.getRetryAfter() * 1000; + } 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, token); + } else { + removeMessages(FLUSH_QUEUE, token); + mTrackEngageRetryAfter = + Math.max((long) Math.pow(2, mFailedRetries) * 60000, mTrackEngageRetryAfter); + mTrackEngageRetryAfter = + Math.min(mTrackEngageRetryAfter, 10 * 60 * 1000); // limit 10 min + final Message flushMessage = Message.obtain(); + flushMessage.what = FLUSH_QUEUE; + flushMessage.obj = token; + sendMessageDelayed(flushMessage, mTrackEngageRetryAfter); + mFailedRetries++; + logAboutMessageToMixpanel( + "Retrying this batch of events in " + mTrackEngageRetryAfter + " ms"); + break; + } + + eventsData = dbAdapter.generateDataString(table, token); + 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); - - final DisplayMetrics displayMetrics = mSystemInformation.getDisplayMetrics(); - ret.put("$screen_dpi", displayMetrics.densityDpi); - ret.put("$screen_height", displayMetrics.heightPixels); - ret.put("$screen_width", displayMetrics.widthPixels); + private JSONObject getDefaultEventProperties() throws JSONException { + final JSONObject ret = new JSONObject(); - final String applicationVersionName = mSystemInformation.getAppVersionName(); - if (null != applicationVersionName) { - ret.put("$app_version", applicationVersionName); - ret.put("$app_version_string", applicationVersionName); - } + ret.put("mp_lib", "android"); + ret.put("$lib_version", MPConfig.VERSION); - final Integer applicationVersionCode = mSystemInformation.getAppVersionCode(); - if (null != applicationVersionCode) { - final String applicationVersion = String.valueOf(applicationVersionCode); - ret.put("$app_release", applicationVersion); - ret.put("$app_build_number", applicationVersion); - } + // For querying together with data from other libraries + ret.put("$os", "Android"); + ret.put("$os_version", Build.VERSION.RELEASE == null ? "UNKNOWN" : Build.VERSION.RELEASE); - final Boolean hasNFC = mSystemInformation.hasNFC(); - if (null != hasNFC) - ret.put("$has_nfc", hasNFC.booleanValue()); + 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); - final Boolean hasTelephony = mSystemInformation.hasTelephony(); - if (null != hasTelephony) - ret.put("$has_telephone", hasTelephony.booleanValue()); + 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 carrier = mSystemInformation.getCurrentNetworkOperator(); - if (null != carrier && !carrier.trim().isEmpty()) - ret.put("$carrier", carrier); + final String applicationVersionName = mSystemInformation.getAppVersionName(); + if (null != applicationVersionName) { + ret.put("$app_version", applicationVersionName); + ret.put("$app_version_string", applicationVersionName); + } - final Boolean isWifi = mSystemInformation.isWifiConnected(); - if (null != isWifi) - ret.put("$wifi", isWifi.booleanValue()); + final Integer applicationVersionCode = mSystemInformation.getAppVersionCode(); + if (null != applicationVersionCode) { + final String applicationVersion = String.valueOf(applicationVersionCode); + ret.put("$app_release", applicationVersion); + ret.put("$app_build_number", applicationVersion); + } - final Boolean isBluetoothEnabled = mSystemInformation.isBluetoothEnabled(); - if (isBluetoothEnabled != null) - ret.put("$bluetooth_enabled", isBluetoothEnabled); + final Boolean hasNFC = mSystemInformation.hasNFC(); + if (null != hasNFC) ret.put("$has_nfc", hasNFC.booleanValue()); - final String bluetoothVersion = mSystemInformation.getBluetoothVersion(); - if (bluetoothVersion != null) - ret.put("$bluetooth_version", bluetoothVersion); + final Boolean hasTelephony = mSystemInformation.hasTelephony(); + if (null != hasTelephony) ret.put("$has_telephone", hasTelephony.booleanValue()); - return ret; - } + final String carrier = mSystemInformation.getCurrentNetworkOperator(); + if (null != carrier && !carrier.trim().isEmpty()) ret.put("$carrier", carrier); - 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); - eventObj.put("$mp_metadata", eventDescription.getSessionMetadata()); - return eventObj; - } + final Boolean isWifi = mSystemInformation.isWifiConnected(); + if (null != isWifi) ret.put("$wifi", isWifi.booleanValue()); - private MPDbAdapter mDbAdapter; - private final long mFlushInterval; - private long mTrackEngageRetryAfter; - private int mFailedRetries; - }// AnalyticsMessageHandler + final Boolean isBluetoothEnabled = mSystemInformation.isBluetoothEnabled(); + if (isBluetoothEnabled != null) ret.put("$bluetooth_enabled", isBluetoothEnabled); - private void updateFlushFrequency() { - final long now = System.currentTimeMillis(); - final long newFlushCount = mFlushCount + 1; + final String bluetoothVersion = mSystemInformation.getBluetoothVersion(); + if (bluetoothVersion != null) ret.put("$bluetooth_version", bluetoothVersion); - if (mLastFlushTime > 0) { - final long flushInterval = now - mLastFlushTime; - final long totalFlushTime = flushInterval + (mAveFlushFrequency * mFlushCount); - mAveFlushFrequency = totalFlushTime / newFlushCount; + return ret; + } - final long seconds = mAveFlushFrequency / 1000; - logAboutMessageToMixpanel("Average send frequency approximately " + seconds + " seconds."); - } - - mLastFlushTime = now; - mFlushCount = newFlushCount; + 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)); + } } - - 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(); + eventObj.put("event", eventDescription.getEventName()); + eventObj.put("properties", sendProperties); + eventObj.put("$mp_metadata", eventDescription.getSessionMetadata()); + return eventObj; + } + + private MPDbAdapter mDbAdapter; + private final long mFlushInterval; + 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; } - ///////////////////////////////////////////////////////// - - // Used across thread boundaries - private final Worker mWorker; - private final String mInstanceName; - protected final Context mContext; - protected final MPConfig mConfig; - protected MixpanelNetworkErrorListener mNetworkErrorListener; - - // Messages for our thread - private static final int ENQUEUE_PEOPLE = 0; // push given JSON message to people DB - private static final int ENQUEUE_EVENTS = 1; // push given JSON message to events DB - private static final int FLUSH_QUEUE = 2; // submit events, people, and groups data - private static final int ENQUEUE_GROUP = 3; // push given JSON message to groups DB - private static final int PUSH_ANONYMOUS_PEOPLE_RECORDS = 4; // push anonymous people DB updates to people 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 EMPTY_QUEUES = 6; // Remove any local (and pending to be flushed) events or people/group updates from the db - private static final int CLEAR_ANONYMOUS_UPDATES = 7; // Remove anonymous people updates from DB - private static final int REWRITE_EVENT_PROPERTIES = 8; // Update or add properties to existing queued events - private static final int REMOVE_RESIDUAL_IMAGE_FILES = 9; // Remove residual image files left from the legacy SDK versions - - private static final String LOGTAG = "MixpanelAPI.Messages"; - - private static final Map sInstances = new HashMap<>(); + 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; + private final String mInstanceName; + protected final Context mContext; + protected final MPConfig mConfig; + protected MixpanelNetworkErrorListener mNetworkErrorListener; + + // Messages for our thread + private static final int ENQUEUE_PEOPLE = 0; // push given JSON message to people DB + private static final int ENQUEUE_EVENTS = 1; // push given JSON message to events DB + private static final int FLUSH_QUEUE = 2; // submit events, people, and groups data + private static final int ENQUEUE_GROUP = 3; // push given JSON message to groups DB + private static final int PUSH_ANONYMOUS_PEOPLE_RECORDS = + 4; // push anonymous people DB updates to people 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 EMPTY_QUEUES = + 6; // Remove any local (and pending to be flushed) events or people/group updates from the db + private static final int CLEAR_ANONYMOUS_UPDATES = 7; // Remove anonymous people updates from DB + private static final int REWRITE_EVENT_PROPERTIES = + 8; // Update or add properties to existing queued events + private static final int REMOVE_RESIDUAL_IMAGE_FILES = + 9; // Remove residual image files left from the legacy SDK versions + + private static final String LOGTAG = "MixpanelAPI.Messages"; + + private static final Map sInstances = new HashMap<>(); } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/AutomaticEvents.java b/src/main/java/com/mixpanel/android/mpmetrics/AutomaticEvents.java index d126abbae..a0b2017f8 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/AutomaticEvents.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/AutomaticEvents.java @@ -1,13 +1,13 @@ package com.mixpanel.android.mpmetrics; /* package */ public class AutomaticEvents { - public static final String FIRST_OPEN = "$ae_first_open"; - public static final String SESSION = "$ae_session"; - public static final String SESSION_LENGTH = "$ae_session_length"; - public static final String TOTAL_SESSIONS = "$ae_total_app_sessions"; - public static final String TOTAL_SESSIONS_LENGTH = "$ae_total_app_session_length"; - public static final String APP_UPDATED = "$ae_updated"; - public static final String VERSION_UPDATED = "$ae_updated_version"; - public static final String APP_CRASHED = "$ae_crashed"; - public static final String APP_CRASHED_REASON = "$ae_crashed_reason"; + public static final String FIRST_OPEN = "$ae_first_open"; + public static final String SESSION = "$ae_session"; + public static final String SESSION_LENGTH = "$ae_session_length"; + public static final String TOTAL_SESSIONS = "$ae_total_app_sessions"; + public static final String TOTAL_SESSIONS_LENGTH = "$ae_total_app_session_length"; + public static final String APP_UPDATED = "$ae_updated"; + public static final String VERSION_UPDATED = "$ae_updated_version"; + public static final String APP_CRASHED = "$ae_crashed"; + public static final String APP_CRASHED_REASON = "$ae_crashed_reason"; } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/ConfigurationChecker.java b/src/main/java/com/mixpanel/android/mpmetrics/ConfigurationChecker.java index 752378d9f..c6a408bef 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/ConfigurationChecker.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/ConfigurationChecker.java @@ -4,7 +4,6 @@ import android.content.pm.PackageManager; import com.mixpanel.android.util.MPLog; - /* * Copyright 2012 Google Inc. * @@ -26,26 +25,33 @@ * on Jan 3, 2013 */ - /* package */ class ConfigurationChecker { - public static String LOGTAG = "MixpanelAPI.ConfigurationChecker"; - - public static boolean checkBasicConfiguration(Context context) { - final PackageManager packageManager = context.getPackageManager(); - final String packageName = context.getPackageName(); + public static String LOGTAG = "MixpanelAPI.ConfigurationChecker"; - if (packageManager == null || packageName == null) { - MPLog.w(LOGTAG, "Can't check configuration when using a Context with null packageManager or packageName"); - return false; - } - if (PackageManager.PERMISSION_GRANTED != packageManager.checkPermission("android.permission.INTERNET", packageName)) { - MPLog.w(LOGTAG, "Package does not have permission android.permission.INTERNET - Mixpanel will not work at all!"); - MPLog.i(LOGTAG, "You can fix this by adding the following to your AndroidManifest.xml file:\n" + - ""); - return false; - } + public static boolean checkBasicConfiguration(Context context) { + final PackageManager packageManager = context.getPackageManager(); + final String packageName = context.getPackageName(); - return true; + if (packageManager == null || packageName == null) { + MPLog.w( + LOGTAG, + "Can't check configuration when using a Context with null packageManager or packageName"); + return false; } -} \ No newline at end of file + if (PackageManager.PERMISSION_GRANTED + != packageManager.checkPermission("android.permission.INTERNET", packageName)) { + MPLog.w( + LOGTAG, + "Package does not have permission android.permission.INTERNET - Mixpanel will not work at" + + " all!"); + MPLog.i( + LOGTAG, + "You can fix this by adding the following to your AndroidManifest.xml file:\n" + + ""); + return false; + } + + return true; + } +} diff --git a/src/main/java/com/mixpanel/android/mpmetrics/ExceptionHandler.java b/src/main/java/com/mixpanel/android/mpmetrics/ExceptionHandler.java index 472ada4b4..bf13432fa 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/ExceptionHandler.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/ExceptionHandler.java @@ -1,61 +1,62 @@ package com.mixpanel.android.mpmetrics; - import org.json.JSONException; import org.json.JSONObject; public class ExceptionHandler implements Thread.UncaughtExceptionHandler { - private static final int SLEEP_TIMEOUT_MS = 400; + private static final int SLEEP_TIMEOUT_MS = 400; - private static ExceptionHandler sInstance; - private final Thread.UncaughtExceptionHandler mDefaultExceptionHandler; + private static ExceptionHandler sInstance; + private final Thread.UncaughtExceptionHandler mDefaultExceptionHandler; - public ExceptionHandler() { - mDefaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler(); - Thread.setDefaultUncaughtExceptionHandler(this); - } + public ExceptionHandler() { + mDefaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler(this); + } - public static void init() { + public static void init() { + if (sInstance == null) { + synchronized (ExceptionHandler.class) { if (sInstance == null) { - synchronized (ExceptionHandler.class) { - if (sInstance == null) { - sInstance = new ExceptionHandler(); - } - } + sInstance = new ExceptionHandler(); } + } } - - @Override - public void uncaughtException(final Thread t, final Throwable e) { - // Only one worker thread - giving priority to storing the event first and then flush - MixpanelAPI.allInstances(new MixpanelAPI.InstanceProcessor() { - @Override - public void process(MixpanelAPI mixpanel) { - if (mixpanel.getTrackAutomaticEvents()) { - try { - final JSONObject messageProp = new JSONObject(); - messageProp.put(AutomaticEvents.APP_CRASHED_REASON, e.toString()); - mixpanel.track(AutomaticEvents.APP_CRASHED, messageProp, true); - } catch (JSONException e) {} - } + } + + @Override + public void uncaughtException(final Thread t, final Throwable e) { + // Only one worker thread - giving priority to storing the event first and then flush + MixpanelAPI.allInstances( + new MixpanelAPI.InstanceProcessor() { + @Override + public void process(MixpanelAPI mixpanel) { + if (mixpanel.getTrackAutomaticEvents()) { + try { + final JSONObject messageProp = new JSONObject(); + messageProp.put(AutomaticEvents.APP_CRASHED_REASON, e.toString()); + mixpanel.track(AutomaticEvents.APP_CRASHED, messageProp, true); + } catch (JSONException e) { + } } + } }); - if (mDefaultExceptionHandler != null) { - mDefaultExceptionHandler.uncaughtException(t, e); - } else { - killProcessAndExit(); - } + if (mDefaultExceptionHandler != null) { + mDefaultExceptionHandler.uncaughtException(t, e); + } else { + killProcessAndExit(); } + } - private void killProcessAndExit() { - try { - Thread.sleep(SLEEP_TIMEOUT_MS); - } catch (InterruptedException e1) { - e1.printStackTrace(); - } - android.os.Process.killProcess(android.os.Process.myPid()); - System.exit(10); + private void killProcessAndExit() { + try { + Thread.sleep(SLEEP_TIMEOUT_MS); + } catch (InterruptedException e1) { + e1.printStackTrace(); } + android.os.Process.killProcess(android.os.Process.myPid()); + System.exit(10); + } } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagDelegate.java b/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagDelegate.java index d79a9752f..72fb0097d 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagDelegate.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagDelegate.java @@ -3,12 +3,15 @@ import org.json.JSONObject; /** - * Interface for FeatureFlagManager to retrieve necessary data and trigger actions - * from the main MixpanelAPI instance. + * Interface for FeatureFlagManager to retrieve necessary data and trigger actions from the main + * MixpanelAPI instance. */ interface FeatureFlagDelegate { - MPConfig getMPConfig(); - String getDistinctId(); - void track(String eventName, JSONObject properties); - String getToken(); -} \ No newline at end of file + MPConfig getMPConfig(); + + String getDistinctId(); + + void track(String eventName, JSONObject properties); + + String getToken(); +} diff --git a/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java b/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java index 339144116..3f8a496a7 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java @@ -5,19 +5,13 @@ import android.os.HandlerThread; import android.os.Looper; import android.os.Message; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; - import com.mixpanel.android.util.Base64Coder; import com.mixpanel.android.util.JsonUtils; 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.lang.ref.WeakReference; @@ -32,632 +26,671 @@ import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import org.json.JSONException; +import org.json.JSONObject; class FeatureFlagManager implements MixpanelAPI.Flags { - private static final String LOGTAG = "MixpanelAPI.FeatureFlagManager"; - - private final WeakReference mDelegate; - private final FlagsConfig mFlagsConfig; - private final String mFlagsEndpoint; // e.g. https://api.mixpanel.com/flags/ - private final RemoteService mHttpService; // Use RemoteService interface - private final FeatureFlagHandler mHandler; // For serializing state access and operations - private final ExecutorService mNetworkExecutor; // For performing network calls off the handler thread - private final Object mLock = new Object(); - - // --- State Variables (Protected by mHandler) --- - private volatile Map mFlags = null; - private final Set mTrackedFlags = new HashSet<>(); - private boolean mIsFetching = false; - private List> mFetchCompletionCallbacks = new ArrayList<>(); - // --- - - // Message codes for Handler - private static final int MSG_FETCH_FLAGS_IF_NEEDED = 0; - private static final int MSG_COMPLETE_FETCH = 1; - - public FeatureFlagManager( - @NonNull FeatureFlagDelegate delegate, - @NonNull RemoteService httpService, - @NonNull FlagsConfig flagsConfig - ) { - mDelegate = new WeakReference<>(delegate); - mFlagsEndpoint = delegate.getMPConfig().getFlagsEndpoint(); - mHttpService = httpService; - mFlagsConfig = flagsConfig; - - // Dedicated thread for serializing access to flags state - HandlerThread handlerThread = new HandlerThread("com.mixpanel.android.FeatureFlagManagerWorker", Thread.MIN_PRIORITY); - handlerThread.start(); - mHandler = new FeatureFlagHandler(handlerThread.getLooper()); - - // Separate executor for network requests so they don't block the state queue - mNetworkExecutor = Executors.newSingleThreadExecutor(); + private static final String LOGTAG = "MixpanelAPI.FeatureFlagManager"; + + private final WeakReference mDelegate; + private final FlagsConfig mFlagsConfig; + private final String mFlagsEndpoint; // e.g. https://api.mixpanel.com/flags/ + private final RemoteService mHttpService; // Use RemoteService interface + private final FeatureFlagHandler mHandler; // For serializing state access and operations + private final ExecutorService + mNetworkExecutor; // For performing network calls off the handler thread + private final Object mLock = new Object(); + + // --- State Variables (Protected by mHandler) --- + private volatile Map mFlags = null; + private final Set mTrackedFlags = new HashSet<>(); + private boolean mIsFetching = false; + private List> mFetchCompletionCallbacks = new ArrayList<>(); + // --- + + // Message codes for Handler + private static final int MSG_FETCH_FLAGS_IF_NEEDED = 0; + private static final int MSG_COMPLETE_FETCH = 1; + + public FeatureFlagManager( + @NonNull FeatureFlagDelegate delegate, + @NonNull RemoteService httpService, + @NonNull FlagsConfig flagsConfig) { + mDelegate = new WeakReference<>(delegate); + mFlagsEndpoint = delegate.getMPConfig().getFlagsEndpoint(); + mHttpService = httpService; + mFlagsConfig = flagsConfig; + + // Dedicated thread for serializing access to flags state + HandlerThread handlerThread = + new HandlerThread("com.mixpanel.android.FeatureFlagManagerWorker", Thread.MIN_PRIORITY); + handlerThread.start(); + mHandler = new FeatureFlagHandler(handlerThread.getLooper()); + + // Separate executor for network requests so they don't block the state queue + mNetworkExecutor = Executors.newSingleThreadExecutor(); + } + + // --- Public Methods --- + + /** Asynchronously loads flags from the Mixpanel server if they haven't been loaded yet */ + public void loadFlags() { + // Send message to the handler thread to check and potentially fetch + mHandler.sendMessage(mHandler.obtainMessage(MSG_FETCH_FLAGS_IF_NEEDED)); + } + + /** Returns true if flags are loaded and ready for synchronous access. */ + public boolean areFlagsReady() { + synchronized (mLock) { + return mFlags != null; } - - // --- Public Methods --- - - /** - * Asynchronously loads flags from the Mixpanel server if they haven't been loaded yet - */ - public void loadFlags() { - // Send message to the handler thread to check and potentially fetch - mHandler.sendMessage(mHandler.obtainMessage(MSG_FETCH_FLAGS_IF_NEEDED)); + } + + // --- Sync Flag Retrieval --- + + /** + * Gets the feature flag variant (key and value) synchronously. IMPORTANT: This method will block + * the calling thread until the value can be retrieved. It is NOT recommended to call this from + * the main UI thread if flags might not be ready. If flags are not ready (`areFlagsReady()` is + * false), it returns the fallback immediately without blocking or fetching. + * + * @param flagName The name of the feature flag. + * @param fallback The MixpanelFlagVariant instance to return if the flag is not found or not + * ready. + * @return The found MixpanelFlagVariant or the fallback. + */ + @NonNull + public MixpanelFlagVariant getVariantSync( + @NonNull final String flagName, @NonNull final MixpanelFlagVariant fallback) { + // 1. Check readiness first - don't block if flags aren't loaded. + if (!areFlagsReady()) { + MPLog.w( + LOGTAG, + "Flags not ready for getVariantSync call for '" + flagName + "'. Returning fallback."); + return fallback; } - /** - * Returns true if flags are loaded and ready for synchronous access. - */ - public boolean areFlagsReady() { - synchronized (mLock) { - return mFlags != null; - } - } - - // --- Sync Flag Retrieval --- - - /** - * Gets the feature flag variant (key and value) synchronously. - * IMPORTANT: This method will block the calling thread until the value can be - * retrieved. It is NOT recommended to call this from the main UI thread - * if flags might not be ready. If flags are not ready (`areFlagsReady()` is false), - * it returns the fallback immediately without blocking or fetching. - * - * @param flagName The name of the feature flag. - * @param fallback The MixpanelFlagVariant instance to return if the flag is not found or not ready. - * @return The found MixpanelFlagVariant or the fallback. - */ - @NonNull - public MixpanelFlagVariant getVariantSync(@NonNull final String flagName, @NonNull final MixpanelFlagVariant fallback) { - // 1. Check readiness first - don't block if flags aren't loaded. - if (!areFlagsReady()) { - MPLog.w(LOGTAG, "Flags not ready for getVariantSync call for '" + flagName + "'. Returning fallback."); - return fallback; - } - - // Use a container to get results back from the handler thread runnable - final var resultContainer = new Object() { - MixpanelFlagVariant flagVariant = null; - boolean tracked = false; + // Use a container to get results back from the handler thread runnable + final var resultContainer = + new Object() { + MixpanelFlagVariant flagVariant = null; + boolean tracked = false; }; - // 2. Execute the core logic synchronously on the handler thread - mHandler.runAndWait(() -> { - // We are now on the mHandler thread. areFlagsReady() was true, but check mFlags again for safety. - if (mFlags == null) { // Should not happen if areFlagsReady was true, but defensive check - MPLog.w(LOGTAG, "Flags became null unexpectedly in getVariantSync runnable."); - return; // Keep resultContainer.flagVariant as null - } - - MixpanelFlagVariant variant = mFlags.get(flagName); - if (variant != null) { - resultContainer.flagVariant = variant; - - // Perform atomic check-and-set for tracking directly here - // (Calls _checkAndSetTrackedFlag which runs on this thread) - resultContainer.tracked = _checkAndSetTrackedFlag(flagName); - } - // If variant is null, resultContainer.flagVariant remains null + // 2. Execute the core logic synchronously on the handler thread + mHandler.runAndWait( + () -> { + // We are now on the mHandler thread. areFlagsReady() was true, but check mFlags again for + // safety. + if (mFlags == null) { // Should not happen if areFlagsReady was true, but defensive check + MPLog.w(LOGTAG, "Flags became null unexpectedly in getVariantSync runnable."); + return; // Keep resultContainer.flagVariant as null + } + + MixpanelFlagVariant variant = mFlags.get(flagName); + if (variant != null) { + resultContainer.flagVariant = variant; + + // Perform atomic check-and-set for tracking directly here + // (Calls _checkAndSetTrackedFlag which runs on this thread) + resultContainer.tracked = _checkAndSetTrackedFlag(flagName); + } + // If variant is null, resultContainer.flagVariant remains null }); - // 3. Process results after handler block completes - - if (resultContainer.flagVariant != null) { - if (resultContainer.tracked) { - // If tracking was performed *in this call*, trigger the delegate call helper - // (This runs on the *calling* thread, but _performTrackingDelegateCall dispatches to main) - _performTrackingDelegateCall(flagName, resultContainer.flagVariant); - } - return resultContainer.flagVariant; - } else { - // Flag key not found in the loaded flags - MPLog.i(LOGTAG, "Flag '" + flagName + "' not found sync. Returning fallback."); - return fallback; - } - } - - /** - * Gets the value of a feature flag synchronously. - * IMPORTANT: See warning on getVariantSync regarding blocking and readiness checks. - * Returns fallback immediately if flags are not ready. - * - * @param flagName The name of the feature flag. - * @param fallbackValue The default value to return if the flag is missing or not ready. - * @return The flag's value (Object or null) or the fallbackValue. - */ - @Nullable - public Object getVariantValueSync(@NonNull String flagName, @Nullable Object fallbackValue) { - MixpanelFlagVariant fallbackVariant = new MixpanelFlagVariant("", fallbackValue); - MixpanelFlagVariant resultVariant = getVariantSync(flagName, fallbackVariant); - // If getVariantSync returned the *original* fallbackValue, its value is fallbackValue. - // If getVariantSync returned a *real* flag, its value is resultVariant.value. - return resultVariant.value; - } - - /** - * Checks if a feature flag is enabled synchronously (evaluates value as boolean). - * IMPORTANT: See warning on getVariantSync regarding blocking and readiness checks. - * Returns fallbackValue immediately if flags are not ready. - * - * @param flagName The name of the feature flag. - * @param fallbackValue The default boolean value if the flag is missing, not boolean, or not ready. - * @return True if the flag evaluates to true, false otherwise or if fallbackValue is returned. - */ - public boolean isEnabledSync(@NonNull String flagName, boolean fallbackValue) { - Object variantValue = getVariantValueSync(flagName, fallbackValue); - return _evaluateBooleanFlag(flagName, variantValue, fallbackValue); + // 3. Process results after handler block completes + + if (resultContainer.flagVariant != null) { + if (resultContainer.tracked) { + // If tracking was performed *in this call*, trigger the delegate call helper + // (This runs on the *calling* thread, but _performTrackingDelegateCall dispatches to main) + _performTrackingDelegateCall(flagName, resultContainer.flagVariant); + } + return resultContainer.flagVariant; + } else { + // Flag key not found in the loaded flags + MPLog.i(LOGTAG, "Flag '" + flagName + "' not found sync. Returning fallback."); + return fallback; } + } + + /** + * Gets the value of a feature flag synchronously. IMPORTANT: See warning on getVariantSync + * regarding blocking and readiness checks. Returns fallback immediately if flags are not ready. + * + * @param flagName The name of the feature flag. + * @param fallbackValue The default value to return if the flag is missing or not ready. + * @return The flag's value (Object or null) or the fallbackValue. + */ + @Nullable + public Object getVariantValueSync(@NonNull String flagName, @Nullable Object fallbackValue) { + MixpanelFlagVariant fallbackVariant = new MixpanelFlagVariant("", fallbackValue); + MixpanelFlagVariant resultVariant = getVariantSync(flagName, fallbackVariant); + // If getVariantSync returned the *original* fallbackValue, its value is fallbackValue. + // If getVariantSync returned a *real* flag, its value is resultVariant.value. + return resultVariant.value; + } + + /** + * Checks if a feature flag is enabled synchronously (evaluates value as boolean). IMPORTANT: See + * warning on getVariantSync regarding blocking and readiness checks. Returns fallbackValue + * immediately if flags are not ready. + * + * @param flagName The name of the feature flag. + * @param fallbackValue The default boolean value if the flag is missing, not boolean, or not + * ready. + * @return True if the flag evaluates to true, false otherwise or if fallbackValue is returned. + */ + public boolean isEnabledSync(@NonNull String flagName, boolean fallbackValue) { + Object variantValue = getVariantValueSync(flagName, fallbackValue); + return _evaluateBooleanFlag(flagName, variantValue, fallbackValue); + } + + /** + * Asynchronously gets the feature flag variant (key and value). If flags are not loaded, it + * triggers a fetch. Completion handler is called on the main thread. + * + * @param flagName The name of the feature flag. + * @param fallback The MixpanelFlagVariant instance to return if the flag is not found or fetch + * fails. + * @param completion The callback to receive the result. + */ + public void getVariant( + @NonNull final String flagName, + @NonNull final MixpanelFlagVariant fallback, + @NonNull final FlagCompletionCallback completion) { + // Post the core logic to the handler thread for safe state access + mHandler.post( + () -> { // Block A: Initial processing, runs serially on mHandler thread + MixpanelFlagVariant flagVariant; + boolean needsTracking; + boolean flagsAreCurrentlyReady = (mFlags != null); + + if (flagsAreCurrentlyReady) { + // --- Flags ARE Ready --- + MPLog.v(LOGTAG, "Flags ready. Checking for flag '" + flagName + "'"); + flagVariant = mFlags.get(flagName); // Read state directly (safe on handler thread) + + if (flagVariant != null) { + needsTracking = _checkAndSetTrackedFlag(flagName); // Runs on handler thread + } else { + needsTracking = false; + } - /** - * Asynchronously gets the feature flag variant (key and value). - * If flags are not loaded, it triggers a fetch. - * Completion handler is called on the main thread. - * - * @param flagName The name of the feature flag. - * @param fallback The MixpanelFlagVariant instance to return if the flag is not found or fetch fails. - * @param completion The callback to receive the result. - */ - public void getVariant( - @NonNull final String flagName, - @NonNull final MixpanelFlagVariant fallback, - @NonNull final FlagCompletionCallback completion - ) { - // Post the core logic to the handler thread for safe state access - mHandler.post(() -> { // Block A: Initial processing, runs serially on mHandler thread - MixpanelFlagVariant flagVariant; - boolean needsTracking; - boolean flagsAreCurrentlyReady = (mFlags != null); - - if (flagsAreCurrentlyReady) { - // --- Flags ARE Ready --- - MPLog.v(LOGTAG, "Flags ready. Checking for flag '" + flagName + "'"); - flagVariant = mFlags.get(flagName); // Read state directly (safe on handler thread) - - if (flagVariant != null) { - needsTracking = _checkAndSetTrackedFlag(flagName); // Runs on handler thread - } else { - needsTracking = false; - } - - MixpanelFlagVariant result = (flagVariant != null) ? flagVariant : fallback; - MPLog.v(LOGTAG, "Found flag variant (or fallback): " + result.key + " -> " + result.value); - - // Dispatch completion and potential tracking to main thread - new Handler(Looper.getMainLooper()).post(() -> { // Block B: User completion and subsequent tracking logic, runs on Main Thread - completion.onComplete(result); - if (flagVariant != null && needsTracking) { + MixpanelFlagVariant result = (flagVariant != null) ? flagVariant : fallback; + MPLog.v( + LOGTAG, "Found flag variant (or fallback): " + result.key + " -> " + result.value); + + // Dispatch completion and potential tracking to main thread + new Handler(Looper.getMainLooper()) + .post( + () -> { // Block B: User completion and subsequent tracking logic, runs on Main + // Thread + completion.onComplete(result); + if (flagVariant != null && needsTracking) { MPLog.v(LOGTAG, "Tracking needed for '" + flagName + "'."); - // _performTrackingDelegateCall handles its own main thread dispatch for the delegate. + // _performTrackingDelegateCall handles its own main thread dispatch for the + // delegate. _performTrackingDelegateCall(flagName, result); - } - }); // End Block B (Main Thread) - - - } else { - // --- Flags were NOT Ready --- - MPLog.i(LOGTAG, "Flags not ready, attempting fetch for getVariant call '" + flagName + "'..."); - _fetchFlagsIfNeeded(success -> { - // This fetch completion block itself runs on the MAIN thread (due to postCompletion in _completeFetch) - MPLog.v(LOGTAG, "Fetch completion received on main thread for '" + flagName + "'. Success: " + success); - if (success) { - // Fetch succeeded. Post BACK to the handler thread to get the flag value - // and perform tracking check now that flags are ready. - mHandler.post(() -> { // Block C: Post-fetch processing, runs on mHandler thread - MPLog.v(LOGTAG, "Processing successful fetch result for '" + flagName + "' on handler thread."); - MixpanelFlagVariant fetchedVariant = mFlags != null ? mFlags.get(flagName) : null; - boolean tracked; - if (fetchedVariant != null) { - tracked = _checkAndSetTrackedFlag(flagName); - } else { - tracked = false; - } - MixpanelFlagVariant finalResult = (fetchedVariant != null) ? fetchedVariant : fallback; - - // Dispatch final user completion and potential tracking to main thread - new Handler(Looper.getMainLooper()).post(() -> { // Block D: User completion and subsequent tracking, runs on Main Thread - completion.onComplete(finalResult); - if (fetchedVariant != null && tracked) { - _performTrackingDelegateCall(flagName, finalResult); - } - }); // End Block D (Main Thread) + } + }); // End Block B (Main Thread) + + } else { + // --- Flags were NOT Ready --- + MPLog.i( + LOGTAG, + "Flags not ready, attempting fetch for getVariant call '" + flagName + "'..."); + _fetchFlagsIfNeeded( + success -> { + // This fetch completion block itself runs on the MAIN thread (due to + // postCompletion in _completeFetch) + MPLog.v( + LOGTAG, + "Fetch completion received on main thread for '" + + flagName + + "'. Success: " + + success); + if (success) { + // Fetch succeeded. Post BACK to the handler thread to get the flag value + // and perform tracking check now that flags are ready. + mHandler.post( + () -> { // Block C: Post-fetch processing, runs on mHandler thread + MPLog.v( + LOGTAG, + "Processing successful fetch result for '" + + flagName + + "' on handler thread."); + MixpanelFlagVariant fetchedVariant = + mFlags != null ? mFlags.get(flagName) : null; + boolean tracked; + if (fetchedVariant != null) { + tracked = _checkAndSetTrackedFlag(flagName); + } else { + tracked = false; + } + MixpanelFlagVariant finalResult = + (fetchedVariant != null) ? fetchedVariant : fallback; + + // Dispatch final user completion and potential tracking to main thread + new Handler(Looper.getMainLooper()) + .post( + () -> { // Block D: User completion and subsequent tracking, runs + // on Main Thread + completion.onComplete(finalResult); + if (fetchedVariant != null && tracked) { + _performTrackingDelegateCall(flagName, finalResult); + } + }); // End Block D (Main Thread) }); // End Block C (handler thread) - } else { - // Fetch failed, just call original completion with fallback (already on main thread) - MPLog.w(LOGTAG, "Fetch failed for '" + flagName + "'. Returning fallback."); - completion.onComplete(fallback); - } + } else { + // Fetch failed, just call original completion with fallback (already on main + // thread) + MPLog.w(LOGTAG, "Fetch failed for '" + flagName + "'. Returning fallback."); + completion.onComplete(fallback); + } }); // End _fetchFlagsIfNeeded completion - // No return here needed as _fetchFlagsIfNeeded's completion handles the original callback - } + // No return here needed as _fetchFlagsIfNeeded's completion handles the original + // callback + } }); // End mHandler.post (Block A) - } + } + + /** + * Asynchronously gets the value of a feature flag. If flags are not loaded, it triggers a fetch. + * Completion handler is called on the main thread. + * + * @param flagName The name of the feature flag. + * @param fallbackValue The default value to return if the flag is missing or fetch fails. + * @param completion The callback to receive the result value (Object or null). + */ + public void getVariantValue( + @NonNull final String flagName, + @Nullable final Object fallbackValue, + @NonNull final FlagCompletionCallback completion) { + // Create a fallback MixpanelFlagVariant. Using empty key as it's not relevant here. + MixpanelFlagVariant fallbackVariant = new MixpanelFlagVariant("", fallbackValue); + // Call getVariant and extract the value in its completion handler + getVariant(flagName, fallbackVariant, result -> completion.onComplete(result.value)); + } + + /** + * Asynchronously checks if a feature flag is enabled (evaluates value as boolean). If flags are + * not loaded, it triggers a fetch. Completion handler is called on the main thread. + * + * @param flagName The name of the feature flag. + * @param fallbackValue The default boolean value if the flag is missing, not boolean, or fetch + * fails. + * @param completion The callback to receive the boolean result. + */ + public void isEnabled( + @NonNull final String flagName, + final boolean fallbackValue, + @NonNull final FlagCompletionCallback completion) { + // Call getVariantValue, using the boolean fallbackValue as the fallback too + // (this ensures if the flag is missing, evaluateBoolean gets the intended fallback) + getVariantValue( + flagName, + fallbackValue, + value -> { + // This completion runs on the main thread + boolean isEnabled = _evaluateBooleanFlag(flagName, value, fallbackValue); + completion.onComplete(isEnabled); + }); + } - /** - * Asynchronously gets the value of a feature flag. - * If flags are not loaded, it triggers a fetch. - * Completion handler is called on the main thread. - * - * @param flagName The name of the feature flag. - * @param fallbackValue The default value to return if the flag is missing or fetch fails. - * @param completion The callback to receive the result value (Object or null). - */ - public void getVariantValue( - @NonNull final String flagName, - @Nullable final Object fallbackValue, - @NonNull final FlagCompletionCallback completion - ) { - // Create a fallback MixpanelFlagVariant. Using empty key as it's not relevant here. - MixpanelFlagVariant fallbackVariant = new MixpanelFlagVariant("", fallbackValue); - // Call getVariant and extract the value in its completion handler - getVariant(flagName, fallbackVariant, result -> completion.onComplete(result.value)); + // --- Handler --- + + private class FeatureFlagHandler extends Handler { + public FeatureFlagHandler(Looper looper) { + super(looper); } + @Override + public void handleMessage(@NonNull Message msg) { + switch (msg.what) { + case MSG_FETCH_FLAGS_IF_NEEDED: + _fetchFlagsIfNeeded(null); // Assume completion passed via instance var now + break; + + case MSG_COMPLETE_FETCH: + // Extract results from the Message Bundle + Bundle data = msg.getData(); + boolean success = data.getBoolean("success"); + String responseJsonString = data.getString("responseJson"); // Can be null + String errorMessage = data.getString("errorMessage"); // Can be null + + JSONObject responseJson = null; + if (success && responseJsonString != null) { + try { + responseJson = new JSONObject(responseJsonString); + } catch (JSONException e) { + MPLog.e(LOGTAG, "Could not parse response JSON string in completeFetch", e); + success = false; // Treat parse failure as overall failure + errorMessage = "Failed to parse flags response JSON."; + } + } + if (!success && errorMessage != null) { + MPLog.w(LOGTAG, "Flag fetch failed: " + errorMessage); + } + // Call the internal completion logic + _completeFetch(success, responseJson); + break; + + default: + MPLog.e(LOGTAG, "Unknown message type " + msg.what); + } + } /** - * Asynchronously checks if a feature flag is enabled (evaluates value as boolean). - * If flags are not loaded, it triggers a fetch. - * Completion handler is called on the main thread. + * Executes a Runnable synchronously on this handler's thread. Blocks the calling thread until + * the Runnable completes. Handles being called from the handler's own thread to prevent + * deadlock. * - * @param flagName The name of the feature flag. - * @param fallbackValue The default boolean value if the flag is missing, not boolean, or fetch fails. - * @param completion The callback to receive the boolean result. + * @param r The Runnable to execute. */ - public void isEnabled( - @NonNull final String flagName, - final boolean fallbackValue, - @NonNull final FlagCompletionCallback completion - ) { - // Call getVariantValue, using the boolean fallbackValue as the fallback too - // (this ensures if the flag is missing, evaluateBoolean gets the intended fallback) - getVariantValue(flagName, fallbackValue, value -> { - // This completion runs on the main thread - boolean isEnabled = _evaluateBooleanFlag(flagName, value, fallbackValue); - completion.onComplete(isEnabled); - }); - } - - - // --- Handler --- - - private class FeatureFlagHandler extends Handler { - public FeatureFlagHandler(Looper looper) { - super(looper); - } - - @Override - public void handleMessage(@NonNull Message msg) { - switch (msg.what) { - case MSG_FETCH_FLAGS_IF_NEEDED: - _fetchFlagsIfNeeded(null); // Assume completion passed via instance var now - break; - - case MSG_COMPLETE_FETCH: - // Extract results from the Message Bundle - Bundle data = msg.getData(); - boolean success = data.getBoolean("success"); - String responseJsonString = data.getString("responseJson"); // Can be null - String errorMessage = data.getString("errorMessage"); // Can be null - - JSONObject responseJson = null; - if (success && responseJsonString != null) { - try { - responseJson = new JSONObject(responseJsonString); - } catch (JSONException e) { - MPLog.e(LOGTAG, "Could not parse response JSON string in completeFetch", e); - success = false; // Treat parse failure as overall failure - errorMessage = "Failed to parse flags response JSON."; - } - } - if (!success && errorMessage != null) { - MPLog.w(LOGTAG, "Flag fetch failed: " + errorMessage); - } - // Call the internal completion logic - _completeFetch(success, responseJson); - break; - - default: - MPLog.e(LOGTAG, "Unknown message type " + msg.what); - } - } - - /** - * Executes a Runnable synchronously on this handler's thread. - * Blocks the calling thread until the Runnable completes. - * Handles being called from the handler's own thread to prevent deadlock. - * @param r The Runnable to execute. - */ - public void runAndWait(Runnable r) { - if (Thread.currentThread() == getLooper().getThread()) { - // Already on the handler thread, run directly + public void runAndWait(Runnable r) { + if (Thread.currentThread() == getLooper().getThread()) { + // Already on the handler thread, run directly + r.run(); + } else { + // Use CountDownLatch to wait for completion + final java.util.concurrent.CountDownLatch latch = + new java.util.concurrent.CountDownLatch(1); + post( + () -> { // Post the task to the handler thread + try { r.run(); - } else { - // Use CountDownLatch to wait for completion - final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); - post(() -> { // Post the task to the handler thread - try { - r.run(); - } finally { - latch.countDown(); // Signal completion even if Runnable throws - } - }); - - // Wait for the latch - try { - latch.await(); - } catch (InterruptedException e) { - MPLog.e(LOGTAG, "Interrupted waiting for handler task", e); - Thread.currentThread().interrupt(); // Preserve interrupt status - } - } + } finally { + latch.countDown(); // Signal completion even if Runnable throws + } + }); + + // Wait for the latch + try { + latch.await(); + } catch (InterruptedException e) { + MPLog.e(LOGTAG, "Interrupted waiting for handler task", e); + Thread.currentThread().interrupt(); // Preserve interrupt status } + } } + } - // --- Internal Methods (run on Handler thread or background executor) --- - - // Runs on Handler thread - private void _fetchFlagsIfNeeded(@Nullable FlagCompletionCallback completion) { - // It calls _performFetchRequest via mNetworkExecutor if needed. - var shouldStartFetch = false; + // --- Internal Methods (run on Handler thread or background executor) --- - if (!mFlagsConfig.enabled) { - MPLog.i(LOGTAG, "Feature flags are disabled, not fetching."); - postCompletion(completion, false); - return; - } + // Runs on Handler thread + private void _fetchFlagsIfNeeded(@Nullable FlagCompletionCallback completion) { + // It calls _performFetchRequest via mNetworkExecutor if needed. + var shouldStartFetch = false; - if (!mIsFetching) { - mIsFetching = true; - shouldStartFetch = true; - if (completion != null) { - mFetchCompletionCallbacks.add(completion); - } - } else { - MPLog.d(LOGTAG, "Fetch already in progress, queueing completion handler."); - if (completion != null) { - mFetchCompletionCallbacks.add(completion); - } - } + if (!mFlagsConfig.enabled) { + MPLog.i(LOGTAG, "Feature flags are disabled, not fetching."); + postCompletion(completion, false); + return; + } - if (shouldStartFetch) { - MPLog.d(LOGTAG, "Starting flag fetch (dispatching network request)..."); - mNetworkExecutor.execute(this::_performFetchRequest); - } + if (!mIsFetching) { + mIsFetching = true; + shouldStartFetch = true; + if (completion != null) { + mFetchCompletionCallbacks.add(completion); + } + } else { + MPLog.d(LOGTAG, "Fetch already in progress, queueing completion handler."); + if (completion != null) { + mFetchCompletionCallbacks.add(completion); + } } - // Runs on Network Executor thread - /** - * Performs the actual network request on the mNetworkExecutor thread. - * Constructs the request, sends it, and posts the result (success/failure + data) - * back to the mHandler thread via MSG_COMPLETE_FETCH. - */ - private void _performFetchRequest() { - MPLog.v(LOGTAG, "Performing fetch request on thread: " + Thread.currentThread().getName()); - boolean success = false; - JSONObject responseJson = null; // To hold parsed successful response - String errorMessage = "Delegate or config not available"; // Default error - - final FeatureFlagDelegate delegate = mDelegate.get(); - if (delegate == null) { - MPLog.w(LOGTAG, "Delegate became null before network request could start."); - postResultToHandler(false, null, errorMessage); - return; - } + if (shouldStartFetch) { + MPLog.d(LOGTAG, "Starting flag fetch (dispatching network request)..."); + mNetworkExecutor.execute(this::_performFetchRequest); + } + } + + // Runs on Network Executor thread + /** + * Performs the actual network request on the mNetworkExecutor thread. Constructs the request, + * sends it, and posts the result (success/failure + data) back to the mHandler thread via + * MSG_COMPLETE_FETCH. + */ + private void _performFetchRequest() { + MPLog.v(LOGTAG, "Performing fetch request on thread: " + Thread.currentThread().getName()); + boolean success = false; + JSONObject responseJson = null; // To hold parsed successful response + String errorMessage = "Delegate or config not available"; // Default error + + final FeatureFlagDelegate delegate = mDelegate.get(); + if (delegate == null) { + MPLog.w(LOGTAG, "Delegate became null before network request could start."); + postResultToHandler(false, null, errorMessage); + return; + } - final MPConfig config = delegate.getMPConfig(); - final String distinctId = delegate.getDistinctId(); + final MPConfig config = delegate.getMPConfig(); + final String distinctId = delegate.getDistinctId(); - if (distinctId == null) { - MPLog.w(LOGTAG, "Distinct ID is null. Cannot fetch flags."); - errorMessage = "Distinct ID is null."; - postResultToHandler(false, null, errorMessage); - return; - } + if (distinctId == null) { + MPLog.w(LOGTAG, "Distinct ID is null. Cannot fetch flags."); + errorMessage = "Distinct ID is null."; + postResultToHandler(false, null, errorMessage); + return; + } + try { + // 1. Build Request Body JSON + JSONObject contextJson = new JSONObject(mFlagsConfig.context.toString()); + contextJson.put("distinct_id", distinctId); + JSONObject requestJson = new JSONObject(); + requestJson.put("context", contextJson); + String requestJsonString = requestJson.toString(); + MPLog.v(LOGTAG, "Request JSON Body: " + requestJsonString); + byte[] requestBodyBytes = requestJsonString.getBytes(StandardCharsets.UTF_8); // Get raw bytes + + // 3. Build Headers + String token = delegate.getToken(); // Assuming token is in MPConfig + if (token == null || token.trim().isEmpty()) { + throw new IOException("Mixpanel token is missing or empty."); + } + String authString = token + ":"; + String base64Auth = Base64Coder.encodeString(authString); + Map headers = new HashMap<>(); + headers.put("Authorization", "Basic " + base64Auth); + headers.put("Content-Type", "application/json; charset=utf-8"); // Explicitly set content type + + // 4. Perform Request + byte[] responseBytes = + mHttpService.performRequest( // <-- Use consolidated method + mFlagsEndpoint, + config.getProxyServerInteractor(), + null, // Pass null for params when sending raw body + headers, + requestBodyBytes, // Pass raw JSON body bytes + config.getSSLSocketFactory()); + + // 5. Process Response + if (responseBytes == null) { + errorMessage = "Received non-successful HTTP status or null response from flags endpoint."; + MPLog.w(LOGTAG, errorMessage); + } else { try { - // 1. Build Request Body JSON - JSONObject contextJson = new JSONObject(mFlagsConfig.context.toString()); - contextJson.put("distinct_id", distinctId); - JSONObject requestJson = new JSONObject(); - requestJson.put("context", contextJson); - String requestJsonString = requestJson.toString(); - MPLog.v(LOGTAG, "Request JSON Body: " + requestJsonString); - byte[] requestBodyBytes = requestJsonString.getBytes(StandardCharsets.UTF_8); // Get raw bytes - - - // 3. Build Headers - String token = delegate.getToken(); // Assuming token is in MPConfig - if (token == null || token.trim().isEmpty()) { - throw new IOException("Mixpanel token is missing or empty."); - } - String authString = token + ":"; - String base64Auth = Base64Coder.encodeString(authString); - Map headers = new HashMap<>(); - headers.put("Authorization", "Basic " + base64Auth); - headers.put("Content-Type", "application/json; charset=utf-8"); // Explicitly set content type - - // 4. Perform Request - byte[] responseBytes = mHttpService.performRequest( // <-- Use consolidated method - mFlagsEndpoint, - config.getProxyServerInteractor(), - null, // Pass null for params when sending raw body - headers, - requestBodyBytes, // Pass raw JSON body bytes - config.getSSLSocketFactory() - ); - - // 5. Process Response - if (responseBytes == null) { - errorMessage = "Received non-successful HTTP status or null response from flags endpoint."; - MPLog.w(LOGTAG, errorMessage); - } else { - try { - String responseString = new String(responseBytes, "UTF-8"); - MPLog.v(LOGTAG, "Flags response: " + responseString); - responseJson = new JSONObject(responseString); - if (responseJson.has("error")) { - errorMessage = "Mixpanel API returned error: " + responseJson.getString("error"); - MPLog.e(LOGTAG, errorMessage); - // Keep success = false - } else { - success = true; // Parsed JSON successfully and no 'error' field - } - } catch (UnsupportedEncodingException e) { - throw new RuntimeException("UTF-8 not supported on this platform?", e); // Should not happen - } catch (JSONException e) { - errorMessage = "Could not parse Mixpanel flags response"; - MPLog.e(LOGTAG, errorMessage, e); - // Keep success = false - } - } - } catch (RemoteService.ServiceUnavailableException e) { - errorMessage = "Mixpanel service unavailable"; - MPLog.w(LOGTAG, errorMessage, e); - // TODO: Implement retry logic / backoff based on e.getRetryAfter() if needed? - // For now, just fail the fetch completely for simplicity. - } catch (MalformedURLException e) { - errorMessage = "Flags endpoint URL is malformed: " + mFlagsEndpoint; - MPLog.e(LOGTAG, errorMessage, e); - } catch (IOException e) { - errorMessage = "Network error while fetching flags"; - MPLog.e(LOGTAG, errorMessage, e); + String responseString = new String(responseBytes, "UTF-8"); + MPLog.v(LOGTAG, "Flags response: " + responseString); + responseJson = new JSONObject(responseString); + if (responseJson.has("error")) { + errorMessage = "Mixpanel API returned error: " + responseJson.getString("error"); + MPLog.e(LOGTAG, errorMessage); + // Keep success = false + } else { + success = true; // Parsed JSON successfully and no 'error' field + } + } catch (UnsupportedEncodingException e) { + throw new RuntimeException( + "UTF-8 not supported on this platform?", e); // Should not happen } catch (JSONException e) { - errorMessage = "Failed to construct request JSON"; - MPLog.e(LOGTAG, errorMessage, e); - } catch (Exception e) { // Catch unexpected errors - errorMessage = "Unexpected error during flag fetch"; - MPLog.e(LOGTAG, errorMessage, e); + errorMessage = "Could not parse Mixpanel flags response"; + MPLog.e(LOGTAG, errorMessage, e); + // Keep success = false } - - // 6. Post result back to Handler thread - postResultToHandler(success, responseJson, errorMessage); + } + } catch (RemoteService.ServiceUnavailableException e) { + errorMessage = "Mixpanel service unavailable"; + MPLog.w(LOGTAG, errorMessage, e); + // TODO: Implement retry logic / backoff based on e.getRetryAfter() if needed? + // For now, just fail the fetch completely for simplicity. + } catch (MalformedURLException e) { + errorMessage = "Flags endpoint URL is malformed: " + mFlagsEndpoint; + MPLog.e(LOGTAG, errorMessage, e); + } catch (IOException e) { + errorMessage = "Network error while fetching flags"; + MPLog.e(LOGTAG, errorMessage, e); + } catch (JSONException e) { + errorMessage = "Failed to construct request JSON"; + MPLog.e(LOGTAG, errorMessage, e); + } catch (Exception e) { // Catch unexpected errors + errorMessage = "Unexpected error during flag fetch"; + MPLog.e(LOGTAG, errorMessage, e); } - /** - * Helper to dispatch the result of the fetch back to the handler thread. - */ - private void postResultToHandler(boolean success, @Nullable JSONObject responseJson, @Nullable String errorMessage) { - // Use a Bundle to pass multiple arguments efficiently - android.os.Bundle resultData = new android.os.Bundle(); - resultData.putBoolean("success", success); - if (success && responseJson != null) { - resultData.putString("responseJson", responseJson.toString()); - } else if (!success && errorMessage != null) { - resultData.putString("errorMessage", errorMessage); - } - - Message msg = mHandler.obtainMessage(MSG_COMPLETE_FETCH); - msg.setData(resultData); - mHandler.sendMessage(msg); + // 6. Post result back to Handler thread + postResultToHandler(success, responseJson, errorMessage); + } + + /** Helper to dispatch the result of the fetch back to the handler thread. */ + private void postResultToHandler( + boolean success, @Nullable JSONObject responseJson, @Nullable String errorMessage) { + // Use a Bundle to pass multiple arguments efficiently + android.os.Bundle resultData = new android.os.Bundle(); + resultData.putBoolean("success", success); + if (success && responseJson != null) { + resultData.putString("responseJson", responseJson.toString()); + } else if (!success && errorMessage != null) { + resultData.putString("errorMessage", errorMessage); } - // Runs on Handler thread - /** - * Centralized fetch completion logic. Runs on the Handler thread. - * Updates state and calls completion handlers. - * - * @param success Whether the fetch was successful. - * @param flagsResponseJson The parsed JSON object from the response, or null if fetch failed or parsing failed. - */ - @VisibleForTesting - void _completeFetch(boolean success, @Nullable JSONObject flagsResponseJson) { - MPLog.d(LOGTAG, "Completing fetch request. Success: " + success); - // State updates MUST happen on the handler thread implicitly - mIsFetching = false; - - List> callbacksToCall = mFetchCompletionCallbacks; - mFetchCompletionCallbacks = new ArrayList<>(); - - if (success && flagsResponseJson != null) { - Map newFlags = JsonUtils.parseFlagsResponse(flagsResponseJson); - synchronized (mLock) { - mFlags = Collections.unmodifiableMap(newFlags); - } - MPLog.v(LOGTAG, "Flags updated: " + mFlags.size() + " flags loaded."); - } else { - MPLog.w(LOGTAG, "Flag fetch failed or response missing/invalid. Keeping existing flags (if any)."); - } - - // Call handlers outside the state update logic, dispatch to main thread - if (!callbacksToCall.isEmpty()) { - MPLog.d(LOGTAG, "Calling " + callbacksToCall.size() + " fetch completion handlers."); - for(FlagCompletionCallback callback : callbacksToCall) { - postCompletion(callback, success); - } - } else { - MPLog.d(LOGTAG, "No fetch completion handlers to call."); - } + Message msg = mHandler.obtainMessage(MSG_COMPLETE_FETCH); + msg.setData(resultData); + mHandler.sendMessage(msg); + } + + // Runs on Handler thread + /** + * Centralized fetch completion logic. Runs on the Handler thread. Updates state and calls + * completion handlers. + * + * @param success Whether the fetch was successful. + * @param flagsResponseJson The parsed JSON object from the response, or null if fetch failed or + * parsing failed. + */ + @VisibleForTesting + void _completeFetch(boolean success, @Nullable JSONObject flagsResponseJson) { + MPLog.d(LOGTAG, "Completing fetch request. Success: " + success); + // State updates MUST happen on the handler thread implicitly + mIsFetching = false; + + List> callbacksToCall = mFetchCompletionCallbacks; + mFetchCompletionCallbacks = new ArrayList<>(); + + if (success && flagsResponseJson != null) { + Map newFlags = JsonUtils.parseFlagsResponse(flagsResponseJson); + synchronized (mLock) { + mFlags = Collections.unmodifiableMap(newFlags); + } + MPLog.v(LOGTAG, "Flags updated: " + mFlags.size() + " flags loaded."); + } else { + MPLog.w( + LOGTAG, + "Flag fetch failed or response missing/invalid. Keeping existing flags (if any)."); } - /** - * Atomically checks if a feature flag has been tracked and marks it as tracked if not. - * MUST be called from the mHandler thread. - * - * @param flagName The name of the feature flag. - * @return true if the flag was NOT previously tracked (and was therefore marked now), false otherwise. - */ - private boolean _checkAndSetTrackedFlag(@NonNull String flagName) { - // Already running on the handler thread, direct access is safe and serialized - if (!mTrackedFlags.contains(flagName)) { - mTrackedFlags.add(flagName); - return true; // Needs tracking - } - return false; // Already tracked + // Call handlers outside the state update logic, dispatch to main thread + if (!callbacksToCall.isEmpty()) { + MPLog.d(LOGTAG, "Calling " + callbacksToCall.size() + " fetch completion handlers."); + for (FlagCompletionCallback callback : callbacksToCall) { + postCompletion(callback, success); + } + } else { + MPLog.d(LOGTAG, "No fetch completion handlers to call."); + } + } + + /** + * Atomically checks if a feature flag has been tracked and marks it as tracked if not. MUST be + * called from the mHandler thread. + * + * @param flagName The name of the feature flag. + * @return true if the flag was NOT previously tracked (and was therefore marked now), false + * otherwise. + */ + private boolean _checkAndSetTrackedFlag(@NonNull String flagName) { + // Already running on the handler thread, direct access is safe and serialized + if (!mTrackedFlags.contains(flagName)) { + mTrackedFlags.add(flagName); + return true; // Needs tracking + } + return false; // Already tracked + } + + /** + * Constructs the $experiment_started event properties and dispatches the track call to the main + * thread via the delegate. This method itself does NOT need to run on the handler thread, but is + * typically called after a check that runs on the handler thread (_trackFeatureIfNeeded). + * + * @param flagName Name of the feature flag. + * @param variant The specific variant received. + */ + private void _performTrackingDelegateCall(String flagName, MixpanelFlagVariant variant) { + final FeatureFlagDelegate delegate = mDelegate.get(); + if (delegate == null) { + MPLog.w(LOGTAG, "Delegate is null, cannot track $experiment_started."); + return; } - /** - * Constructs the $experiment_started event properties and dispatches - * the track call to the main thread via the delegate. - * This method itself does NOT need to run on the handler thread, but is typically - * called after a check that runs on the handler thread (_trackFeatureIfNeeded). - * - * @param flagName Name of the feature flag. - * @param variant The specific variant received. - */ - private void _performTrackingDelegateCall(String flagName, MixpanelFlagVariant variant) { - final FeatureFlagDelegate delegate = mDelegate.get(); - if (delegate == null) { - MPLog.w(LOGTAG, "Delegate is null, cannot track $experiment_started."); - return; - } - - // Construct properties - JSONObject properties = new JSONObject(); - try { - properties.put("Experiment name", flagName); - properties.put("Variant name", variant.key); // Use the variant key - properties.put("$experiment_type", "feature_flag"); - } catch (JSONException e) { - MPLog.e(LOGTAG, "Failed to create JSON properties for $experiment_started event", e); - return; // Don't track if properties failed - } + // Construct properties + JSONObject properties = new JSONObject(); + try { + properties.put("Experiment name", flagName); + properties.put("Variant name", variant.key); // Use the variant key + properties.put("$experiment_type", "feature_flag"); + } catch (JSONException e) { + MPLog.e(LOGTAG, "Failed to create JSON properties for $experiment_started event", e); + return; // Don't track if properties failed + } - MPLog.v(LOGTAG, "Queueing $experiment_started event for dispatch: " + properties.toString()); + MPLog.v(LOGTAG, "Queueing $experiment_started event for dispatch: " + properties.toString()); - // Dispatch delegate call asynchronously to main thread for safety - new Handler(Looper.getMainLooper()).post(() -> { - // Re-fetch delegate inside main thread runnable just in case? Usually not necessary. - final FeatureFlagDelegate currentDelegate = mDelegate.get(); - if (currentDelegate != null) { + // Dispatch delegate call asynchronously to main thread for safety + new Handler(Looper.getMainLooper()) + .post( + () -> { + // Re-fetch delegate inside main thread runnable just in case? Usually not necessary. + final FeatureFlagDelegate currentDelegate = mDelegate.get(); + if (currentDelegate != null) { currentDelegate.track("$experiment_started", properties); - MPLog.v(LOGTAG, "Tracked $experiment_started for " + flagName + " (dispatched to main)"); - } else { + MPLog.v( + LOGTAG, + "Tracked $experiment_started for " + flagName + " (dispatched to main)"); + } else { MPLog.w(LOGTAG, "Delegate was null when track call executed on main thread."); - } - }); + } + }); + } + + // Helper to post completion callbacks to the main thread + private void postCompletion( + @Nullable final FlagCompletionCallback callback, final T result) { + if (callback != null) { + new Handler(Looper.getMainLooper()).post(() -> callback.onComplete(result)); } + } - // Helper to post completion callbacks to the main thread - private void postCompletion(@Nullable final FlagCompletionCallback callback, final T result) { - if (callback != null) { - new Handler(Looper.getMainLooper()).post(() -> callback.onComplete(result)); - } - } - - // --- Boolean Evaluation Helper --- - private boolean _evaluateBooleanFlag(String flagName, Object variantValue, boolean fallbackValue) { - if (variantValue instanceof Boolean) { - return (Boolean) variantValue; - } - MPLog.w(LOGTAG,"Flag value for " + flagName + " not boolean: " + variantValue); - return fallbackValue; + // --- Boolean Evaluation Helper --- + private boolean _evaluateBooleanFlag( + String flagName, Object variantValue, boolean fallbackValue) { + if (variantValue instanceof Boolean) { + return (Boolean) variantValue; } -} \ No newline at end of file + MPLog.w(LOGTAG, "Flag value for " + flagName + " not boolean: " + variantValue); + return fallbackValue; + } +} diff --git a/src/main/java/com/mixpanel/android/mpmetrics/FlagCompletionCallback.java b/src/main/java/com/mixpanel/android/mpmetrics/FlagCompletionCallback.java index a96e020e1..5bc767df6 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/FlagCompletionCallback.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/FlagCompletionCallback.java @@ -1,5 +1,5 @@ package com.mixpanel.android.mpmetrics; public interface FlagCompletionCallback { - void onComplete(T result); + void onComplete(T result); } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/FlagsConfig.java b/src/main/java/com/mixpanel/android/mpmetrics/FlagsConfig.java index 0bf7e26bc..8332ac391 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/FlagsConfig.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/FlagsConfig.java @@ -1,28 +1,24 @@ package com.mixpanel.android.mpmetrics; import androidx.annotation.NonNull; - import org.json.JSONObject; -import java.util.HashMap; -import java.util.Map; - class FlagsConfig { - public final boolean enabled; - @NonNull public final JSONObject context; + public final boolean enabled; + @NonNull public final JSONObject context; - public FlagsConfig() { - this.enabled = false; - this.context = new JSONObject(); - } + public FlagsConfig() { + this.enabled = false; + this.context = new JSONObject(); + } - public FlagsConfig(boolean enabled) { - this.enabled = enabled; - this.context = new JSONObject(); - } + public FlagsConfig(boolean enabled) { + this.enabled = enabled; + this.context = new JSONObject(); + } - public FlagsConfig(boolean enabled, @NonNull JSONObject context) { - this.enabled = enabled; - this.context = context; - } + public FlagsConfig(boolean enabled, @NonNull JSONObject context) { + this.enabled = enabled; + this.context = context; + } } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MPConfig.java b/src/main/java/com/mixpanel/android/mpmetrics/MPConfig.java index 315cf5f80..5a43dbb67 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/MPConfig.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/MPConfig.java @@ -5,521 +5,613 @@ import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.os.Bundle; - import androidx.annotation.Nullable; import com.mixpanel.android.BuildConfig; import com.mixpanel.android.util.MPConstants; import com.mixpanel.android.util.MPLog; -import com.mixpanel.android.util.ProxyServerInteractor; import com.mixpanel.android.util.OfflineMode; - +import com.mixpanel.android.util.ProxyServerInteractor; import java.security.GeneralSecurityException; -import java.util.HashMap; -import java.util.Map; - import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocketFactory; - /** - * Stores global configuration options for the Mixpanel library. You can enable and disable configuration - * options using <meta-data> tags inside of the <application> tag in your AndroidManifest.xml. - * All settings are optional, and default to reasonable recommended values. Most users will not have to - * set any options. + * Stores global configuration options for the Mixpanel library. You can enable and disable + * configuration options using <meta-data> tags inside of the <application> tag in your + * AndroidManifest.xml. All settings are optional, and default to reasonable recommended values. + * Most users will not have to set any options. * - * Mixpanel understands the following options: + *

Mixpanel understands the following options: * *

- *
com.mixpanel.android.MPConfig.EnableDebugLogging
- *
A boolean value. If true, emit more detailed log messages. Defaults to false
- * - *
com.mixpanel.android.MPConfig.BulkUploadLimit
- *
An integer count of messages, the maximum number of messages to queue before an upload attempt. This value should be less than 50.
- * - *
com.mixpanel.android.MPConfig.FlushInterval
- *
An integer number of milliseconds, the maximum time to wait before an upload if the bulk upload limit isn't reached.
- * - *
com.mixpanel.android.MPConfig.FlushBatchSize
- *
Maximum number of events/updates to send in a single network request
- * - *
com.mixpanel.android.MPConfig.FlushOnBackground
- *
A boolean value. If false, the library will not flush the event and people queues when the app goes into the background. Defaults to true.
- * - *
com.mixpanel.android.MPConfig.DebugFlushInterval
- *
An integer number of milliseconds, the maximum time to wait before an upload if the bulk upload limit isn't reached in debug mode.
- * - *
com.mixpanel.android.MPConfig.DataExpiration
- *
An integer number of milliseconds, the maximum age of records to send to Mixpanel. Corresponds to Mixpanel's server-side limit on record age.
- * - *
com.mixpanel.android.MPConfig.MinimumDatabaseLimit
- *
An integer number of bytes. Mixpanel attempts to limit the size of its persistent data - * queue based on the storage capacity of the device, but will always allow queuing below this limit. Higher values - * will take up more storage even when user storage is very full.
- * - *
com.mixpanel.android.MPConfig.MaximumDatabaseLimit
- *
An integer number of bytes, the maximum size limit to the Mixpanel database.
- * - *
com.mixpanel.android.MPConfig.ResourcePackageName
- *
A string java package name. Defaults to the package name of the Application. Users should set if the package name of their R class is different from the application package name due to application id settings.
- * - *
com.mixpanel.android.MPConfig.DisableAppOpenEvent
- *
A boolean value. If true, do not send an "$app_open" event when the MixpanelAPI object is created for the first time. Defaults to true - the $app_open event will not be sent by default.
- * - *
com.mixpanel.android.MPConfig.DisableExceptionHandler
- *
A boolean value. If true, do not automatically capture app crashes. "App Crashed" events won't show up on Mixpanel. Defaults to false.
- * - *
com.mixpanel.android.MPConfig.EventsEndpoint
- *
A string URL. If present, the library will attempt to send events to this endpoint rather than to the default Mixpanel endpoint.
- * - *
com.mixpanel.android.MPConfig.PeopleEndpoint
- *
A string URL. If present, the library will attempt to send people updates to this endpoint rather than to the default Mixpanel endpoint.
- * - *
com.mixpanel.android.MPConfig.GroupsEndpoint
- *
A string URL. If present, the library will attempt to send group updates to this endpoint rather than to the default Mixpanel endpoint.
- * - *
com.mixpanel.android.MPConfig.FlagsEndpoint
- *
A string URL. If present, the library will attempt to fetch feature flags from this endpoint rather than to the default Mixpanel endpoint.
- * - *
com.mixpanel.android.MPConfig.MinimumSessionDuration
- *
An integer number. The minimum session duration (ms) that is tracked in automatic events. Defaults to 10000 (10 seconds).
- * - *
com.mixpanel.android.MPConfig.SessionTimeoutDuration
- *
An integer number. The maximum session duration (ms) that is tracked in automatic events. Defaults to Integer.MAX_VALUE (no maximum session duration).
- * - *
com.mixpanel.android.MPConfig.UseIpAddressForGeolocation
- *
A boolean value. If true, Mixpanel will automatically determine city, region and country data using the IP address of the client.Defaults to true.
- * - *
com.mixpanel.android.MPConfig.RemoveLegacyResidualFiles
- *
A boolean value. If true, Mixpanel will remove the residual files from legacy versions such as images produced by deprecated Messages and Experiment features. Defaults to false.
+ *
com.mixpanel.android.MPConfig.EnableDebugLogging + *
A boolean value. If true, emit more detailed log messages. Defaults to false + *
com.mixpanel.android.MPConfig.BulkUploadLimit + *
An integer count of messages, the maximum number of messages to queue before an upload + * attempt. This value should be less than 50. + *
com.mixpanel.android.MPConfig.FlushInterval + *
An integer number of milliseconds, the maximum time to wait before an upload if the bulk + * upload limit isn't reached. + *
com.mixpanel.android.MPConfig.FlushBatchSize + *
Maximum number of events/updates to send in a single network request + *
com.mixpanel.android.MPConfig.FlushOnBackground + *
A boolean value. If false, the library will not flush the event and people queues when the + * app goes into the background. Defaults to true. + *
com.mixpanel.android.MPConfig.DebugFlushInterval + *
An integer number of milliseconds, the maximum time to wait before an upload if the bulk + * upload limit isn't reached in debug mode. + *
com.mixpanel.android.MPConfig.DataExpiration + *
An integer number of milliseconds, the maximum age of records to send to Mixpanel. + * Corresponds to Mixpanel's server-side limit on record age. + *
com.mixpanel.android.MPConfig.MinimumDatabaseLimit + *
An integer number of bytes. Mixpanel attempts to limit the size of its persistent data + * queue based on the storage capacity of the device, but will always allow queuing below this + * limit. Higher values will take up more storage even when user storage is very full. + *
com.mixpanel.android.MPConfig.MaximumDatabaseLimit + *
An integer number of bytes, the maximum size limit to the Mixpanel database. + *
com.mixpanel.android.MPConfig.ResourcePackageName + *
A string java package name. Defaults to the package name of the Application. Users should + * set if the package name of their R class is different from the application package name due + * to application id settings. + *
com.mixpanel.android.MPConfig.DisableAppOpenEvent + *
A boolean value. If true, do not send an "$app_open" event when the MixpanelAPI object is + * created for the first time. Defaults to true - the $app_open event will not be sent by + * default. + *
com.mixpanel.android.MPConfig.DisableExceptionHandler + *
A boolean value. If true, do not automatically capture app crashes. "App Crashed" events + * won't show up on Mixpanel. Defaults to false. + *
com.mixpanel.android.MPConfig.EventsEndpoint + *
A string URL. If present, the library will attempt to send events to this endpoint rather + * than to the default Mixpanel endpoint. + *
com.mixpanel.android.MPConfig.PeopleEndpoint + *
A string URL. If present, the library will attempt to send people updates to this endpoint + * rather than to the default Mixpanel endpoint. + *
com.mixpanel.android.MPConfig.GroupsEndpoint + *
A string URL. If present, the library will attempt to send group updates to this endpoint + * rather than to the default Mixpanel endpoint. + *
com.mixpanel.android.MPConfig.FlagsEndpoint + *
A string URL. If present, the library will attempt to fetch feature flags from this + * endpoint rather than to the default Mixpanel endpoint. + *
com.mixpanel.android.MPConfig.MinimumSessionDuration + *
An integer number. The minimum session duration (ms) that is tracked in automatic events. + * Defaults to 10000 (10 seconds). + *
com.mixpanel.android.MPConfig.SessionTimeoutDuration + *
An integer number. The maximum session duration (ms) that is tracked in automatic events. + * Defaults to Integer.MAX_VALUE (no maximum session duration). + *
com.mixpanel.android.MPConfig.UseIpAddressForGeolocation + *
A boolean value. If true, Mixpanel will automatically determine city, region and country + * data using the IP address of the client.Defaults to true. + *
com.mixpanel.android.MPConfig.RemoveLegacyResidualFiles + *
A boolean value. If true, Mixpanel will remove the residual files from legacy versions such + * as images produced by deprecated Messages and Experiment features. Defaults to false. *
- * */ public class MPConfig { - public static final String VERSION = BuildConfig.MIXPANEL_VERSION; - - public static boolean DEBUG = false; - - // Name for persistent storage of app referral SharedPreferences - /* package */ static final String REFERRER_PREFS_NAME = "com.mixpanel.android.mpmetrics.ReferralInfo"; - - /** - * Retrieves a new instance of MPConfig with configuration settings loaded from the provided context. - * This method creates a new instance each time it is called, allowing for multiple configurations - * within the same application. - * - * Since version 7.4.0, MPConfig is no longer a Singleton, in favor of supporting multiple, - * distinct configurations for different Mixpanel instances. This change allows greater flexibility - * in scenarios where different parts of an application require different Mixpanel configurations, - * such as different endpoints or settings. - * - * It's important for users of this method to manage the lifecycle of the returned MPConfig instances - * themselves. Each call will result in a new configuration instance based on the application's - * metadata, and it's the responsibility of the caller to maintain any necessary references to these - * instances to use them later in their application. - * - * @param context The context used to load Mixpanel configuration. It's recommended to provide - * an ApplicationContext to avoid potential memory leaks. - * @return A new instance of MPConfig with settings loaded from the context's application metadata. - */ - public static MPConfig getInstance(Context context, @Nullable String instanceName) { - return readConfig(context.getApplicationContext(), instanceName); - } - - /** - * The MixpanelAPI will use the system default SSL socket settings under ordinary circumstances. - * That means it will ignore settings you associated with the default SSLSocketFactory in the - * schema registry or in underlying HTTP libraries. If you'd prefer for Mixpanel to use your - * own SSL settings, you'll need to call setSSLSocketFactory early in your code, like this - * - * {@code - *
-     *     MPConfig.getInstance(context).setSSLSocketFactory(someCustomizedSocketFactory);
-     * 
- * } - * - * Your settings will be globally available to all Mixpanel instances, and will be used for - * all SSL connections in the library. The call is thread safe, but should be done before - * your first call to MixpanelAPI.getInstance to insure that the library never uses it's - * default. - * - * The given socket factory may be used from multiple threads, which is safe for the system - * SSLSocketFactory class, but if you pass a subclass you should ensure that it is thread-safe - * before passing it to Mixpanel. - * - * @param factory an SSLSocketFactory that - */ - public synchronized void setSSLSocketFactory(SSLSocketFactory factory) { - mSSLSocketFactory = factory; - } - - /** - * {@link OfflineMode} allows Mixpanel to be in-sync with client offline internal logic. - * If you want to integrate your own logic with Mixpanel you'll need to call - * {@link #setOfflineMode(OfflineMode)} early in your code, like this - * - * {@code - *
-     *     MPConfig.getInstance(context).setOfflineMode(OfflineModeImplementation);
-     * 
- * } - * - * Your settings will be globally available to all Mixpanel instances, and will be used across - * all the library. The call is thread safe, but should be done before - * your first call to MixpanelAPI.getInstance to insure that the library never uses it's - * default. - * - * The given {@link OfflineMode} may be used from multiple threads, you should ensure that - * your implementation is thread-safe before passing it to Mixpanel. - * - * @param offlineMode client offline implementation to use on Mixpanel - */ - public synchronized void setOfflineMode(OfflineMode offlineMode) { - mOfflineMode = offlineMode; - } - - /* package */ MPConfig(Bundle metaData, Context context, String instanceName) { - - // By default, we use a clean, FACTORY default SSLSocket. In general this is the right - // thing to do, and some other third party libraries change the - SSLSocketFactory foundSSLFactory; - try { - final SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, null, null); - foundSSLFactory = sslContext.getSocketFactory(); - } catch (final GeneralSecurityException e) { - MPLog.i("MixpanelAPI.Conf", "System has no SSL support. Built-in events editor will not be available", e); - foundSSLFactory = null; - } - mSSLSocketFactory = foundSSLFactory; - mInstanceName = instanceName; - DEBUG = metaData.getBoolean("com.mixpanel.android.MPConfig.EnableDebugLogging", false); - if (DEBUG) { - MPLog.setLevel(MPLog.VERBOSE); - } - - if (metaData.containsKey("com.mixpanel.android.MPConfig.DebugFlushInterval")) { - MPLog.w(LOGTAG, "We do not support com.mixpanel.android.MPConfig.DebugFlushInterval anymore. There will only be one flush interval. Please, update your AndroidManifest.xml."); - } - - mBulkUploadLimit = metaData.getInt("com.mixpanel.android.MPConfig.BulkUploadLimit", 40); // 40 records default - mFlushInterval = metaData.getInt("com.mixpanel.android.MPConfig.FlushInterval", 60 * 1000); // one minute default - mFlushBatchSize = metaData.getInt("com.mixpanel.android.MPConfig.FlushBatchSize", 50); // flush 50 events at a time by default - shouldGzipRequestPayload = metaData.getBoolean("com.mixpanel.android.MPConfig.GzipRequestPayload", false); - mFlushOnBackground = metaData.getBoolean("com.mixpanel.android.MPConfig.FlushOnBackground", true); - mMinimumDatabaseLimit = metaData.getInt("com.mixpanel.android.MPConfig.MinimumDatabaseLimit", 20 * 1024 * 1024); // 20 Mb - mMaximumDatabaseLimit = metaData.getInt("com.mixpanel.android.MPConfig.MaximumDatabaseLimit", Integer.MAX_VALUE); // 2 Gb - mResourcePackageName = metaData.getString("com.mixpanel.android.MPConfig.ResourcePackageName"); // default is null - mDisableAppOpenEvent = metaData.getBoolean("com.mixpanel.android.MPConfig.DisableAppOpenEvent", true); - mDisableExceptionHandler = metaData.getBoolean("com.mixpanel.android.MPConfig.DisableExceptionHandler", false); - mMinSessionDuration = metaData.getInt("com.mixpanel.android.MPConfig.MinimumSessionDuration", 10 * 1000); // 10 seconds - mSessionTimeoutDuration = metaData.getInt("com.mixpanel.android.MPConfig.SessionTimeoutDuration", Integer.MAX_VALUE); // no timeout by default - mUseIpAddressForGeolocation = metaData.getBoolean("com.mixpanel.android.MPConfig.UseIpAddressForGeolocation", true); - mRemoveLegacyResidualFiles = metaData.getBoolean("com.mixpanel.android.MPConfig.RemoveLegacyResidualFiles", false); - - Object dataExpirationMetaData = metaData.get("com.mixpanel.android.MPConfig.DataExpiration"); - long dataExpirationLong = 1000 * 60 * 60 * 24 * 5; // 5 days default - if (dataExpirationMetaData != null) { - try { - if (dataExpirationMetaData instanceof Integer) { - dataExpirationLong = (long) (int) dataExpirationMetaData; - } else if (dataExpirationMetaData instanceof Float) { - dataExpirationLong = (long) (float) dataExpirationMetaData; - } else { - throw new NumberFormatException(dataExpirationMetaData.toString() + " is not a number."); - } - } catch (Exception e) { - MPLog.e(LOGTAG,"Error parsing com.mixpanel.android.MPConfig.DataExpiration meta-data value", e); - } - } - mDataExpiration = dataExpirationLong; - boolean noUseIpAddressForGeolocationSetting = !metaData.containsKey("com.mixpanel.android.MPConfig.UseIpAddressForGeolocation"); - - String eventsEndpoint = metaData.getString("com.mixpanel.android.MPConfig.EventsEndpoint"); - if (eventsEndpoint != null) { - setEventsEndpoint(noUseIpAddressForGeolocationSetting ? eventsEndpoint : getEndPointWithIpTrackingParam(eventsEndpoint, getUseIpAddressForGeolocation())); - } else { - setEventsEndpointWithBaseURL(MPConstants.URL.MIXPANEL_API); - } - - String peopleEndpoint = metaData.getString("com.mixpanel.android.MPConfig.PeopleEndpoint"); - if (peopleEndpoint != null) { - setPeopleEndpoint(noUseIpAddressForGeolocationSetting ? peopleEndpoint : getEndPointWithIpTrackingParam(peopleEndpoint, getUseIpAddressForGeolocation())); - } else { - setPeopleEndpointWithBaseURL(MPConstants.URL.MIXPANEL_API); - } - - String groupsEndpoint = metaData.getString("com.mixpanel.android.MPConfig.GroupsEndpoint"); - if (groupsEndpoint != null) { - setGroupsEndpoint(noUseIpAddressForGeolocationSetting ? groupsEndpoint : getEndPointWithIpTrackingParam(groupsEndpoint, getUseIpAddressForGeolocation())); - } else { - setGroupsEndpointWithBaseURL(MPConstants.URL.MIXPANEL_API); - } - - String flagsEndpoint = metaData.getString("com.mixpanel.android.MPConfig.FlagsEndpoint"); - if (flagsEndpoint != null) { - setFlagsEndpoint(flagsEndpoint); - } else { - setFlagsEndpointWithBaseURL(MPConstants.URL.MIXPANEL_API); - } - - MPLog.v(LOGTAG, toString()); - } - - // Max size of queue before we require a flush. Must be below the limit the service will accept. - public int getBulkUploadLimit() { - return mBulkUploadLimit; - } - - // Target max milliseconds between flushes. This is advisory. - public int getFlushInterval() { - return mFlushInterval; - } - - // Whether the SDK should flush() queues when the app goes into the background or not. - public boolean getFlushOnBackground() { - return mFlushOnBackground; - } - - // Maximum number of events/updates to send in a single network request - public int getFlushBatchSize() { - return mFlushBatchSize; - } - - - public void setFlushBatchSize(int flushBatchSize) { - mFlushBatchSize = flushBatchSize; - } - public boolean shouldGzipRequestPayload() { return shouldGzipRequestPayload; } - - public void setShouldGzipRequestPayload(Boolean shouldGzip) { shouldGzipRequestPayload = shouldGzip; } - - - // Throw away records that are older than this in milliseconds. Should be below the server side age limit for events. - public long getDataExpiration() { - return mDataExpiration; - } - - public int getMinimumDatabaseLimit() { return mMinimumDatabaseLimit; } - - public int getMaximumDatabaseLimit() { return mMaximumDatabaseLimit; } - - public void setMaximumDatabaseLimit(int maximumDatabaseLimit) { - mMaximumDatabaseLimit = maximumDatabaseLimit; - } - - public String getInstanceName() { return mInstanceName; } - - public boolean getDisableAppOpenEvent() { - return mDisableAppOpenEvent; - } - - // Preferred URL for tracking events - public String getEventsEndpoint() { - return mEventsEndpoint; - } - - public String getFlagsEndpoint() { - return mFlagsEndpoint; - } - - public boolean getTrackAutomaticEvents() { return mTrackAutomaticEvents; } - - public void setServerURL(String serverURL, ProxyServerInteractor interactor) { - setServerURL(serverURL); - setProxyServerInteractor(interactor); - } - - // In parity with iOS SDK - public void setServerURL(String serverURL) { - setEventsEndpointWithBaseURL(serverURL); - setPeopleEndpointWithBaseURL(serverURL); - setGroupsEndpointWithBaseURL(serverURL); - setFlagsEndpointWithBaseURL(serverURL); - } - - private String getEndPointWithIpTrackingParam(String endPoint, boolean ifUseIpAddressForGeolocation) { - if (endPoint.contains("?ip=")) { - return endPoint.substring(0, endPoint.indexOf("?ip=")) + "?ip=" + (ifUseIpAddressForGeolocation ? "1" : "0"); + public static final String VERSION = BuildConfig.MIXPANEL_VERSION; + + public static boolean DEBUG = false; + + // Name for persistent storage of app referral SharedPreferences + /* package */ static final String REFERRER_PREFS_NAME = + "com.mixpanel.android.mpmetrics.ReferralInfo"; + + /** + * Retrieves a new instance of MPConfig with configuration settings loaded from the provided + * context. This method creates a new instance each time it is called, allowing for multiple + * configurations within the same application. + * + *

Since version 7.4.0, MPConfig is no longer a Singleton, in favor of supporting multiple, + * distinct configurations for different Mixpanel instances. This change allows greater + * flexibility in scenarios where different parts of an application require different Mixpanel + * configurations, such as different endpoints or settings. + * + *

It's important for users of this method to manage the lifecycle of the returned MPConfig + * instances themselves. Each call will result in a new configuration instance based on the + * application's metadata, and it's the responsibility of the caller to maintain any necessary + * references to these instances to use them later in their application. + * + * @param context The context used to load Mixpanel configuration. It's recommended to provide an + * ApplicationContext to avoid potential memory leaks. + * @return A new instance of MPConfig with settings loaded from the context's application + * metadata. + */ + public static MPConfig getInstance(Context context, @Nullable String instanceName) { + return readConfig(context.getApplicationContext(), instanceName); + } + + /** + * The MixpanelAPI will use the system default SSL socket settings under ordinary circumstances. + * That means it will ignore settings you associated with the default SSLSocketFactory in the + * schema registry or in underlying HTTP libraries. If you'd prefer for Mixpanel to use your own + * SSL settings, you'll need to call setSSLSocketFactory early in your code, like this + * + *

{@code

 MPConfig.getInstance(context).setSSLSocketFactory(someCustomizedSocketFactory);
+   * 
} + * + *

Your settings will be globally available to all Mixpanel instances, and will be used for all + * SSL connections in the library. The call is thread safe, but should be done before your first + * call to MixpanelAPI.getInstance to insure that the library never uses it's default. + * + *

The given socket factory may be used from multiple threads, which is safe for the system + * SSLSocketFactory class, but if you pass a subclass you should ensure that it is thread-safe + * before passing it to Mixpanel. + * + * @param factory an SSLSocketFactory that + */ + public synchronized void setSSLSocketFactory(SSLSocketFactory factory) { + mSSLSocketFactory = factory; + } + + /** + * {@link OfflineMode} allows Mixpanel to be in-sync with client offline internal logic. If you + * want to integrate your own logic with Mixpanel you'll need to call {@link + * #setOfflineMode(OfflineMode)} early in your code, like this + * + *

{@code

 MPConfig.getInstance(context).setOfflineMode(OfflineModeImplementation); 
+ * } + * + *

Your settings will be globally available to all Mixpanel instances, and will be used across + * all the library. The call is thread safe, but should be done before your first call to + * MixpanelAPI.getInstance to insure that the library never uses it's default. + * + *

The given {@link OfflineMode} may be used from multiple threads, you should ensure that your + * implementation is thread-safe before passing it to Mixpanel. + * + * @param offlineMode client offline implementation to use on Mixpanel + */ + public synchronized void setOfflineMode(OfflineMode offlineMode) { + mOfflineMode = offlineMode; + } + + /* package */ MPConfig(Bundle metaData, Context context, String instanceName) { + + // By default, we use a clean, FACTORY default SSLSocket. In general this is the right + // thing to do, and some other third party libraries change the + SSLSocketFactory foundSSLFactory; + try { + final SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, null, null); + foundSSLFactory = sslContext.getSocketFactory(); + } catch (final GeneralSecurityException e) { + MPLog.i( + "MixpanelAPI.Conf", + "System has no SSL support. Built-in events editor will not be available", + e); + foundSSLFactory = null; + } + mSSLSocketFactory = foundSSLFactory; + mInstanceName = instanceName; + DEBUG = metaData.getBoolean("com.mixpanel.android.MPConfig.EnableDebugLogging", false); + if (DEBUG) { + MPLog.setLevel(MPLog.VERBOSE); + } + + if (metaData.containsKey("com.mixpanel.android.MPConfig.DebugFlushInterval")) { + MPLog.w( + LOGTAG, + "We do not support com.mixpanel.android.MPConfig.DebugFlushInterval anymore. There will" + + " only be one flush interval. Please, update your AndroidManifest.xml."); + } + + mBulkUploadLimit = + metaData.getInt("com.mixpanel.android.MPConfig.BulkUploadLimit", 40); // 40 records default + mFlushInterval = + metaData.getInt( + "com.mixpanel.android.MPConfig.FlushInterval", 60 * 1000); // one minute default + mFlushBatchSize = + metaData.getInt( + "com.mixpanel.android.MPConfig.FlushBatchSize", + 50); // flush 50 events at a time by default + shouldGzipRequestPayload = + metaData.getBoolean("com.mixpanel.android.MPConfig.GzipRequestPayload", false); + mFlushOnBackground = + metaData.getBoolean("com.mixpanel.android.MPConfig.FlushOnBackground", true); + mMinimumDatabaseLimit = + metaData.getInt( + "com.mixpanel.android.MPConfig.MinimumDatabaseLimit", 20 * 1024 * 1024); // 20 Mb + mMaximumDatabaseLimit = + metaData.getInt( + "com.mixpanel.android.MPConfig.MaximumDatabaseLimit", Integer.MAX_VALUE); // 2 Gb + mResourcePackageName = + metaData.getString("com.mixpanel.android.MPConfig.ResourcePackageName"); // default is null + mDisableAppOpenEvent = + metaData.getBoolean("com.mixpanel.android.MPConfig.DisableAppOpenEvent", true); + mDisableExceptionHandler = + metaData.getBoolean("com.mixpanel.android.MPConfig.DisableExceptionHandler", false); + mMinSessionDuration = + metaData.getInt( + "com.mixpanel.android.MPConfig.MinimumSessionDuration", 10 * 1000); // 10 seconds + mSessionTimeoutDuration = + metaData.getInt( + "com.mixpanel.android.MPConfig.SessionTimeoutDuration", + Integer.MAX_VALUE); // no timeout by default + mUseIpAddressForGeolocation = + metaData.getBoolean("com.mixpanel.android.MPConfig.UseIpAddressForGeolocation", true); + mRemoveLegacyResidualFiles = + metaData.getBoolean("com.mixpanel.android.MPConfig.RemoveLegacyResidualFiles", false); + + Object dataExpirationMetaData = metaData.get("com.mixpanel.android.MPConfig.DataExpiration"); + long dataExpirationLong = 1000 * 60 * 60 * 24 * 5; // 5 days default + if (dataExpirationMetaData != null) { + try { + if (dataExpirationMetaData instanceof Integer) { + dataExpirationLong = (long) (int) dataExpirationMetaData; + } else if (dataExpirationMetaData instanceof Float) { + dataExpirationLong = (long) (float) dataExpirationMetaData; } else { - return endPoint + "?ip=" + (ifUseIpAddressForGeolocation ? "1" : "0"); - } - } - - private void setEventsEndpointWithBaseURL(String baseURL) { - setEventsEndpoint(getEndPointWithIpTrackingParam(baseURL + MPConstants.URL.EVENT, getUseIpAddressForGeolocation())); - } - - private void setEventsEndpoint(String eventsEndpoint) { - mEventsEndpoint = eventsEndpoint; - } - - // Preferred URL for tracking people - public String getPeopleEndpoint() { - return mPeopleEndpoint; - } - - private void setPeopleEndpointWithBaseURL(String baseURL) { - setPeopleEndpoint(getEndPointWithIpTrackingParam(baseURL + MPConstants.URL.PEOPLE, getUseIpAddressForGeolocation())); - } - - private void setPeopleEndpoint(String peopleEndpoint) { - mPeopleEndpoint = peopleEndpoint; - } - - // Preferred URL for tracking groups - public String getGroupsEndpoint() { - return mGroupsEndpoint; - } - - private void setGroupsEndpointWithBaseURL(String baseURL) { - setGroupsEndpoint(getEndPointWithIpTrackingParam(baseURL + MPConstants.URL.GROUPS, getUseIpAddressForGeolocation())); - } - - private void setGroupsEndpoint(String groupsEndpoint) { - mGroupsEndpoint = groupsEndpoint; - } - - private void setFlagsEndpointWithBaseURL(String baseURL) { - setFlagsEndpoint(baseURL + MPConstants.URL.FLAGS); - } - - private void setFlagsEndpoint(String flagsEndpoint) { - mFlagsEndpoint = flagsEndpoint; - } - - public int getMinimumSessionDuration() { - return mMinSessionDuration; - } - - public int getSessionTimeoutDuration() { - return mSessionTimeoutDuration; - } - - public boolean getDisableExceptionHandler() { - return mDisableExceptionHandler; - } - - private boolean getUseIpAddressForGeolocation() { - return mUseIpAddressForGeolocation; - } - - public boolean getRemoveLegacyResidualFiles() { return mRemoveLegacyResidualFiles; } - - public void setUseIpAddressForGeolocation(boolean useIpAddressForGeolocation) { - mUseIpAddressForGeolocation = useIpAddressForGeolocation; - setEventsEndpoint(getEndPointWithIpTrackingParam(getEventsEndpoint(), useIpAddressForGeolocation)); - setPeopleEndpoint(getEndPointWithIpTrackingParam(getPeopleEndpoint(), useIpAddressForGeolocation)); - setGroupsEndpoint(getEndPointWithIpTrackingParam(getGroupsEndpoint(), useIpAddressForGeolocation)); - } - - public void setEnableLogging(boolean enableLogging) { - DEBUG = enableLogging; - MPLog.setLevel(DEBUG ? MPLog.VERBOSE : MPLog.NONE); - } - - public void setTrackAutomaticEvents(boolean trackAutomaticEvents) { - mTrackAutomaticEvents = trackAutomaticEvents; - } - // Pre-configured package name for resources, if they differ from the application package name - // - // mContext.getPackageName() actually returns the "application id", which - // usually (but not always) the same as package of the generated R class. - // - // See: http://tools.android.com/tech-docs/new-build-system/applicationid-vs-packagename - // - // As far as I can tell, the original package name is lost in the build - // process in these cases, and must be specified by the developer using - // MPConfig meta-data. - public String getResourcePackageName() { - return mResourcePackageName; - } - - // This method is thread safe, and assumes that SSLSocketFactory is also thread safe - // (At this writing, all HttpsURLConnections in the framework share a single factory, - // so this is pretty safe even if the docs are ambiguous) - public synchronized SSLSocketFactory getSSLSocketFactory() { - return mSSLSocketFactory; - } - - // This method is thread safe, and assumes that OfflineMode is also thread safe - public synchronized OfflineMode getOfflineMode() { - return mOfflineMode; - } - - /////////////////////////////////////////////// - - public ProxyServerInteractor getProxyServerInteractor() { - return this.serverCallbacks; - } - - public void setProxyServerInteractor(ProxyServerInteractor interactor) { - this.serverCallbacks = interactor; - } - - // Package access for testing only- do not call directly in library code - /* package */ static MPConfig readConfig(Context appContext, String instanceName) { - final String packageName = appContext.getPackageName(); - try { - final ApplicationInfo appInfo = appContext.getPackageManager().getApplicationInfo(packageName, PackageManager.GET_META_DATA); - Bundle configBundle = appInfo.metaData; - if (null == configBundle) { - configBundle = new Bundle(); - } - return new MPConfig(configBundle, appContext, instanceName); - } catch (final NameNotFoundException e) { - throw new RuntimeException("Can't configure Mixpanel with package name " + packageName, e); + throw new NumberFormatException(dataExpirationMetaData.toString() + " is not a number."); } - } - - @Override - public String toString() { - return "Mixpanel (" + VERSION + ") configured with:\n" + - " TrackAutomaticEvents: " + getTrackAutomaticEvents() + "\n" + - " BulkUploadLimit " + getBulkUploadLimit() + "\n" + - " FlushInterval " + getFlushInterval() + "\n" + - " FlushInterval " + getFlushBatchSize() + "\n" + - " DataExpiration " + getDataExpiration() + "\n" + - " MinimumDatabaseLimit " + getMinimumDatabaseLimit() + "\n" + - " MaximumDatabaseLimit " + getMaximumDatabaseLimit() + "\n" + - " DisableAppOpenEvent " + getDisableAppOpenEvent() + "\n" + - " EnableDebugLogging " + DEBUG + "\n" + - " EventsEndpoint " + getEventsEndpoint() + "\n" + - " PeopleEndpoint " + getPeopleEndpoint() + "\n" + - " GroupsEndpoint " + getGroupsEndpoint() + "\n" + - " FlagsEndpoint " + getFlagsEndpoint() + "\n" + - " MinimumSessionDuration: " + getMinimumSessionDuration() + "\n" + - " SessionTimeoutDuration: " + getSessionTimeoutDuration() + "\n" + - " DisableExceptionHandler: " + getDisableExceptionHandler() + "\n" + - " FlushOnBackground: " + getFlushOnBackground(); - } - - private final int mBulkUploadLimit; - private final int mFlushInterval; - private final boolean mFlushOnBackground; - private final long mDataExpiration; - private final int mMinimumDatabaseLimit; - private int mMaximumDatabaseLimit; - private String mInstanceName; - private final boolean mDisableAppOpenEvent; - private final boolean mDisableExceptionHandler; - private boolean mTrackAutomaticEvents = true; - private String mEventsEndpoint; - private String mPeopleEndpoint; - private String mGroupsEndpoint; - private String mFlagsEndpoint; - private int mFlushBatchSize; - private boolean shouldGzipRequestPayload; - - private final String mResourcePackageName; - private final int mMinSessionDuration; - private final int mSessionTimeoutDuration; - private boolean mUseIpAddressForGeolocation; - private final boolean mRemoveLegacyResidualFiles; - - // Mutable, with synchronized accessor and mutator - private SSLSocketFactory mSSLSocketFactory; - private OfflineMode mOfflineMode; - private ProxyServerInteractor serverCallbacks = null; - private static final String LOGTAG = "MixpanelAPI.Conf"; + } catch (Exception e) { + MPLog.e( + LOGTAG, + "Error parsing com.mixpanel.android.MPConfig.DataExpiration meta-data value", + e); + } + } + mDataExpiration = dataExpirationLong; + boolean noUseIpAddressForGeolocationSetting = + !metaData.containsKey("com.mixpanel.android.MPConfig.UseIpAddressForGeolocation"); + + String eventsEndpoint = metaData.getString("com.mixpanel.android.MPConfig.EventsEndpoint"); + if (eventsEndpoint != null) { + setEventsEndpoint( + noUseIpAddressForGeolocationSetting + ? eventsEndpoint + : getEndPointWithIpTrackingParam(eventsEndpoint, getUseIpAddressForGeolocation())); + } else { + setEventsEndpointWithBaseURL(MPConstants.URL.MIXPANEL_API); + } + + String peopleEndpoint = metaData.getString("com.mixpanel.android.MPConfig.PeopleEndpoint"); + if (peopleEndpoint != null) { + setPeopleEndpoint( + noUseIpAddressForGeolocationSetting + ? peopleEndpoint + : getEndPointWithIpTrackingParam(peopleEndpoint, getUseIpAddressForGeolocation())); + } else { + setPeopleEndpointWithBaseURL(MPConstants.URL.MIXPANEL_API); + } + + String groupsEndpoint = metaData.getString("com.mixpanel.android.MPConfig.GroupsEndpoint"); + if (groupsEndpoint != null) { + setGroupsEndpoint( + noUseIpAddressForGeolocationSetting + ? groupsEndpoint + : getEndPointWithIpTrackingParam(groupsEndpoint, getUseIpAddressForGeolocation())); + } else { + setGroupsEndpointWithBaseURL(MPConstants.URL.MIXPANEL_API); + } + + String flagsEndpoint = metaData.getString("com.mixpanel.android.MPConfig.FlagsEndpoint"); + if (flagsEndpoint != null) { + setFlagsEndpoint(flagsEndpoint); + } else { + setFlagsEndpointWithBaseURL(MPConstants.URL.MIXPANEL_API); + } + + MPLog.v(LOGTAG, toString()); + } + + // Max size of queue before we require a flush. Must be below the limit the service will accept. + public int getBulkUploadLimit() { + return mBulkUploadLimit; + } + + // Target max milliseconds between flushes. This is advisory. + public int getFlushInterval() { + return mFlushInterval; + } + + // Whether the SDK should flush() queues when the app goes into the background or not. + public boolean getFlushOnBackground() { + return mFlushOnBackground; + } + + // Maximum number of events/updates to send in a single network request + public int getFlushBatchSize() { + return mFlushBatchSize; + } + + public void setFlushBatchSize(int flushBatchSize) { + mFlushBatchSize = flushBatchSize; + } + + public boolean shouldGzipRequestPayload() { + return shouldGzipRequestPayload; + } + + public void setShouldGzipRequestPayload(Boolean shouldGzip) { + shouldGzipRequestPayload = shouldGzip; + } + + // Throw away records that are older than this in milliseconds. Should be below the server side + // age limit for events. + public long getDataExpiration() { + return mDataExpiration; + } + + public int getMinimumDatabaseLimit() { + return mMinimumDatabaseLimit; + } + + public int getMaximumDatabaseLimit() { + return mMaximumDatabaseLimit; + } + + public void setMaximumDatabaseLimit(int maximumDatabaseLimit) { + mMaximumDatabaseLimit = maximumDatabaseLimit; + } + + public String getInstanceName() { + return mInstanceName; + } + + public boolean getDisableAppOpenEvent() { + return mDisableAppOpenEvent; + } + + // Preferred URL for tracking events + public String getEventsEndpoint() { + return mEventsEndpoint; + } + + public String getFlagsEndpoint() { + return mFlagsEndpoint; + } + + public boolean getTrackAutomaticEvents() { + return mTrackAutomaticEvents; + } + + public void setServerURL(String serverURL, ProxyServerInteractor interactor) { + setServerURL(serverURL); + setProxyServerInteractor(interactor); + } + + // In parity with iOS SDK + public void setServerURL(String serverURL) { + setEventsEndpointWithBaseURL(serverURL); + setPeopleEndpointWithBaseURL(serverURL); + setGroupsEndpointWithBaseURL(serverURL); + setFlagsEndpointWithBaseURL(serverURL); + } + + private String getEndPointWithIpTrackingParam( + String endPoint, boolean ifUseIpAddressForGeolocation) { + if (endPoint.contains("?ip=")) { + return endPoint.substring(0, endPoint.indexOf("?ip=")) + + "?ip=" + + (ifUseIpAddressForGeolocation ? "1" : "0"); + } else { + return endPoint + "?ip=" + (ifUseIpAddressForGeolocation ? "1" : "0"); + } + } + + private void setEventsEndpointWithBaseURL(String baseURL) { + setEventsEndpoint( + getEndPointWithIpTrackingParam( + baseURL + MPConstants.URL.EVENT, getUseIpAddressForGeolocation())); + } + + private void setEventsEndpoint(String eventsEndpoint) { + mEventsEndpoint = eventsEndpoint; + } + + // Preferred URL for tracking people + public String getPeopleEndpoint() { + return mPeopleEndpoint; + } + + private void setPeopleEndpointWithBaseURL(String baseURL) { + setPeopleEndpoint( + getEndPointWithIpTrackingParam( + baseURL + MPConstants.URL.PEOPLE, getUseIpAddressForGeolocation())); + } + + private void setPeopleEndpoint(String peopleEndpoint) { + mPeopleEndpoint = peopleEndpoint; + } + + // Preferred URL for tracking groups + public String getGroupsEndpoint() { + return mGroupsEndpoint; + } + + private void setGroupsEndpointWithBaseURL(String baseURL) { + setGroupsEndpoint( + getEndPointWithIpTrackingParam( + baseURL + MPConstants.URL.GROUPS, getUseIpAddressForGeolocation())); + } + + private void setGroupsEndpoint(String groupsEndpoint) { + mGroupsEndpoint = groupsEndpoint; + } + + private void setFlagsEndpointWithBaseURL(String baseURL) { + setFlagsEndpoint(baseURL + MPConstants.URL.FLAGS); + } + + private void setFlagsEndpoint(String flagsEndpoint) { + mFlagsEndpoint = flagsEndpoint; + } + + public int getMinimumSessionDuration() { + return mMinSessionDuration; + } + + public int getSessionTimeoutDuration() { + return mSessionTimeoutDuration; + } + + public boolean getDisableExceptionHandler() { + return mDisableExceptionHandler; + } + + private boolean getUseIpAddressForGeolocation() { + return mUseIpAddressForGeolocation; + } + + public boolean getRemoveLegacyResidualFiles() { + return mRemoveLegacyResidualFiles; + } + + public void setUseIpAddressForGeolocation(boolean useIpAddressForGeolocation) { + mUseIpAddressForGeolocation = useIpAddressForGeolocation; + setEventsEndpoint( + getEndPointWithIpTrackingParam(getEventsEndpoint(), useIpAddressForGeolocation)); + setPeopleEndpoint( + getEndPointWithIpTrackingParam(getPeopleEndpoint(), useIpAddressForGeolocation)); + setGroupsEndpoint( + getEndPointWithIpTrackingParam(getGroupsEndpoint(), useIpAddressForGeolocation)); + } + + public void setEnableLogging(boolean enableLogging) { + DEBUG = enableLogging; + MPLog.setLevel(DEBUG ? MPLog.VERBOSE : MPLog.NONE); + } + + public void setTrackAutomaticEvents(boolean trackAutomaticEvents) { + mTrackAutomaticEvents = trackAutomaticEvents; + } + + // Pre-configured package name for resources, if they differ from the application package name + // + // mContext.getPackageName() actually returns the "application id", which + // usually (but not always) the same as package of the generated R class. + // + // See: http://tools.android.com/tech-docs/new-build-system/applicationid-vs-packagename + // + // As far as I can tell, the original package name is lost in the build + // process in these cases, and must be specified by the developer using + // MPConfig meta-data. + public String getResourcePackageName() { + return mResourcePackageName; + } + + // This method is thread safe, and assumes that SSLSocketFactory is also thread safe + // (At this writing, all HttpsURLConnections in the framework share a single factory, + // so this is pretty safe even if the docs are ambiguous) + public synchronized SSLSocketFactory getSSLSocketFactory() { + return mSSLSocketFactory; + } + + // This method is thread safe, and assumes that OfflineMode is also thread safe + public synchronized OfflineMode getOfflineMode() { + return mOfflineMode; + } + + /////////////////////////////////////////////// + + public ProxyServerInteractor getProxyServerInteractor() { + return this.serverCallbacks; + } + + public void setProxyServerInteractor(ProxyServerInteractor interactor) { + this.serverCallbacks = interactor; + } + + // Package access for testing only- do not call directly in library code + /* package */ static MPConfig readConfig(Context appContext, String instanceName) { + final String packageName = appContext.getPackageName(); + try { + final ApplicationInfo appInfo = + appContext + .getPackageManager() + .getApplicationInfo(packageName, PackageManager.GET_META_DATA); + Bundle configBundle = appInfo.metaData; + if (null == configBundle) { + configBundle = new Bundle(); + } + return new MPConfig(configBundle, appContext, instanceName); + } catch (final NameNotFoundException e) { + throw new RuntimeException("Can't configure Mixpanel with package name " + packageName, e); + } + } + + @Override + public String toString() { + return "Mixpanel (" + + VERSION + + ") configured with:\n" + + " TrackAutomaticEvents: " + + getTrackAutomaticEvents() + + "\n" + + " BulkUploadLimit " + + getBulkUploadLimit() + + "\n" + + " FlushInterval " + + getFlushInterval() + + "\n" + + " FlushInterval " + + getFlushBatchSize() + + "\n" + + " DataExpiration " + + getDataExpiration() + + "\n" + + " MinimumDatabaseLimit " + + getMinimumDatabaseLimit() + + "\n" + + " MaximumDatabaseLimit " + + getMaximumDatabaseLimit() + + "\n" + + " DisableAppOpenEvent " + + getDisableAppOpenEvent() + + "\n" + + " EnableDebugLogging " + + DEBUG + + "\n" + + " EventsEndpoint " + + getEventsEndpoint() + + "\n" + + " PeopleEndpoint " + + getPeopleEndpoint() + + "\n" + + " GroupsEndpoint " + + getGroupsEndpoint() + + "\n" + + " FlagsEndpoint " + + getFlagsEndpoint() + + "\n" + + " MinimumSessionDuration: " + + getMinimumSessionDuration() + + "\n" + + " SessionTimeoutDuration: " + + getSessionTimeoutDuration() + + "\n" + + " DisableExceptionHandler: " + + getDisableExceptionHandler() + + "\n" + + " FlushOnBackground: " + + getFlushOnBackground(); + } + + private final int mBulkUploadLimit; + private final int mFlushInterval; + private final boolean mFlushOnBackground; + private final long mDataExpiration; + private final int mMinimumDatabaseLimit; + private int mMaximumDatabaseLimit; + private String mInstanceName; + private final boolean mDisableAppOpenEvent; + private final boolean mDisableExceptionHandler; + private boolean mTrackAutomaticEvents = true; + private String mEventsEndpoint; + private String mPeopleEndpoint; + private String mGroupsEndpoint; + private String mFlagsEndpoint; + private int mFlushBatchSize; + private boolean shouldGzipRequestPayload; + + private final String mResourcePackageName; + private final int mMinSessionDuration; + private final int mSessionTimeoutDuration; + private boolean mUseIpAddressForGeolocation; + private final boolean mRemoveLegacyResidualFiles; + + // Mutable, with synchronized accessor and mutator + private SSLSocketFactory mSSLSocketFactory; + private OfflineMode mOfflineMode; + private ProxyServerInteractor serverCallbacks = null; + private static final String LOGTAG = "MixpanelAPI.Conf"; } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MPDbAdapter.java b/src/main/java/com/mixpanel/android/mpmetrics/MPDbAdapter.java index 94a9f5ac6..487ce4980 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/MPDbAdapter.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/MPDbAdapter.java @@ -1,15 +1,5 @@ package com.mixpanel.android.mpmetrics; -import java.io.File; -import java.io.FilenameFilter; -import java.util.HashMap; -import java.util.Map; - -import androidx.annotation.Nullable; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - import android.content.ContentValues; import android.content.Context; import android.content.SharedPreferences; @@ -17,661 +7,838 @@ import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteException; import android.database.sqlite.SQLiteOpenHelper; - import com.mixpanel.android.util.MPLog; +import java.io.File; +import java.io.FilenameFilter; +import java.util.HashMap; +import java.util.Map; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; /** * SQLite database adapter for MixpanelAPI. * - *

Not thread-safe. Instances of this class should only be used - * by a single thread. - * + *

Not thread-safe. Instances of this class should only be used by a single thread. */ /* package */ class MPDbAdapter { - private static final String LOGTAG = "MixpanelAPI.Database"; - private static final Map sInstances = new HashMap<>(); - - public enum Table { - EVENTS ("events"), - PEOPLE ("people"), - ANONYMOUS_PEOPLE ("anonymous_people"), - GROUPS ("groups"); - - Table(String name) { - mTableName = name; - } + private static final String LOGTAG = "MixpanelAPI.Database"; + private static final Map sInstances = new HashMap<>(); - public String getName() { - return mTableName; - } + public enum Table { + EVENTS("events"), + PEOPLE("people"), + ANONYMOUS_PEOPLE("anonymous_people"), + GROUPS("groups"); - private final String mTableName; + Table(String name) { + mTableName = name; } - public static final String KEY_DATA = "data"; - public static final String KEY_CREATED_AT = "created_at"; - public static final String KEY_AUTOMATIC_DATA = "automatic_data"; - public static final String KEY_TOKEN = "token"; - - public static final int ID_COLUMN_INDEX = 0; - public static final int DATA_COLUMN_INDEX = 1; - public static final int CREATED_AT_COLUMN_INDEX = 2; - public static final int AUTOMATIC_DATA_COLUMN_INDEX = 3; - public static final int TOKEN_COLUMN_INDEX = 4; - - public static final int DB_UPDATE_ERROR = -1; - public static final int DB_OUT_OF_MEMORY_ERROR = -2; - public static final int DB_UNDEFINED_CODE = -3; - - private static final String DATABASE_NAME = "mixpanel"; - private static final int MIN_DB_VERSION = 4; - - // If you increment DATABASE_VERSION, don't forget to define migration - private static final int DATABASE_VERSION = 7; // current database version - private static final int MAX_DB_VERSION = 7; // Max database version onUpdate can migrate to. - - - private static final String CREATE_EVENTS_TABLE = - "CREATE TABLE " + Table.EVENTS.getName() + " (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + - KEY_DATA + " STRING NOT NULL, " + - KEY_CREATED_AT + " INTEGER NOT NULL, " + - KEY_AUTOMATIC_DATA + " INTEGER DEFAULT 0, " + - KEY_TOKEN + " STRING NOT NULL DEFAULT '')"; - private static final String CREATE_PEOPLE_TABLE = - "CREATE TABLE " + Table.PEOPLE.getName() + " (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + - KEY_DATA + " STRING NOT NULL, " + - KEY_CREATED_AT + " INTEGER NOT NULL, " + - KEY_AUTOMATIC_DATA + " INTEGER DEFAULT 0, " + - KEY_TOKEN + " STRING NOT NULL DEFAULT '')"; - private static final String CREATE_GROUPS_TABLE = - "CREATE TABLE " + Table.GROUPS.getName() + " (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + - KEY_DATA + " STRING NOT NULL, " + - KEY_CREATED_AT + " INTEGER NOT NULL, " + - KEY_AUTOMATIC_DATA + " INTEGER DEFAULT 0, " + - KEY_TOKEN + " STRING NOT NULL DEFAULT '')"; - private static final String CREATE_ANONYMOUS_PEOPLE_TABLE = - "CREATE TABLE " + Table.ANONYMOUS_PEOPLE.getName() + " (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + - KEY_DATA + " STRING NOT NULL, " + - KEY_CREATED_AT + " INTEGER NOT NULL, " + - KEY_AUTOMATIC_DATA + " INTEGER DEFAULT 0, " + - KEY_TOKEN + " STRING NOT NULL DEFAULT '')"; - private static final String EVENTS_TIME_INDEX = - "CREATE INDEX IF NOT EXISTS time_idx ON " + Table.EVENTS.getName() + - " (" + KEY_CREATED_AT + ");"; - private static final String PEOPLE_TIME_INDEX = - "CREATE INDEX IF NOT EXISTS time_idx ON " + Table.PEOPLE.getName() + - " (" + KEY_CREATED_AT + ");"; - private static final String GROUPS_TIME_INDEX = - "CREATE INDEX IF NOT EXISTS time_idx ON " + Table.GROUPS.getName() + - " (" + KEY_CREATED_AT + ");"; - private static final String ANONYMOUS_PEOPLE_TIME_INDEX = - "CREATE INDEX IF NOT EXISTS time_idx ON " + Table.ANONYMOUS_PEOPLE.getName() + - " (" + KEY_CREATED_AT + ");"; - - private final MPDatabaseHelper mDb; - - private static class MPDatabaseHelper extends SQLiteOpenHelper { - MPDatabaseHelper(Context context, String dbName, MPConfig config) { - super(context, dbName, null, DATABASE_VERSION); - mDatabaseFile = context.getDatabasePath(dbName); - mConfig = config; - mContext = context; - } - - /** - * Completely deletes the DB file from the file system. - */ - public void deleteDatabase() { - close(); - mDatabaseFile.delete(); - } - - @Override - public void onCreate(SQLiteDatabase db) { - MPLog.v(LOGTAG, "Creating a new Mixpanel events DB"); - - db.execSQL(CREATE_EVENTS_TABLE); - db.execSQL(CREATE_PEOPLE_TABLE); - db.execSQL(CREATE_GROUPS_TABLE); - db.execSQL(CREATE_ANONYMOUS_PEOPLE_TABLE); - db.execSQL(EVENTS_TIME_INDEX); - db.execSQL(PEOPLE_TIME_INDEX); - db.execSQL(GROUPS_TIME_INDEX); - db.execSQL(ANONYMOUS_PEOPLE_TIME_INDEX); - } - - @Override - public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - MPLog.v(LOGTAG, "Upgrading app, replacing Mixpanel events DB"); - - if (oldVersion >= MIN_DB_VERSION && newVersion <= MAX_DB_VERSION) { - if (oldVersion == 4) { - migrateTableFrom4To5(db); - migrateTableFrom5To6(db); - migrateTableFrom6To7(db); - } - - if (oldVersion == 5) { - migrateTableFrom5To6(db); - migrateTableFrom6To7(db); - } - - if (oldVersion == 6) { - migrateTableFrom6To7(db); - } - } else { - db.execSQL("DROP TABLE IF EXISTS " + Table.EVENTS.getName()); - db.execSQL("DROP TABLE IF EXISTS " + Table.PEOPLE.getName()); - db.execSQL("DROP TABLE IF EXISTS " + Table.GROUPS.getName()); - db.execSQL("DROP TABLE IF EXISTS " + Table.ANONYMOUS_PEOPLE.getName()); - db.execSQL(CREATE_EVENTS_TABLE); - db.execSQL(CREATE_PEOPLE_TABLE); - db.execSQL(CREATE_GROUPS_TABLE); - db.execSQL(CREATE_ANONYMOUS_PEOPLE_TABLE); - db.execSQL(EVENTS_TIME_INDEX); - db.execSQL(PEOPLE_TIME_INDEX); - db.execSQL(GROUPS_TIME_INDEX); - db.execSQL(ANONYMOUS_PEOPLE_TIME_INDEX); - } - } - - public boolean aboveMemThreshold() { - if (mDatabaseFile.exists()) { - return mDatabaseFile.length() > Math.max(mDatabaseFile.getUsableSpace(), mConfig.getMinimumDatabaseLimit()) || - mDatabaseFile.length() > mConfig.getMaximumDatabaseLimit(); - } - return false; - } - - private void migrateTableFrom4To5(SQLiteDatabase db) { - db.execSQL("ALTER TABLE " + Table.EVENTS.getName() + " ADD COLUMN " + KEY_AUTOMATIC_DATA + " INTEGER DEFAULT 0"); - db.execSQL("ALTER TABLE " + Table.PEOPLE.getName() + " ADD COLUMN " + KEY_AUTOMATIC_DATA + " INTEGER DEFAULT 0"); - db.execSQL("ALTER TABLE " + Table.EVENTS.getName() + " ADD COLUMN " + KEY_TOKEN + " STRING NOT NULL DEFAULT ''"); - db.execSQL("ALTER TABLE " + Table.PEOPLE.getName() + " ADD COLUMN " + KEY_TOKEN + " STRING NOT NULL DEFAULT ''"); - - Cursor eventsCursor = db.rawQuery("SELECT * FROM " + Table.EVENTS.getName(), null); - while (eventsCursor.moveToNext()) { - int rowId = 0; - try { - final int dataColumnIndex = eventsCursor.getColumnIndex(KEY_DATA) >= 0 ? eventsCursor.getColumnIndex(KEY_DATA) : DATA_COLUMN_INDEX; - final JSONObject j = new JSONObject(eventsCursor.getString(dataColumnIndex)); - String token = j.getJSONObject("properties").getString("token"); - final int idColumnIndex = eventsCursor.getColumnIndex("_id") >= 0 ? eventsCursor.getColumnIndex("_id") : ID_COLUMN_INDEX; - rowId = eventsCursor.getInt(idColumnIndex); - db.execSQL("UPDATE " + Table.EVENTS.getName() + " SET " + KEY_TOKEN + " = '" + token + "' WHERE _id = " + rowId); - } catch (final JSONException e) { - db.delete(Table.EVENTS.getName(), "_id = " + rowId, null); - } - } - - Cursor peopleCursor = db.rawQuery("SELECT * FROM " + Table.PEOPLE.getName(), null); - while (peopleCursor.moveToNext()) { - int rowId = 0; - try { - final int dataColumnIndex = peopleCursor.getColumnIndex(KEY_DATA) >= 0 ? peopleCursor.getColumnIndex(KEY_DATA) : DATA_COLUMN_INDEX; - final JSONObject j = new JSONObject(peopleCursor.getString(dataColumnIndex)); - String token = j.getString("$token"); - final int idColumnIndex = peopleCursor.getColumnIndex("_id") >= 0 ? peopleCursor.getColumnIndex("_id") : ID_COLUMN_INDEX; - rowId = peopleCursor.getInt(idColumnIndex); - db.execSQL("UPDATE " + Table.PEOPLE.getName() + " SET " + KEY_TOKEN + " = '" + token + "' WHERE _id = " + rowId); - } catch (final JSONException e) { - db.delete(Table.PEOPLE.getName(), "_id = " + rowId, null); - } - } - } - - private void migrateTableFrom5To6(SQLiteDatabase db) { - db.execSQL(CREATE_GROUPS_TABLE); - db.execSQL(GROUPS_TIME_INDEX); - } - - private void migrateTableFrom6To7(SQLiteDatabase db) { - db.execSQL(CREATE_ANONYMOUS_PEOPLE_TABLE); - db.execSQL(ANONYMOUS_PEOPLE_TIME_INDEX); - - File prefsDir = new File(mContext.getApplicationInfo().dataDir, "shared_prefs"); - - if (prefsDir.exists() && prefsDir.isDirectory()) { - String[] storedPrefsFiles = prefsDir.list(new FilenameFilter() { - @Override - public boolean accept(File dir, String name) { - return name.startsWith("com.mixpanel.android.mpmetrics.MixpanelAPI_"); - } - }); - - for (String storedPrefFile : storedPrefsFiles) { - String storedPrefName = storedPrefFile.split("\\.xml")[0]; - SharedPreferences s = mContext.getSharedPreferences(storedPrefName, Context.MODE_PRIVATE); - final String waitingPeopleUpdates = s.getString("waiting_array", null); - if (waitingPeopleUpdates != null) { - try { - JSONArray waitingObjects = new JSONArray(waitingPeopleUpdates); - db.beginTransaction(); - try { - for (int i = 0; i < waitingObjects.length(); i++) { - try { - final JSONObject j = waitingObjects.getJSONObject(i); - String token = j.getString("$token"); - - final ContentValues cv = new ContentValues(); - cv.put(KEY_DATA, j.toString()); - cv.put(KEY_CREATED_AT, System.currentTimeMillis()); - cv.put(KEY_AUTOMATIC_DATA, false); - cv.put(KEY_TOKEN, token); - db.insert(Table.ANONYMOUS_PEOPLE.getName(), null, cv); - } catch (JSONException e) { - // ignore record - } - } - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - } catch (JSONException e) { - // waiting array is corrupted. dismiss. - } - - SharedPreferences.Editor e = s.edit(); - e.remove("waiting_array"); - e.apply(); - } - } - } - } - - private final File mDatabaseFile; - private final MPConfig mConfig; - private final Context mContext; + public String getName() { + return mTableName; } - public MPDbAdapter(Context context, MPConfig config) { - this(context, getDbName(config.getInstanceName()), config); + private final String mTableName; + } + + public static final String KEY_DATA = "data"; + public static final String KEY_CREATED_AT = "created_at"; + public static final String KEY_AUTOMATIC_DATA = "automatic_data"; + public static final String KEY_TOKEN = "token"; + + public static final int ID_COLUMN_INDEX = 0; + public static final int DATA_COLUMN_INDEX = 1; + public static final int CREATED_AT_COLUMN_INDEX = 2; + public static final int AUTOMATIC_DATA_COLUMN_INDEX = 3; + public static final int TOKEN_COLUMN_INDEX = 4; + + public static final int DB_UPDATE_ERROR = -1; + public static final int DB_OUT_OF_MEMORY_ERROR = -2; + public static final int DB_UNDEFINED_CODE = -3; + + private static final String DATABASE_NAME = "mixpanel"; + private static final int MIN_DB_VERSION = 4; + + // If you increment DATABASE_VERSION, don't forget to define migration + private static final int DATABASE_VERSION = 7; // current database version + private static final int MAX_DB_VERSION = 7; // Max database version onUpdate can migrate to. + + private static final String CREATE_EVENTS_TABLE = + "CREATE TABLE " + + Table.EVENTS.getName() + + " (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + KEY_DATA + + " STRING NOT NULL, " + + KEY_CREATED_AT + + " INTEGER NOT NULL, " + + KEY_AUTOMATIC_DATA + + " INTEGER DEFAULT 0, " + + KEY_TOKEN + + " STRING NOT NULL DEFAULT '')"; + private static final String CREATE_PEOPLE_TABLE = + "CREATE TABLE " + + Table.PEOPLE.getName() + + " (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + KEY_DATA + + " STRING NOT NULL, " + + KEY_CREATED_AT + + " INTEGER NOT NULL, " + + KEY_AUTOMATIC_DATA + + " INTEGER DEFAULT 0, " + + KEY_TOKEN + + " STRING NOT NULL DEFAULT '')"; + private static final String CREATE_GROUPS_TABLE = + "CREATE TABLE " + + Table.GROUPS.getName() + + " (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + KEY_DATA + + " STRING NOT NULL, " + + KEY_CREATED_AT + + " INTEGER NOT NULL, " + + KEY_AUTOMATIC_DATA + + " INTEGER DEFAULT 0, " + + KEY_TOKEN + + " STRING NOT NULL DEFAULT '')"; + private static final String CREATE_ANONYMOUS_PEOPLE_TABLE = + "CREATE TABLE " + + Table.ANONYMOUS_PEOPLE.getName() + + " (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + KEY_DATA + + " STRING NOT NULL, " + + KEY_CREATED_AT + + " INTEGER NOT NULL, " + + KEY_AUTOMATIC_DATA + + " INTEGER DEFAULT 0, " + + KEY_TOKEN + + " STRING NOT NULL DEFAULT '')"; + private static final String EVENTS_TIME_INDEX = + "CREATE INDEX IF NOT EXISTS time_idx ON " + + Table.EVENTS.getName() + + " (" + + KEY_CREATED_AT + + ");"; + private static final String PEOPLE_TIME_INDEX = + "CREATE INDEX IF NOT EXISTS time_idx ON " + + Table.PEOPLE.getName() + + " (" + + KEY_CREATED_AT + + ");"; + private static final String GROUPS_TIME_INDEX = + "CREATE INDEX IF NOT EXISTS time_idx ON " + + Table.GROUPS.getName() + + " (" + + KEY_CREATED_AT + + ");"; + private static final String ANONYMOUS_PEOPLE_TIME_INDEX = + "CREATE INDEX IF NOT EXISTS time_idx ON " + + Table.ANONYMOUS_PEOPLE.getName() + + " (" + + KEY_CREATED_AT + + ");"; + + private final MPDatabaseHelper mDb; + + private static class MPDatabaseHelper extends SQLiteOpenHelper { + MPDatabaseHelper(Context context, String dbName, MPConfig config) { + super(context, dbName, null, DATABASE_VERSION); + mDatabaseFile = context.getDatabasePath(dbName); + mConfig = config; + mContext = context; } - private static String getDbName(String instanceName) { - return (instanceName == null || instanceName.trim().isEmpty()) ? DATABASE_NAME : (DATABASE_NAME + "_" + instanceName); + /** Completely deletes the DB file from the file system. */ + public void deleteDatabase() { + close(); + mDatabaseFile.delete(); } - public MPDbAdapter(Context context, String dbName, MPConfig config) { - mDb = new MPDatabaseHelper(context, dbName, config); + @Override + public void onCreate(SQLiteDatabase db) { + MPLog.v(LOGTAG, "Creating a new Mixpanel events DB"); + + db.execSQL(CREATE_EVENTS_TABLE); + db.execSQL(CREATE_PEOPLE_TABLE); + db.execSQL(CREATE_GROUPS_TABLE); + db.execSQL(CREATE_ANONYMOUS_PEOPLE_TABLE); + db.execSQL(EVENTS_TIME_INDEX); + db.execSQL(PEOPLE_TIME_INDEX); + db.execSQL(GROUPS_TIME_INDEX); + db.execSQL(ANONYMOUS_PEOPLE_TIME_INDEX); } - public static MPDbAdapter getInstance(Context context, MPConfig config) { - synchronized (sInstances) { - final Context appContext = context.getApplicationContext(); - MPDbAdapter ret; - String instanceName = config.getInstanceName(); - if (!sInstances.containsKey(instanceName)) { - ret = new MPDbAdapter(appContext, config); - sInstances.put(instanceName, ret); - } else { - ret = sInstances.get(instanceName); - } - return ret; - } - } + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + MPLog.v(LOGTAG, "Upgrading app, replacing Mixpanel events DB"); - /** - * Adds a JSON string representing an event with properties or a person record - * to the SQLiteDatabase. - * @param j the JSON to record - * @param token token of the project - * @param table the table to insert into, one of "events", "people", "groups" or "anonymous_people" - * @return the number of rows in the table, or DB_OUT_OF_MEMORY_ERROR/DB_UPDATE_ERROR - * on failure - */ - public int addJSON(JSONObject j, String token, Table table) { - // we are aware of the race condition here, but what can we do..? - if (this.aboveMemThreshold()) { - MPLog.e(LOGTAG, "There is not enough space left on the device or " + - "the data was over the maximum size limit so it was discarded"); - return DB_OUT_OF_MEMORY_ERROR; + if (oldVersion >= MIN_DB_VERSION && newVersion <= MAX_DB_VERSION) { + if (oldVersion == 4) { + migrateTableFrom4To5(db); + migrateTableFrom5To6(db); + migrateTableFrom6To7(db); } - final String tableName = table.getName(); - - Cursor c = null; - int count = DB_UPDATE_ERROR; + if (oldVersion == 5) { + migrateTableFrom5To6(db); + migrateTableFrom6To7(db); + } - try { - final SQLiteDatabase db = mDb.getWritableDatabase(); - - final ContentValues cv = new ContentValues(); - cv.put(KEY_DATA, j.toString()); - cv.put(KEY_CREATED_AT, System.currentTimeMillis()); - cv.put(KEY_TOKEN, token); - db.insert(tableName, null, cv); - - c = db.rawQuery("SELECT COUNT(*) FROM " + tableName + " WHERE token='" + token + "'", null); - c.moveToFirst(); - count = c.getInt(0); - } catch (final SQLiteException e) { - MPLog.e(LOGTAG, "Could not add Mixpanel data to table"); - - // We assume that in general, the results of a SQL exception are - // unrecoverable, and could be associated with an oversized or - // otherwise unusable DB. Better to bomb it and get back on track - // than to leave it junked up (and maybe filling up the disk.) - if (c != null) { - c.close(); - c = null; - } - mDb.deleteDatabase(); - } catch (final OutOfMemoryError e) { - MPLog.e(LOGTAG, "Out of memory when adding Mixpanel data to table"); - } finally { - if (c != null) { - c.close(); - } - mDb.close(); + if (oldVersion == 6) { + migrateTableFrom6To7(db); } - return count; + } else { + db.execSQL("DROP TABLE IF EXISTS " + Table.EVENTS.getName()); + db.execSQL("DROP TABLE IF EXISTS " + Table.PEOPLE.getName()); + db.execSQL("DROP TABLE IF EXISTS " + Table.GROUPS.getName()); + db.execSQL("DROP TABLE IF EXISTS " + Table.ANONYMOUS_PEOPLE.getName()); + db.execSQL(CREATE_EVENTS_TABLE); + db.execSQL(CREATE_PEOPLE_TABLE); + db.execSQL(CREATE_GROUPS_TABLE); + db.execSQL(CREATE_ANONYMOUS_PEOPLE_TABLE); + db.execSQL(EVENTS_TIME_INDEX); + db.execSQL(PEOPLE_TIME_INDEX); + db.execSQL(GROUPS_TIME_INDEX); + db.execSQL(ANONYMOUS_PEOPLE_TIME_INDEX); + } } - /** - * Copies anonymous people updates to people db after a user has been identified - * @param token project token - * @param distinctId people profile distinct id - * @return the number of rows copied (anonymous updates), or DB_OUT_OF_MEMORY_ERROR/DB_UPDATE_ERROR - * on failure - */ - /* package */ int pushAnonymousUpdatesToPeopleDb(String token, String distinctId) { - if (this.aboveMemThreshold()) { - MPLog.e(LOGTAG, "There is not enough space left on the device or " + - "the data was over the maximum size limit so it was discarded"); - return DB_OUT_OF_MEMORY_ERROR; - } - Cursor selectCursor = null; - int count = DB_UPDATE_ERROR; + public boolean aboveMemThreshold() { + if (mDatabaseFile.exists()) { + return mDatabaseFile.length() + > Math.max(mDatabaseFile.getUsableSpace(), mConfig.getMinimumDatabaseLimit()) + || mDatabaseFile.length() > mConfig.getMaximumDatabaseLimit(); + } + return false; + } + private void migrateTableFrom4To5(SQLiteDatabase db) { + db.execSQL( + "ALTER TABLE " + + Table.EVENTS.getName() + + " ADD COLUMN " + + KEY_AUTOMATIC_DATA + + " INTEGER DEFAULT 0"); + db.execSQL( + "ALTER TABLE " + + Table.PEOPLE.getName() + + " ADD COLUMN " + + KEY_AUTOMATIC_DATA + + " INTEGER DEFAULT 0"); + db.execSQL( + "ALTER TABLE " + + Table.EVENTS.getName() + + " ADD COLUMN " + + KEY_TOKEN + + " STRING NOT NULL DEFAULT ''"); + db.execSQL( + "ALTER TABLE " + + Table.PEOPLE.getName() + + " ADD COLUMN " + + KEY_TOKEN + + " STRING NOT NULL DEFAULT ''"); + + Cursor eventsCursor = db.rawQuery("SELECT * FROM " + Table.EVENTS.getName(), null); + while (eventsCursor.moveToNext()) { + int rowId = 0; try { - final SQLiteDatabase db = mDb.getWritableDatabase(); - StringBuffer allAnonymousQuery = new StringBuffer("SELECT * FROM " + Table.ANONYMOUS_PEOPLE.getName() + " WHERE " + KEY_TOKEN + " = '" + token + "'"); - - selectCursor = db.rawQuery(allAnonymousQuery.toString(), null); - db.beginTransaction(); - try { - while (selectCursor.moveToNext()) { - try { - ContentValues values = new ContentValues(); - final int createdAtColumnIndex = selectCursor.getColumnIndex(KEY_CREATED_AT) >= 0 ? selectCursor.getColumnIndex(KEY_CREATED_AT) : CREATED_AT_COLUMN_INDEX; - values.put(KEY_CREATED_AT, selectCursor.getLong(createdAtColumnIndex)); - final int automaticDataColumnIndex = selectCursor.getColumnIndex(KEY_AUTOMATIC_DATA) >= 0 ? selectCursor.getColumnIndex(KEY_AUTOMATIC_DATA) : AUTOMATIC_DATA_COLUMN_INDEX; - values.put(KEY_AUTOMATIC_DATA, selectCursor.getInt(automaticDataColumnIndex)); - final int tokenColumnIndex = selectCursor.getColumnIndex(KEY_TOKEN) >= 0 ? selectCursor.getColumnIndex(KEY_TOKEN) : TOKEN_COLUMN_INDEX; - values.put(KEY_TOKEN, selectCursor.getString(tokenColumnIndex)); - final int dataColumnIndex = selectCursor.getColumnIndex(KEY_DATA) >= 0 ? selectCursor.getColumnIndex(KEY_DATA) : DATA_COLUMN_INDEX; - JSONObject updatedData = new JSONObject(selectCursor.getString(dataColumnIndex)); - updatedData.put("$distinct_id", distinctId); - values.put(KEY_DATA, updatedData.toString()); - db.insert(Table.PEOPLE.getName(), null, values); - final int idColumnIndex = selectCursor.getColumnIndex("_id") >= 0 ? selectCursor.getColumnIndex("_id") : ID_COLUMN_INDEX; - int rowId = selectCursor.getInt(idColumnIndex); - db.delete(Table.ANONYMOUS_PEOPLE.getName(), "_id = " + rowId, null); - count++; - } catch (final JSONException e) { - // Ignore this object - } - } - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - } catch (final SQLiteException e) { - MPLog.e(LOGTAG, "Could not push anonymous updates records from " + Table.ANONYMOUS_PEOPLE.getName() + ". Re-initializing database.", e); + final int dataColumnIndex = + eventsCursor.getColumnIndex(KEY_DATA) >= 0 + ? eventsCursor.getColumnIndex(KEY_DATA) + : DATA_COLUMN_INDEX; + final JSONObject j = new JSONObject(eventsCursor.getString(dataColumnIndex)); + String token = j.getJSONObject("properties").getString("token"); + final int idColumnIndex = + eventsCursor.getColumnIndex("_id") >= 0 + ? eventsCursor.getColumnIndex("_id") + : ID_COLUMN_INDEX; + rowId = eventsCursor.getInt(idColumnIndex); + db.execSQL( + "UPDATE " + + Table.EVENTS.getName() + + " SET " + + KEY_TOKEN + + " = '" + + token + + "' WHERE _id = " + + rowId); + } catch (final JSONException e) { + db.delete(Table.EVENTS.getName(), "_id = " + rowId, null); + } + } - if (selectCursor != null) { - selectCursor.close(); - selectCursor = null; - } - // We assume that in general, the results of a SQL exception are - // unrecoverable, and could be associated with an oversized or - // otherwise unusable DB. Better to bomb it and get back on track - // than to leave it junked up (and maybe filling up the disk.) - mDb.deleteDatabase(); - } finally { - if (selectCursor != null) { - selectCursor.close(); - } - mDb.close(); + Cursor peopleCursor = db.rawQuery("SELECT * FROM " + Table.PEOPLE.getName(), null); + while (peopleCursor.moveToNext()) { + int rowId = 0; + try { + final int dataColumnIndex = + peopleCursor.getColumnIndex(KEY_DATA) >= 0 + ? peopleCursor.getColumnIndex(KEY_DATA) + : DATA_COLUMN_INDEX; + final JSONObject j = new JSONObject(peopleCursor.getString(dataColumnIndex)); + String token = j.getString("$token"); + final int idColumnIndex = + peopleCursor.getColumnIndex("_id") >= 0 + ? peopleCursor.getColumnIndex("_id") + : ID_COLUMN_INDEX; + rowId = peopleCursor.getInt(idColumnIndex); + db.execSQL( + "UPDATE " + + Table.PEOPLE.getName() + + " SET " + + KEY_TOKEN + + " = '" + + token + + "' WHERE _id = " + + rowId); + } catch (final JSONException e) { + db.delete(Table.PEOPLE.getName(), "_id = " + rowId, null); } + } + } - return count; + private void migrateTableFrom5To6(SQLiteDatabase db) { + db.execSQL(CREATE_GROUPS_TABLE); + db.execSQL(GROUPS_TIME_INDEX); } - /** - * Copies anonymous people updates to people db after a user has been identified - * @param properties Map of properties that will be added to existing events. - * @param token project token - * @return the number of rows updated , or DB_OUT_OF_MEMORY_ERROR/DB_UPDATE_ERROR - * on failure - */ - /* package */ int rewriteEventDataWithProperties(Map properties, String token) { - if (this.aboveMemThreshold()) { - MPLog.e(LOGTAG, "There is not enough space left on the device or " + - "the data was over the maximum size limit so it was discarded"); - return DB_OUT_OF_MEMORY_ERROR; - } - Cursor selectCursor = null; - int count = 0; + private void migrateTableFrom6To7(SQLiteDatabase db) { + db.execSQL(CREATE_ANONYMOUS_PEOPLE_TABLE); + db.execSQL(ANONYMOUS_PEOPLE_TIME_INDEX); - try { - final SQLiteDatabase db = mDb.getWritableDatabase(); - StringBuffer allAnonymousQuery = new StringBuffer("SELECT * FROM " + Table.EVENTS.getName() + " WHERE " + KEY_TOKEN + " = '" + token + "'"); + File prefsDir = new File(mContext.getApplicationInfo().dataDir, "shared_prefs"); + + if (prefsDir.exists() && prefsDir.isDirectory()) { + String[] storedPrefsFiles = + prefsDir.list( + new FilenameFilter() { + @Override + public boolean accept(File dir, String name) { + return name.startsWith("com.mixpanel.android.mpmetrics.MixpanelAPI_"); + } + }); - selectCursor = db.rawQuery(allAnonymousQuery.toString(), null); - db.beginTransaction(); + for (String storedPrefFile : storedPrefsFiles) { + String storedPrefName = storedPrefFile.split("\\.xml")[0]; + SharedPreferences s = mContext.getSharedPreferences(storedPrefName, Context.MODE_PRIVATE); + final String waitingPeopleUpdates = s.getString("waiting_array", null); + if (waitingPeopleUpdates != null) { try { - while (selectCursor.moveToNext()) { - try { - ContentValues values = new ContentValues(); - final int dataColumnIndex = selectCursor.getColumnIndex(KEY_DATA) >= 0 ? selectCursor.getColumnIndex(KEY_DATA) : DATA_COLUMN_INDEX; - JSONObject updatedData = new JSONObject(selectCursor.getString(dataColumnIndex)); - JSONObject existingProps = updatedData.getJSONObject("properties"); - for (final Map.Entry entry : properties.entrySet()) { - final String key = entry.getKey(); - final String value = entry.getValue(); - existingProps.put(key, value); - } - updatedData.put("properties", existingProps); - values.put(KEY_DATA, updatedData.toString()); - final int idColumnIndex = selectCursor.getColumnIndex("_id") >= 0 ? selectCursor.getColumnIndex("_id") : ID_COLUMN_INDEX; - int rowId = selectCursor.getInt(idColumnIndex); - db.update(Table.EVENTS.getName(), values, "_id = " + rowId, null); - count++; - } catch (final JSONException e) { - // Ignore this object - } + JSONArray waitingObjects = new JSONArray(waitingPeopleUpdates); + db.beginTransaction(); + try { + for (int i = 0; i < waitingObjects.length(); i++) { + try { + final JSONObject j = waitingObjects.getJSONObject(i); + String token = j.getString("$token"); + + final ContentValues cv = new ContentValues(); + cv.put(KEY_DATA, j.toString()); + cv.put(KEY_CREATED_AT, System.currentTimeMillis()); + cv.put(KEY_AUTOMATIC_DATA, false); + cv.put(KEY_TOKEN, token); + db.insert(Table.ANONYMOUS_PEOPLE.getName(), null, cv); + } catch (JSONException e) { + // ignore record + } } db.setTransactionSuccessful(); - } finally { + } finally { db.endTransaction(); + } + } catch (JSONException e) { + // waiting array is corrupted. dismiss. } - } catch (final SQLiteException e) { - MPLog.e(LOGTAG, "Could not re-write events history. Re-initializing database.", e); - if (selectCursor != null) { - selectCursor.close(); - selectCursor = null; - } - // We assume that in general, the results of a SQL exception are - // unrecoverable, and could be associated with an oversized or - // otherwise unusable DB. Better to bomb it and get back on track - // than to leave it junked up (and maybe filling up the disk.) - mDb.deleteDatabase(); - } finally { - if (selectCursor != null) { - selectCursor.close(); - } - mDb.close(); + SharedPreferences.Editor e = s.edit(); + e.remove("waiting_array"); + e.apply(); + } } - - return count; + } } - /** - * Removes events with an _id <= last_id from table - * @param last_id the last id to delete - * @param table the table to remove events from, one of "events", "people", "groups" or "anonymous_people" - */ - public void cleanupEvents(String last_id, Table table, String token) { - final String tableName = table.getName(); - - try { - final SQLiteDatabase db = mDb.getWritableDatabase(); - StringBuffer deleteQuery = new StringBuffer("_id <= " + last_id + " AND " + KEY_TOKEN + " = '" + token + "'"); - - db.delete(tableName, deleteQuery.toString(), null); - } catch (final SQLiteException e) { - MPLog.e(LOGTAG, "Could not clean sent Mixpanel records from " + tableName + ". Re-initializing database.", e); - - // We assume that in general, the results of a SQL exception are - // unrecoverable, and could be associated with an oversized or - // otherwise unusable DB. Better to bomb it and get back on track - // than to leave it junked up (and maybe filling up the disk.) - mDb.deleteDatabase(); - } catch (final Exception e) { - MPLog.e(LOGTAG, "Unknown exception. Could not clean sent Mixpanel records from " + tableName + ".Re-initializing database.", e); - mDb.deleteDatabase(); - } finally { - mDb.close(); - } + private final File mDatabaseFile; + private final MPConfig mConfig; + private final Context mContext; + } + + public MPDbAdapter(Context context, MPConfig config) { + this(context, getDbName(config.getInstanceName()), config); + } + + private static String getDbName(String instanceName) { + return (instanceName == null || instanceName.trim().isEmpty()) + ? DATABASE_NAME + : (DATABASE_NAME + "_" + instanceName); + } + + public MPDbAdapter(Context context, String dbName, MPConfig config) { + mDb = new MPDatabaseHelper(context, dbName, config); + } + + public static MPDbAdapter getInstance(Context context, MPConfig config) { + synchronized (sInstances) { + final Context appContext = context.getApplicationContext(); + MPDbAdapter ret; + String instanceName = config.getInstanceName(); + if (!sInstances.containsKey(instanceName)) { + ret = new MPDbAdapter(appContext, config); + sInstances.put(instanceName, ret); + } else { + ret = sInstances.get(instanceName); + } + return ret; } - - /** - * Removes events before time. - * @param time the unix epoch in milliseconds to remove events before - * @param table the table to remove events from, one of "events", "people", "groups" or "anonymous_people" - */ - public void cleanupEvents(long time, Table table) { - final String tableName = table.getName(); - - try { - final SQLiteDatabase db = mDb.getWritableDatabase(); - db.delete(tableName, KEY_CREATED_AT + " <= " + time, null); - } catch (final SQLiteException e) { - MPLog.e(LOGTAG, "Could not clean timed-out Mixpanel records from " + tableName + ". Re-initializing database.", e); - - // We assume that in general, the results of a SQL exception are - // unrecoverable, and could be associated with an oversized or - // otherwise unusable DB. Better to bomb it and get back on track - // than to leave it junked up (and maybe filling up the disk.) - mDb.deleteDatabase(); - } finally { - mDb.close(); - } + } + + /** + * Adds a JSON string representing an event with properties or a person record to the + * SQLiteDatabase. + * + * @param j the JSON to record + * @param token token of the project + * @param table the table to insert into, one of "events", "people", "groups" or + * "anonymous_people" + * @return the number of rows in the table, or DB_OUT_OF_MEMORY_ERROR/DB_UPDATE_ERROR on failure + */ + public int addJSON(JSONObject j, String token, Table table) { + // we are aware of the race condition here, but what can we do..? + if (this.aboveMemThreshold()) { + MPLog.e( + LOGTAG, + "There is not enough space left on the device or " + + "the data was over the maximum size limit so it was discarded"); + return DB_OUT_OF_MEMORY_ERROR; } - /** - * Removes all events given a project token. - * @param table the table to remove events from, one of "events", "people", "groups" or "anonymous_people" - * @param token token of the project to remove events from - */ - public void cleanupAllEvents(Table table, String token) { - final String tableName = table.getName(); - - try { - final SQLiteDatabase db = mDb.getWritableDatabase(); - db.delete(tableName, KEY_TOKEN + " = '" + token + "'", null); - } catch (final SQLiteException e) { - MPLog.e(LOGTAG, "Could not clean timed-out Mixpanel records from " + tableName + ". Re-initializing database.", e); - - // We assume that in general, the results of a SQL exception are - // unrecoverable, and could be associated with an oversized or - // otherwise unusable DB. Better to bomb it and get back on track - // than to leave it junked up (and maybe filling up the disk.) - mDb.deleteDatabase(); - } finally { - mDb.close(); + final String tableName = table.getName(); + + Cursor c = null; + int count = DB_UPDATE_ERROR; + + try { + final SQLiteDatabase db = mDb.getWritableDatabase(); + + final ContentValues cv = new ContentValues(); + cv.put(KEY_DATA, j.toString()); + cv.put(KEY_CREATED_AT, System.currentTimeMillis()); + cv.put(KEY_TOKEN, token); + db.insert(tableName, null, cv); + + c = db.rawQuery("SELECT COUNT(*) FROM " + tableName + " WHERE token='" + token + "'", null); + c.moveToFirst(); + count = c.getInt(0); + } catch (final SQLiteException e) { + MPLog.e(LOGTAG, "Could not add Mixpanel data to table"); + + // We assume that in general, the results of a SQL exception are + // unrecoverable, and could be associated with an oversized or + // otherwise unusable DB. Better to bomb it and get back on track + // than to leave it junked up (and maybe filling up the disk.) + if (c != null) { + c.close(); + c = null; + } + mDb.deleteDatabase(); + } catch (final OutOfMemoryError e) { + MPLog.e(LOGTAG, "Out of memory when adding Mixpanel data to table"); + } finally { + if (c != null) { + c.close(); + } + mDb.close(); + } + return count; + } + + /** + * Copies anonymous people updates to people db after a user has been identified + * + * @param token project token + * @param distinctId people profile distinct id + * @return the number of rows copied (anonymous updates), or + * DB_OUT_OF_MEMORY_ERROR/DB_UPDATE_ERROR on failure + */ + /* package */ int pushAnonymousUpdatesToPeopleDb(String token, String distinctId) { + if (this.aboveMemThreshold()) { + MPLog.e( + LOGTAG, + "There is not enough space left on the device or " + + "the data was over the maximum size limit so it was discarded"); + return DB_OUT_OF_MEMORY_ERROR; + } + Cursor selectCursor = null; + int count = DB_UPDATE_ERROR; + + try { + final SQLiteDatabase db = mDb.getWritableDatabase(); + StringBuffer allAnonymousQuery = + new StringBuffer( + "SELECT * FROM " + + Table.ANONYMOUS_PEOPLE.getName() + + " WHERE " + + KEY_TOKEN + + " = '" + + token + + "'"); + + selectCursor = db.rawQuery(allAnonymousQuery.toString(), null); + db.beginTransaction(); + try { + while (selectCursor.moveToNext()) { + try { + ContentValues values = new ContentValues(); + final int createdAtColumnIndex = + selectCursor.getColumnIndex(KEY_CREATED_AT) >= 0 + ? selectCursor.getColumnIndex(KEY_CREATED_AT) + : CREATED_AT_COLUMN_INDEX; + values.put(KEY_CREATED_AT, selectCursor.getLong(createdAtColumnIndex)); + final int automaticDataColumnIndex = + selectCursor.getColumnIndex(KEY_AUTOMATIC_DATA) >= 0 + ? selectCursor.getColumnIndex(KEY_AUTOMATIC_DATA) + : AUTOMATIC_DATA_COLUMN_INDEX; + values.put(KEY_AUTOMATIC_DATA, selectCursor.getInt(automaticDataColumnIndex)); + final int tokenColumnIndex = + selectCursor.getColumnIndex(KEY_TOKEN) >= 0 + ? selectCursor.getColumnIndex(KEY_TOKEN) + : TOKEN_COLUMN_INDEX; + values.put(KEY_TOKEN, selectCursor.getString(tokenColumnIndex)); + final int dataColumnIndex = + selectCursor.getColumnIndex(KEY_DATA) >= 0 + ? selectCursor.getColumnIndex(KEY_DATA) + : DATA_COLUMN_INDEX; + JSONObject updatedData = new JSONObject(selectCursor.getString(dataColumnIndex)); + updatedData.put("$distinct_id", distinctId); + values.put(KEY_DATA, updatedData.toString()); + db.insert(Table.PEOPLE.getName(), null, values); + final int idColumnIndex = + selectCursor.getColumnIndex("_id") >= 0 + ? selectCursor.getColumnIndex("_id") + : ID_COLUMN_INDEX; + int rowId = selectCursor.getInt(idColumnIndex); + db.delete(Table.ANONYMOUS_PEOPLE.getName(), "_id = " + rowId, null); + count++; + } catch (final JSONException e) { + // Ignore this object + } } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } catch (final SQLiteException e) { + MPLog.e( + LOGTAG, + "Could not push anonymous updates records from " + + Table.ANONYMOUS_PEOPLE.getName() + + ". Re-initializing database.", + e); + + if (selectCursor != null) { + selectCursor.close(); + selectCursor = null; + } + // We assume that in general, the results of a SQL exception are + // unrecoverable, and could be associated with an oversized or + // otherwise unusable DB. Better to bomb it and get back on track + // than to leave it junked up (and maybe filling up the disk.) + mDb.deleteDatabase(); + } finally { + if (selectCursor != null) { + selectCursor.close(); + } + mDb.close(); } - public void deleteDB() { - mDb.deleteDatabase(); + return count; + } + + /** + * Copies anonymous people updates to people db after a user has been identified + * + * @param properties Map of properties that will be added to existing events. + * @param token project token + * @return the number of rows updated , or DB_OUT_OF_MEMORY_ERROR/DB_UPDATE_ERROR on failure + */ + /* package */ int rewriteEventDataWithProperties(Map properties, String token) { + if (this.aboveMemThreshold()) { + MPLog.e( + LOGTAG, + "There is not enough space left on the device or " + + "the data was over the maximum size limit so it was discarded"); + return DB_OUT_OF_MEMORY_ERROR; } - - /** - * Returns the data string to send to Mixpanel and the maximum ID of the row that - * we're sending, so we know what rows to delete when a track request was successful. - * - * @param table the table to read the JSON from, one of "events", "people", or "groups" - * @param token the token of the project you want to retrieve the records for - * @return String array containing the maximum ID, the data string - * representing the events (or null if none could be successfully retrieved) and the total - * current number of events in the queue. - */ - public String[] generateDataString(Table table, String token) { - Cursor c = null; - Cursor queueCountCursor = null; - String data = null; - String last_id = null; - String queueCount = null; - final String tableName = table.getName(); - final SQLiteDatabase db = mDb.getReadableDatabase(); - - try { - StringBuffer rawDataQuery = new StringBuffer("SELECT * FROM " + tableName + " WHERE " + KEY_TOKEN + " = '" + token + "' "); - StringBuffer queueCountQuery = new StringBuffer("SELECT COUNT(*) FROM " + tableName + " WHERE " + KEY_TOKEN + " = '" + token + "' "); - - - rawDataQuery.append("ORDER BY " + KEY_CREATED_AT + " ASC LIMIT " + Integer.toString(mDb.mConfig.getFlushBatchSize())); - c = db.rawQuery(rawDataQuery.toString(), null); - - queueCountCursor = db.rawQuery(queueCountQuery.toString(), null); - queueCountCursor.moveToFirst(); - queueCount = String.valueOf(queueCountCursor.getInt(0)); - - final JSONArray arr = new JSONArray(); - - while (c.moveToNext()) { - if (c.isLast()) { - final int idColumnIndex = c.getColumnIndex("_id") >= 0 ? c.getColumnIndex("_id") : ID_COLUMN_INDEX; - last_id = c.getString(idColumnIndex); - } - try { - final int dataColumnIndex = c.getColumnIndex(KEY_DATA) >= 0 ? c.getColumnIndex(KEY_DATA) : DATA_COLUMN_INDEX; - final JSONObject j = new JSONObject(c.getString(dataColumnIndex)); - arr.put(j); - } catch (final JSONException e) { - // Ignore this object - } - } - - if (arr.length() > 0) { - data = arr.toString(); - } - } catch (final SQLiteException e) { - MPLog.e(LOGTAG, "Could not pull records for Mixpanel out of database " + tableName + ". Waiting to send.", e); - - // We'll dump the DB on write failures, but with reads we can - // let things ride in hopes the issue clears up. - // (A bit more likely, since we're opening the DB for read and not write.) - // A corrupted or disk-full DB will be cleaned up on the next write or clear call. - last_id = null; - data = null; - } finally { - mDb.close(); - if (c != null) { - c.close(); - } - if (queueCountCursor != null) { - queueCountCursor.close(); + Cursor selectCursor = null; + int count = 0; + + try { + final SQLiteDatabase db = mDb.getWritableDatabase(); + StringBuffer allAnonymousQuery = + new StringBuffer( + "SELECT * FROM " + + Table.EVENTS.getName() + + " WHERE " + + KEY_TOKEN + + " = '" + + token + + "'"); + + selectCursor = db.rawQuery(allAnonymousQuery.toString(), null); + db.beginTransaction(); + try { + while (selectCursor.moveToNext()) { + try { + ContentValues values = new ContentValues(); + final int dataColumnIndex = + selectCursor.getColumnIndex(KEY_DATA) >= 0 + ? selectCursor.getColumnIndex(KEY_DATA) + : DATA_COLUMN_INDEX; + JSONObject updatedData = new JSONObject(selectCursor.getString(dataColumnIndex)); + JSONObject existingProps = updatedData.getJSONObject("properties"); + for (final Map.Entry entry : properties.entrySet()) { + final String key = entry.getKey(); + final String value = entry.getValue(); + existingProps.put(key, value); } + updatedData.put("properties", existingProps); + values.put(KEY_DATA, updatedData.toString()); + final int idColumnIndex = + selectCursor.getColumnIndex("_id") >= 0 + ? selectCursor.getColumnIndex("_id") + : ID_COLUMN_INDEX; + int rowId = selectCursor.getInt(idColumnIndex); + db.update(Table.EVENTS.getName(), values, "_id = " + rowId, null); + count++; + } catch (final JSONException e) { + // Ignore this object + } } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } catch (final SQLiteException e) { + MPLog.e(LOGTAG, "Could not re-write events history. Re-initializing database.", e); + + if (selectCursor != null) { + selectCursor.close(); + selectCursor = null; + } + // We assume that in general, the results of a SQL exception are + // unrecoverable, and could be associated with an oversized or + // otherwise unusable DB. Better to bomb it and get back on track + // than to leave it junked up (and maybe filling up the disk.) + mDb.deleteDatabase(); + } finally { + if (selectCursor != null) { + selectCursor.close(); + } + mDb.close(); + } - if (last_id != null && data != null) { - final String[] ret = {last_id, data, queueCount}; - return ret; + return count; + } + + /** + * Removes events with an _id <= last_id from table + * + * @param last_id the last id to delete + * @param table the table to remove events from, one of "events", "people", "groups" or + * "anonymous_people" + */ + public void cleanupEvents(String last_id, Table table, String token) { + final String tableName = table.getName(); + + try { + final SQLiteDatabase db = mDb.getWritableDatabase(); + StringBuffer deleteQuery = + new StringBuffer("_id <= " + last_id + " AND " + KEY_TOKEN + " = '" + token + "'"); + + db.delete(tableName, deleteQuery.toString(), null); + } catch (final SQLiteException e) { + MPLog.e( + LOGTAG, + "Could not clean sent Mixpanel records from " + tableName + ". Re-initializing database.", + e); + + // We assume that in general, the results of a SQL exception are + // unrecoverable, and could be associated with an oversized or + // otherwise unusable DB. Better to bomb it and get back on track + // than to leave it junked up (and maybe filling up the disk.) + mDb.deleteDatabase(); + } catch (final Exception e) { + MPLog.e( + LOGTAG, + "Unknown exception. Could not clean sent Mixpanel records from " + + tableName + + ".Re-initializing database.", + e); + mDb.deleteDatabase(); + } finally { + mDb.close(); + } + } + + /** + * Removes events before time. + * + * @param time the unix epoch in milliseconds to remove events before + * @param table the table to remove events from, one of "events", "people", "groups" or + * "anonymous_people" + */ + public void cleanupEvents(long time, Table table) { + final String tableName = table.getName(); + + try { + final SQLiteDatabase db = mDb.getWritableDatabase(); + db.delete(tableName, KEY_CREATED_AT + " <= " + time, null); + } catch (final SQLiteException e) { + MPLog.e( + LOGTAG, + "Could not clean timed-out Mixpanel records from " + + tableName + + ". Re-initializing database.", + e); + + // We assume that in general, the results of a SQL exception are + // unrecoverable, and could be associated with an oversized or + // otherwise unusable DB. Better to bomb it and get back on track + // than to leave it junked up (and maybe filling up the disk.) + mDb.deleteDatabase(); + } finally { + mDb.close(); + } + } + + /** + * Removes all events given a project token. + * + * @param table the table to remove events from, one of "events", "people", "groups" or + * "anonymous_people" + * @param token token of the project to remove events from + */ + public void cleanupAllEvents(Table table, String token) { + final String tableName = table.getName(); + + try { + final SQLiteDatabase db = mDb.getWritableDatabase(); + db.delete(tableName, KEY_TOKEN + " = '" + token + "'", null); + } catch (final SQLiteException e) { + MPLog.e( + LOGTAG, + "Could not clean timed-out Mixpanel records from " + + tableName + + ". Re-initializing database.", + e); + + // We assume that in general, the results of a SQL exception are + // unrecoverable, and could be associated with an oversized or + // otherwise unusable DB. Better to bomb it and get back on track + // than to leave it junked up (and maybe filling up the disk.) + mDb.deleteDatabase(); + } finally { + mDb.close(); + } + } + + public void deleteDB() { + mDb.deleteDatabase(); + } + + /** + * Returns the data string to send to Mixpanel and the maximum ID of the row that we're sending, + * so we know what rows to delete when a track request was successful. + * + * @param table the table to read the JSON from, one of "events", "people", or "groups" + * @param token the token of the project you want to retrieve the records for + * @return String array containing the maximum ID, the data string representing the events (or + * null if none could be successfully retrieved) and the total current number of events in the + * queue. + */ + public String[] generateDataString(Table table, String token) { + Cursor c = null; + Cursor queueCountCursor = null; + String data = null; + String last_id = null; + String queueCount = null; + final String tableName = table.getName(); + final SQLiteDatabase db = mDb.getReadableDatabase(); + + try { + StringBuffer rawDataQuery = + new StringBuffer( + "SELECT * FROM " + tableName + " WHERE " + KEY_TOKEN + " = '" + token + "' "); + StringBuffer queueCountQuery = + new StringBuffer( + "SELECT COUNT(*) FROM " + tableName + " WHERE " + KEY_TOKEN + " = '" + token + "' "); + + rawDataQuery.append( + "ORDER BY " + + KEY_CREATED_AT + + " ASC LIMIT " + + Integer.toString(mDb.mConfig.getFlushBatchSize())); + c = db.rawQuery(rawDataQuery.toString(), null); + + queueCountCursor = db.rawQuery(queueCountQuery.toString(), null); + queueCountCursor.moveToFirst(); + queueCount = String.valueOf(queueCountCursor.getInt(0)); + + final JSONArray arr = new JSONArray(); + + while (c.moveToNext()) { + if (c.isLast()) { + final int idColumnIndex = + c.getColumnIndex("_id") >= 0 ? c.getColumnIndex("_id") : ID_COLUMN_INDEX; + last_id = c.getString(idColumnIndex); } - return null; + try { + final int dataColumnIndex = + c.getColumnIndex(KEY_DATA) >= 0 ? c.getColumnIndex(KEY_DATA) : DATA_COLUMN_INDEX; + final JSONObject j = new JSONObject(c.getString(dataColumnIndex)); + arr.put(j); + } catch (final JSONException e) { + // Ignore this object + } + } + + if (arr.length() > 0) { + data = arr.toString(); + } + } catch (final SQLiteException e) { + MPLog.e( + LOGTAG, + "Could not pull records for Mixpanel out of database " + tableName + ". Waiting to send.", + e); + + // We'll dump the DB on write failures, but with reads we can + // let things ride in hopes the issue clears up. + // (A bit more likely, since we're opening the DB for read and not write.) + // A corrupted or disk-full DB will be cleaned up on the next write or clear call. + last_id = null; + data = null; + } finally { + mDb.close(); + if (c != null) { + c.close(); + } + if (queueCountCursor != null) { + queueCountCursor.close(); + } } - public File getDatabaseFile() { - return mDb.mDatabaseFile; + if (last_id != null && data != null) { + final String[] ret = {last_id, data, queueCount}; + return ret; } + return null; + } - /* For testing use only, do not call from in production code */ - protected boolean aboveMemThreshold() { - return mDb.aboveMemThreshold(); - } + public File getDatabaseFile() { + return mDb.mDatabaseFile; + } + + /* For testing use only, do not call from in production code */ + protected boolean aboveMemThreshold() { + return mDb.aboveMemThreshold(); + } } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java index c2e8bfecb..19cfb81d5 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java @@ -12,21 +12,14 @@ import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; - import com.mixpanel.android.util.HttpService; import com.mixpanel.android.util.MPLog; import com.mixpanel.android.util.MixpanelNetworkErrorListener; import com.mixpanel.android.util.ProxyServerInteractor; import com.mixpanel.android.util.RemoteService; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - import java.io.File; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -43,34 +36,30 @@ import java.util.Map.Entry; import java.util.TimeZone; import java.util.concurrent.Future; - +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; /** * Core class for interacting with Mixpanel Analytics. * - *

Call {@link #getInstance(Context, String, boolean)} with - * your main application activity and your Mixpanel API token as arguments - * an to get an instance you can use to report how users are using your - * application. + *

Call {@link #getInstance(Context, String, boolean)} with your main application activity and + * your Mixpanel API token as arguments an to get an instance you can use to report how users are + * using your application. * - *

Once you have an instance, you can send events to Mixpanel - * using {@link #track(String, JSONObject)}, and update People Analytics - * records with {@link #getPeople()} + *

Once you have an instance, you can send events to Mixpanel using {@link #track(String, + * JSONObject)}, and update People Analytics records with {@link #getPeople()} * - *

The Mixpanel library will periodically send information to - * Mixpanel servers, so your application will need to have - * android.permission.INTERNET. In addition, to preserve - * battery life, messages to Mixpanel servers may not be sent immediately - * when you call {@link #track(String)}or {@link People#set(String, Object)}. - * The library will send messages periodically throughout the lifetime - * of your application, but you will need to call {@link #flush()} - * before your application is completely shutdown to ensure all of your - * events are sent. + *

The Mixpanel library will periodically send information to Mixpanel servers, so your + * application will need to have android.permission.INTERNET. In addition, to preserve + * battery life, messages to Mixpanel servers may not be sent immediately when you call {@link + * #track(String)}or {@link People#set(String, Object)}. The library will send messages periodically + * throughout the lifetime of your application, but you will need to call {@link #flush()} before + * your application is completely shutdown to ensure all of your events are sent. * *

A typical use-case for the library might look like this: * - *

- * {@code
+ * 
{@code
  * public class MainActivity extends Activity {
  *      MixpanelAPI mMixpanel;
  *
@@ -91,2603 +80,2717 @@
  *          super.onDestroy();
  *      }
  * }
- * }
- * 
+ * }
* - *

In addition to this documentation, you may wish to take a look at - * the Mixpanel sample Android application. - * It demonstrates a variety of techniques, including - * updating People Analytics records with {@link People} and others. + *

In addition to this documentation, you may wish to take a look at the Mixpanel sample + * Android application. It demonstrates a variety of techniques, including updating People + * Analytics records with {@link People} and others. * *

There are also step-by-step getting started documents * available at mixpanel.com * - * @see getting started documentation for tracking events - * @see getting started documentation for People Analytics - * @see The Mixpanel Android sample application + * @see getting started + * documentation for tracking events + * @see getting started documentation + * for People Analytics + * @see The Mixpanel + * Android sample application */ public class MixpanelAPI implements FeatureFlagDelegate { - /** - * String version of the library. - */ - public static final String VERSION = MPConfig.VERSION; - - /** - * You shouldn't instantiate MixpanelAPI objects directly. - * Use MixpanelAPI.getInstance to get an instance. - */ - MixpanelAPI(Context context, Future referrerPreferences, String token, boolean optOutTrackingDefault, JSONObject superProperties, boolean trackAutomaticEvents) { - this(context, referrerPreferences, token, MPConfig.getInstance(context, null), optOutTrackingDefault, superProperties, null, trackAutomaticEvents); + /** String version of the library. */ + public static final String VERSION = MPConfig.VERSION; + + /** + * You shouldn't instantiate MixpanelAPI objects directly. Use MixpanelAPI.getInstance to get an + * instance. + */ + MixpanelAPI( + Context context, + Future referrerPreferences, + String token, + boolean optOutTrackingDefault, + JSONObject superProperties, + boolean trackAutomaticEvents) { + this( + context, + referrerPreferences, + token, + MPConfig.getInstance(context, null), + optOutTrackingDefault, + superProperties, + null, + trackAutomaticEvents); + } + + /** + * You shouldn't instantiate MixpanelAPI objects directly. Use MixpanelAPI.getInstance to get an + * instance. + */ + MixpanelAPI( + Context context, + Future referrerPreferences, + String token, + boolean optOutTrackingDefault, + JSONObject superProperties, + String instanceName, + boolean trackAutomaticEvents) { + this( + context, + referrerPreferences, + token, + MPConfig.getInstance(context, instanceName), + optOutTrackingDefault, + superProperties, + instanceName, + trackAutomaticEvents); + } + + /** + * You shouldn't instantiate MixpanelAPI objects directly. Use MixpanelAPI.getInstance to get an + * instance. + */ + MixpanelAPI( + Context context, + Future referrerPreferences, + String token, + MPConfig config, + boolean optOutTrackingDefault, + JSONObject superProperties, + String instanceName, + boolean trackAutomaticEvents) { + this( + context, + referrerPreferences, + token, + config, + new MixpanelOptions.Builder() + .optOutTrackingDefault(optOutTrackingDefault) + .superProperties(superProperties) + .instanceName(instanceName) + .build(), + trackAutomaticEvents); + } + + /** + * You shouldn't instantiate MixpanelAPI objects directly. Use MixpanelAPI.getInstance to get an + * instance. + */ + MixpanelAPI( + Context context, + Future referrerPreferences, + String token, + MPConfig config, + MixpanelOptions options, + boolean trackAutomaticEvents) { + mContext = context; + mToken = token; + mInstanceName = options.getInstanceName(); + mPeople = new PeopleImpl(); + mGroups = new HashMap(); + mConfig = config; + mTrackAutomaticEvents = trackAutomaticEvents; + + final Map deviceInfo = new HashMap(); + deviceInfo.put("$android_lib_version", MPConfig.VERSION); + deviceInfo.put("$android_os", "Android"); + deviceInfo.put( + "$android_os_version", Build.VERSION.RELEASE == null ? "UNKNOWN" : Build.VERSION.RELEASE); + deviceInfo.put( + "$android_manufacturer", Build.MANUFACTURER == null ? "UNKNOWN" : Build.MANUFACTURER); + deviceInfo.put("$android_brand", Build.BRAND == null ? "UNKNOWN" : Build.BRAND); + deviceInfo.put("$android_model", Build.MODEL == null ? "UNKNOWN" : Build.MODEL); + try { + final PackageManager manager = mContext.getPackageManager(); + final PackageInfo info = manager.getPackageInfo(mContext.getPackageName(), 0); + deviceInfo.put("$android_app_version", info.versionName); + deviceInfo.put("$android_app_version_code", Integer.toString(info.versionCode)); + } catch (final PackageManager.NameNotFoundException e) { + MPLog.e(LOGTAG, "Exception getting app version name", e); + } + mDeviceInfo = Collections.unmodifiableMap(deviceInfo); + + mSessionMetadata = new SessionMetadata(); + mMessages = getAnalyticsMessages(); + mPersistentIdentity = + getPersistentIdentity(context, referrerPreferences, token, options.getInstanceName()); + mEventTimings = mPersistentIdentity.getTimeEvents(); + + mFeatureFlagManager = + new FeatureFlagManager( + this, + getHttpService(), + new FlagsConfig(options.areFeatureFlagsEnabled(), options.getFeatureFlagsContext())); + + mFeatureFlagManager.loadFlags(); + + if (options.isOptOutTrackingDefault() + && (hasOptedOutTracking() || !mPersistentIdentity.hasOptOutFlag(token))) { + optOutTracking(); + } + + if (options.getSuperProperties() != null) { + registerSuperProperties(options.getSuperProperties()); + } + + final boolean dbExists = MPDbAdapter.getInstance(mContext, mConfig).getDatabaseFile().exists(); + + registerMixpanelActivityLifecycleCallbacks(); + + if (mPersistentIdentity.isFirstLaunch(dbExists, mToken) && mTrackAutomaticEvents) { + track(AutomaticEvents.FIRST_OPEN, null, true); + mPersistentIdentity.setHasLaunched(mToken); + } + + if (sendAppOpen() && mTrackAutomaticEvents) { + track("$app_open", null); + } + + if (mPersistentIdentity.isNewVersion(deviceInfo.get("$android_app_version_code")) + && mTrackAutomaticEvents) { + try { + final JSONObject messageProps = new JSONObject(); + messageProps.put(AutomaticEvents.VERSION_UPDATED, deviceInfo.get("$android_app_version")); + track(AutomaticEvents.APP_UPDATED, messageProps, true); + } catch (JSONException e) { + } } - /** - * You shouldn't instantiate MixpanelAPI objects directly. - * Use MixpanelAPI.getInstance to get an instance. - */ - MixpanelAPI(Context context, Future referrerPreferences, String token, boolean optOutTrackingDefault, JSONObject superProperties, String instanceName, boolean trackAutomaticEvents) { - this(context, referrerPreferences, token, MPConfig.getInstance(context, instanceName), optOutTrackingDefault, superProperties, instanceName, trackAutomaticEvents); - } + if (!mConfig.getDisableExceptionHandler()) { + ExceptionHandler.init(); + } + + if (mConfig.getRemoveLegacyResidualFiles()) { + mMessages.removeResidualImageFiles(new File(mContext.getApplicationInfo().dataDir)); + } + + // Event tracking integration w/ Session Replay SDK requires Android 13 or higher. + // It is also NOT supported in "Instant" apps + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && !context.getPackageManager().isInstantApp()) { + BroadcastReceiver sessionReplayReceiver = new SessionReplayBroadcastReceiver(this); + ContextCompat.registerReceiver( + mContext.getApplicationContext(), + sessionReplayReceiver, + SessionReplayBroadcastReceiver.INTENT_FILTER, + ContextCompat.RECEIVER_NOT_EXPORTED); + } + } + + /** + * Get the instance of MixpanelAPI associated with your Mixpanel project token. + * + *

Use getInstance to get a reference to a shared instance of MixpanelAPI you can use to send + * events and People Analytics updates to Mixpanel. + * + *

getInstance is thread safe, but the returned instance is not, and may be shared with other + * callers of getInstance. The best practice is to call getInstance, and use the returned + * MixpanelAPI, object from a single thread (probably the main UI thread of your application). + * + *

If you do choose to track events from multiple threads in your application, you should + * synchronize your calls on the instance itself, like so: + * + *

{@code
+   * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
+   * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
+   *     instance.track(...)
+   * }
+   * }
+ * + * @param context The application context you are tracking + * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web + * site, in the settings dialog. + * @param trackAutomaticEvents Whether or not to collect common mobile events include app + * sessions, first app opens, app updated, etc. + * @return an instance of MixpanelAPI associated with your project + */ + public static MixpanelAPI getInstance( + Context context, String token, boolean trackAutomaticEvents) { + return getInstance(context, token, false, null, null, trackAutomaticEvents); + } + + /** + * Get the instance of MixpanelAPI associated with your Mixpanel project token. + * + *

Use getInstance to get a reference to a shared instance of MixpanelAPI you can use to send + * events and People Analytics updates to Mixpanel. + * + *

getInstance is thread safe, but the returned instance is not, and may be shared with other + * callers of getInstance. The best practice is to call getInstance, and use the returned + * MixpanelAPI, object from a single thread (probably the main UI thread of your application). + * + *

If you do choose to track events from multiple threads in your application, you should + * synchronize your calls on the instance itself, like so: + * + *

{@code
+   * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
+   * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
+   *     instance.track(...)
+   * }
+   * }
+ * + * @param context The application context you are tracking + * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web + * site, in the settings dialog. + * @param instanceName The name you want to uniquely identify the Mixpanel Instance. It is useful + * when you want more than one Mixpanel instance under the same project token + * @param trackAutomaticEvents Whether or not to collect common mobile events include app + * sessions, first app opens, app updated, etc. + * @return an instance of MixpanelAPI associated with your project + */ + public static MixpanelAPI getInstance( + Context context, String token, String instanceName, boolean trackAutomaticEvents) { + return getInstance(context, token, false, null, instanceName, trackAutomaticEvents); + } + + /** + * Get the instance of MixpanelAPI associated with your Mixpanel project token. + * + *

Use getInstance to get a reference to a shared instance of MixpanelAPI you can use to send + * events and People Analytics updates to Mixpanel. + * + *

getInstance is thread safe, but the returned instance is not, and may be shared with other + * callers of getInstance. The best practice is to call getInstance, and use the returned + * MixpanelAPI, object from a single thread (probably the main UI thread of your application). + * + *

If you do choose to track events from multiple threads in your application, you should + * synchronize your calls on the instance itself, like so: + * + *

{@code
+   * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
+   * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
+   *     instance.track(...)
+   * }
+   * }
+ * + * @param context The application context you are tracking + * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web + * site, in the settings dialog. + * @param optOutTrackingDefault Whether or not Mixpanel can start tracking by default. See {@link + * #optOutTracking()}. + * @param trackAutomaticEvents Whether or not to collect common mobile events include app + * sessions, first app opens, app updated, etc. + * @return an instance of MixpanelAPI associated with your project + */ + public static MixpanelAPI getInstance( + Context context, String token, boolean optOutTrackingDefault, boolean trackAutomaticEvents) { + return getInstance(context, token, optOutTrackingDefault, null, null, trackAutomaticEvents); + } + + /** + * Get the instance of MixpanelAPI associated with your Mixpanel project token. + * + *

Use getInstance to get a reference to a shared instance of MixpanelAPI you can use to send + * events and People Analytics updates to Mixpanel. + * + *

getInstance is thread safe, but the returned instance is not, and may be shared with other + * callers of getInstance. The best practice is to call getInstance, and use the returned + * MixpanelAPI, object from a single thread (probably the main UI thread of your application). + * + *

If you do choose to track events from multiple threads in your application, you should + * synchronize your calls on the instance itself, like so: + * + *

{@code
+   * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
+   * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
+   *     instance.track(...)
+   * }
+   * }
+ * + * @param context The application context you are tracking + * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web + * site, in the settings dialog. + * @param optOutTrackingDefault Whether or not Mixpanel can start tracking by default. See {@link + * #optOutTracking()}. + * @param instanceName The name you want to uniquely identify the Mixpanel Instance. It is useful + * when you want more than one Mixpanel instance under the same project token. + * @param trackAutomaticEvents Whether or not to collect common mobile events include app + * sessions, first app opens, app updated, etc. + * @return an instance of MixpanelAPI associated with your project + */ + public static MixpanelAPI getInstance( + Context context, + String token, + boolean optOutTrackingDefault, + String instanceName, + boolean trackAutomaticEvents) { + return getInstance( + context, token, optOutTrackingDefault, null, instanceName, trackAutomaticEvents); + } + + /** + * Get the instance of MixpanelAPI associated with your Mixpanel project token. + * + *

Use getInstance to get a reference to a shared instance of MixpanelAPI you can use to send + * events and People Analytics updates to Mixpanel. + * + *

getInstance is thread safe, but the returned instance is not, and may be shared with other + * callers of getInstance. The best practice is to call getInstance, and use the returned + * MixpanelAPI, object from a single thread (probably the main UI thread of your application). + * + *

If you do choose to track events from multiple threads in your application, you should + * synchronize your calls on the instance itself, like so: + * + *

{@code
+   * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
+   * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
+   *     instance.track(...)
+   * }
+   * }
+ * + * @param context The application context you are tracking + * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web + * site, in the settings dialog. + * @param superProperties A JSONObject containing super properties to register. + * @param trackAutomaticEvents Whether or not to collect common mobile events include app + * sessions, first app opens, app updated, etc. + * @return an instance of MixpanelAPI associated with your project + */ + public static MixpanelAPI getInstance( + Context context, String token, JSONObject superProperties, boolean trackAutomaticEvents) { + return getInstance(context, token, false, superProperties, null, trackAutomaticEvents); + } + + /** + * Get the instance of MixpanelAPI associated with your Mixpanel project token. + * + *

Use getInstance to get a reference to a shared instance of MixpanelAPI you can use to send + * events and People Analytics updates to Mixpanel. + * + *

getInstance is thread safe, but the returned instance is not, and may be shared with other + * callers of getInstance. The best practice is to call getInstance, and use the returned + * MixpanelAPI, object from a single thread (probably the main UI thread of your application). + * + *

If you do choose to track events from multiple threads in your application, you should + * synchronize your calls on the instance itself, like so: + * + *

{@code
+   * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
+   * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
+   *     instance.track(...)
+   * }
+   * }
+ * + * @param context The application context you are tracking + * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web + * site, in the settings dialog. + * @param superProperties A JSONObject containing super properties to register. + * @param instanceName The name you want to uniquely identify the Mixpanel Instance. It is useful + * when you want more than one Mixpanel instance under the same project token + * @param trackAutomaticEvents Whether or not to collect common mobile events include app + * sessions, first app opens, app updated, etc. + * @return an instance of MixpanelAPI associated with your project + */ + public static MixpanelAPI getInstance( + Context context, + String token, + JSONObject superProperties, + String instanceName, + boolean trackAutomaticEvents) { + return getInstance(context, token, false, superProperties, instanceName, trackAutomaticEvents); + } + + /** + * Get the instance of MixpanelAPI associated with your Mixpanel project token. + * + *

Use getInstance to get a reference to a shared instance of MixpanelAPI you can use to send + * events and People Analytics updates to Mixpanel. + * + *

getInstance is thread safe, but the returned instance is not, and may be shared with other + * callers of getInstance. The best practice is to call getInstance, and use the returned + * MixpanelAPI, object from a single thread (probably the main UI thread of your application). + * + *

If you do choose to track events from multiple threads in your application, you should + * synchronize your calls on the instance itself, like so: + * + *

{@code
+   * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
+   * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
+   *     instance.track(...)
+   * }
+   * }
+ * + * @param context The application context you are tracking + * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web + * site, in the settings dialog. + * @param optOutTrackingDefault Whether or not Mixpanel can start tracking by default. See {@link + * #optOutTracking()}. + * @param superProperties A JSONObject containing super properties to register. + * @param instanceName The name you want to uniquely identify the Mixpanel Instance. It is useful + * when you want more than one Mixpanel instance under the same project token + * @param trackAutomaticEvents Whether or not to collect common mobile events include app + * sessions, first app opens, app updated, etc. + * @return an instance of MixpanelAPI associated with your project + */ + public static MixpanelAPI getInstance( + Context context, + String token, + boolean optOutTrackingDefault, + JSONObject superProperties, + String instanceName, + boolean trackAutomaticEvents) { + MixpanelOptions options = + new MixpanelOptions.Builder() + .instanceName(instanceName) + .optOutTrackingDefault(optOutTrackingDefault) + .superProperties(superProperties) + .build(); + return getInstance(context, token, trackAutomaticEvents, options); + } + + /** + * Get the instance of MixpanelAPI associated with your Mixpanel project token and configured with + * the provided options. + * + *

Use getInstance to get a reference to a shared instance of MixpanelAPI you can use to send + * events and People Analytics updates to Mixpanel. This overload allows for more detailed + * configuration via the {@link MixpanelOptions} parameter. + * + *

getInstance is thread safe, but the returned instance is not, and may be shared with other + * callers of getInstance. The best practice is to call getInstance, and use the returned + * MixpanelAPI, object from a single thread (probably the main UI thread of your application). + * + *

If you do choose to track events from multiple threads in your application, you should + * synchronize your calls on the instance itself, like so: + * + *

{@code
+   * MixpanelAPI instance = MixpanelAPI.getInstance(context, token, true, options);
+   * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
+   * instance.track(...)
+   * }
+   * }
+ * + * @param context The application context you are tracking. + * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web + * site, in the settings dialog. + * @param trackAutomaticEvents Whether or not to collect common mobile events such as app + * sessions, first app opens, app updates, etc. + * @param options An instance of {@link MixpanelOptions} to configure the MixpanelAPI instance. + * This allows setting options like {@code optOutTrackingDefault}, {@code superProperties}, + * and {@code instanceName}. Other options within MixpanelOptions may be used by other SDK + * features if applicable. + * @return an instance of MixpanelAPI associated with your project and configured with the + * specified options. + */ + public static MixpanelAPI getInstance( + Context context, String token, boolean trackAutomaticEvents, MixpanelOptions options) { + if (null == token || null == context) { + return null; + } + synchronized (sInstanceMap) { + final Context appContext = context.getApplicationContext(); + + if (null == sReferrerPrefs) { + sReferrerPrefs = sPrefsLoader.loadPreferences(context, MPConfig.REFERRER_PREFS_NAME, null); + } + String instanceKey = options.getInstanceName() != null ? options.getInstanceName() : token; + Map instances = sInstanceMap.get(instanceKey); + if (null == instances) { + instances = new HashMap(); + sInstanceMap.put(instanceKey, instances); + } - /** - * You shouldn't instantiate MixpanelAPI objects directly. - * Use MixpanelAPI.getInstance to get an instance. - */ - MixpanelAPI(Context context, Future referrerPreferences, String token, MPConfig config, boolean optOutTrackingDefault, JSONObject superProperties, String instanceName, boolean trackAutomaticEvents) { - this( - context, - referrerPreferences, - token, - config, - new MixpanelOptions.Builder().optOutTrackingDefault(optOutTrackingDefault).superProperties(superProperties).instanceName(instanceName).build(), - trackAutomaticEvents - ); - } + MixpanelAPI instance = instances.get(appContext); + if (null == instance && ConfigurationChecker.checkBasicConfiguration(appContext)) { + instance = + new MixpanelAPI( + appContext, + sReferrerPrefs, + token, + MPConfig.getInstance(context, options.getInstanceName()), + options, + trackAutomaticEvents); + registerAppLinksListeners(context, instance); + instances.put(appContext, instance); + } - /** - * You shouldn't instantiate MixpanelAPI objects directly. - * Use MixpanelAPI.getInstance to get an instance. - */ - MixpanelAPI(Context context, Future referrerPreferences, String token, MPConfig config, MixpanelOptions options, boolean trackAutomaticEvents) { - mContext = context; - mToken = token; - mInstanceName = options.getInstanceName(); - mPeople = new PeopleImpl(); - mGroups = new HashMap(); - mConfig = config; - mTrackAutomaticEvents = trackAutomaticEvents; - - final Map deviceInfo = new HashMap(); - deviceInfo.put("$android_lib_version", MPConfig.VERSION); - deviceInfo.put("$android_os", "Android"); - deviceInfo.put("$android_os_version", Build.VERSION.RELEASE == null ? "UNKNOWN" : Build.VERSION.RELEASE); - deviceInfo.put("$android_manufacturer", Build.MANUFACTURER == null ? "UNKNOWN" : Build.MANUFACTURER); - deviceInfo.put("$android_brand", Build.BRAND == null ? "UNKNOWN" : Build.BRAND); - deviceInfo.put("$android_model", Build.MODEL == null ? "UNKNOWN" : Build.MODEL); + checkIntentForInboundAppLink(context); + + return instance; + } + } + + /** + * Controls whether to automatically send the client IP Address as part of event tracking. + * + *

With an IP address, geo-location is possible down to neighborhoods within a city, although + * the Mixpanel Dashboard will just show you city level location specificity. + * + * @param useIpAddressForGeolocation If true, automatically send the client IP Address. Defaults + * to true. + */ + public void setUseIpAddressForGeolocation(boolean useIpAddressForGeolocation) { + mConfig.setUseIpAddressForGeolocation(useIpAddressForGeolocation); + } + + /** + * Controls whether to enable the run time debug logging + * + * @param enableLogging If true, emit more detailed log messages. Defaults to false + */ + public void setEnableLogging(boolean enableLogging) { + mConfig.setEnableLogging(enableLogging); + } + + /** + * Set maximum number of events/updates to send in a single network request + * + * @param flushBatchSize int, the number of events to be flushed at a time, defaults to 50 + */ + public void setFlushBatchSize(int flushBatchSize) { + mConfig.setFlushBatchSize(flushBatchSize); + } + + /** + * Get maximum number of events/updates to send in a single network request + * + * @return the integer number of events to be flushed at a time + */ + public int getFlushBatchSize() { + return mConfig.getFlushBatchSize(); + } + + /** + * Set whether the request payload should be GZIP-compressed before being sent. + * + * @param shouldGzipRequestPayload boolean, true to enable GZIP compression, false otherwise. + */ + public void setShouldGzipRequestPayload(boolean shouldGzipRequestPayload) { + mConfig.setShouldGzipRequestPayload(shouldGzipRequestPayload); + } + + /** + * Get whether the request payload is currently set to be GZIP-compressed. + * + * @return boolean, whether GZIP compression is enabled + */ + public boolean shouldGzipRequestPayload() { + return mConfig.shouldGzipRequestPayload(); + } + + /** + * Set an integer number of bytes, the maximum size limit to the Mixpanel database. + * + * @param maximumDatabaseLimit an integer number of bytes, the maximum size limit to the Mixpanel + * database. + */ + public void setMaximumDatabaseLimit(int maximumDatabaseLimit) { + mConfig.setMaximumDatabaseLimit(maximumDatabaseLimit); + } + + /** + * Get the maximum size limit to the Mixpanel database. + * + * @return an integer number of bytes, the maximum size limit to the Mixpanel database. + */ + public int getMaximumDatabaseLimit() { + return mConfig.getMaximumDatabaseLimit(); + } + + /** + * Set the base URL used for Mixpanel API requests. Useful if you need to proxy Mixpanel requests. + * Defaults to https://api.mixpanel.com. To route data to Mixpanel's EU servers, set to + * https://api-eu.mixpanel.com + * + * @param serverURL the base URL used for Mixpanel API requests + */ + public void setServerURL(String serverURL) { + mConfig.setServerURL(serverURL); + } + + /** + * Set the base URL used for Mixpanel API requests. Useful if you need to proxy Mixpanel requests. + * Defaults to https://api.mixpanel.com. To route data to Mixpanel's EU servers, set to + * https://api-eu.mixpanel.com + * + * @param serverURL the base URL used for Mixpanel API requests + * @param callback the callback for mixpanel proxy server api headers and status + */ + public void setServerURL(String serverURL, ProxyServerInteractor callback) { + mConfig.setServerURL(serverURL, callback); + } + + /** + * Set the listener for network errors. + * + * @param listener + */ + public void setNetworkErrorListener(MixpanelNetworkErrorListener listener) { + AnalyticsMessages.getInstance(mContext, mConfig).setNetworkErrorListener(listener); + } + + public Boolean getTrackAutomaticEvents() { + return mTrackAutomaticEvents; + } + + /** + * This function creates a distinct_id alias from alias to distinct_id. If distinct_id is null, + * then it will create an alias to the current events distinct_id, which may be the distinct_id + * randomly generated by the Mixpanel library before {@link #identify(String)} is called. + * + *

This call does not identify the user after. You must still call {@link #identify(String)} if + * you wish the new alias to be used for Events and People. + * + * @param alias the new value that should represent distinct_id. + * @param distinct_id the old distinct_id that alias will be mapped to. + */ + public void alias(String alias, String distinct_id) { + if (hasOptedOutTracking()) return; + if (distinct_id == null) { + distinct_id = getDistinctId(); + } + if (alias.equals(distinct_id)) { + MPLog.w( + LOGTAG, + "Attempted to alias identical distinct_ids " + + alias + + ". Alias message will not be sent."); + return; + } + try { + final JSONObject j = new JSONObject(); + j.put("alias", alias); + j.put("distinct_id", distinct_id); + track("$create_alias", j); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Failed to alias", e); + } + flush(); + } + + /** + * Equivalent to {@link #identify(String, boolean)} with a true argument for usePeople. + * + *

By default, this method will also associate future calls to {@link People#set(JSONObject)}, + * {@link People#increment(Map)}, {@link People#append(String, Object)}, etc... with a particular + * People Analytics user with the distinct id. If you do not want to do that, you must call {@link + * #identify(String, boolean)} with false for second argument. NOTE: This behavior changed in + * version 6.2.0, previously {@link People#identify(String)} had to be called separately. + * + * @param distinctId a string uniquely identifying this user. Events sent to Mixpanel or Users + * identified using the same distinct id will be considered associated with the same + * visitor/customer for retention and funnel reporting, so be sure that the given value is + * globally unique for each individual user you intend to track. + */ + public void identify(String distinctId) { + identify(distinctId, true); + } + + /** + * Associate all future calls to {@link #track(String, JSONObject)} with the user identified by + * the given distinct id. + * + *

Calls to {@link #track(String, JSONObject)} made before corresponding calls to identify will + * use an anonymous locally generated distinct id, which means it is best to call identify early + * to ensure that your Mixpanel funnels and retention analytics can continue to track the user + * throughout their lifetime. We recommend calling identify when the user authenticates. + * + *

Once identify is called, the local distinct id persists across restarts of your application. + * + * @param distinctId a string uniquely identifying this user. Events sent to Mixpanel using the + * same disinct id will be considered associated with the same visitor/customer for retention + * and funnel reporting, so be sure that the given value is globally unique for each + * individual user you intend to track. + * @param usePeople boolean indicating whether or not to also call {@link People#identify(String)} + */ + public void identify(String distinctId, boolean usePeople) { + if (hasOptedOutTracking()) return; + if (distinctId == null) { + MPLog.e(LOGTAG, "Can't identify with null distinct_id."); + return; + } + synchronized (mPersistentIdentity) { + String currentEventsDistinctId = mPersistentIdentity.getEventsDistinctId(); + if (!distinctId.equals(currentEventsDistinctId)) { + if (distinctId.startsWith("$device:")) { + MPLog.e(LOGTAG, "Can't identify with '$device:' distinct_id."); + return; + } + + mPersistentIdentity.setEventsDistinctId(distinctId); + mPersistentIdentity.setAnonymousIdIfAbsent(currentEventsDistinctId); + mPersistentIdentity.markEventsUserIdPresent(); + mFeatureFlagManager.loadFlags(); try { - final PackageManager manager = mContext.getPackageManager(); - final PackageInfo info = manager.getPackageInfo(mContext.getPackageName(), 0); - deviceInfo.put("$android_app_version", info.versionName); - deviceInfo.put("$android_app_version_code", Integer.toString(info.versionCode)); - } catch (final PackageManager.NameNotFoundException e) { - MPLog.e(LOGTAG, "Exception getting app version name", e); + JSONObject identifyPayload = new JSONObject(); + identifyPayload.put("$anon_distinct_id", currentEventsDistinctId); + track("$identify", identifyPayload); + } catch (JSONException e) { + MPLog.e(LOGTAG, "Could not track $identify event"); } - mDeviceInfo = Collections.unmodifiableMap(deviceInfo); + } - mSessionMetadata = new SessionMetadata(); - mMessages = getAnalyticsMessages(); - mPersistentIdentity = getPersistentIdentity(context, referrerPreferences, token, options.getInstanceName()); - mEventTimings = mPersistentIdentity.getTimeEvents(); + if (usePeople) { + mPeople.identify_people(distinctId); + } + } + } + + /** + * Begin timing of an event. Calling timeEvent("Thing") will not send an event, but when you + * eventually call track("Thing"), your tracked event will be sent with a "$duration" property, + * representing the number of seconds between your calls. + * + * @param eventName the name of the event to track with timing. + */ + public void timeEvent(final String eventName) { + if (hasOptedOutTracking()) return; + final long writeTime = System.currentTimeMillis(); + synchronized (mEventTimings) { + mEventTimings.put(eventName, writeTime); + mPersistentIdentity.addTimeEvent(eventName, writeTime); + } + } + + /** Clears all current event timings. */ + public void clearTimedEvents() { + synchronized (mEventTimings) { + mEventTimings.clear(); + mPersistentIdentity.clearTimedEvents(); + } + } + + /** + * Clears the event timing for an event. + * + * @param eventName the name of the timed event to clear. + */ + public void clearTimedEvent(final String eventName) { + synchronized (mEventTimings) { + mEventTimings.remove(eventName); + mPersistentIdentity.removeTimedEvent(eventName); + } + } + + /** + * Retrieves the time elapsed for the named event since timeEvent() was called. + * + * @param eventName the name of the event to be tracked that was previously called with + * timeEvent() + * @return Time elapsed since {@link #timeEvent(String)} was called for the given eventName. + */ + public double eventElapsedTime(final String eventName) { + final long currentTime = System.currentTimeMillis(); + Long startTime; + synchronized (mEventTimings) { + startTime = mEventTimings.get(eventName); + } + return startTime == null ? 0 : (double) ((currentTime - startTime) / 1000); + } + + /** + * Track an event. + * + *

Every call to track eventually results in a data point sent to Mixpanel. These data points + * are what are measured, counted, and broken down to create your Mixpanel reports. Events have a + * string name, and an optional set of name/value pairs that describe the properties of that + * event. + * + * @param eventName The name of the event to send + * @param properties A Map containing the key value pairs of the properties to include in this + * event. Pass null if no extra properties exist. + *

See also {@link #track(String, org.json.JSONObject)} + */ + public void trackMap(String eventName, Map properties) { + if (hasOptedOutTracking()) return; + if (null == properties) { + track(eventName, null); + } else { + try { + track(eventName, new JSONObject(properties)); + } catch (NullPointerException e) { + MPLog.w(LOGTAG, "Can't have null keys in the properties of trackMap!"); + } + } + } + + /** + * Track an event with specific groups. + * + *

Every call to track eventually results in a data point sent to Mixpanel. These data points + * are what are measured, counted, and broken down to create your Mixpanel reports. Events have a + * string name, and an optional set of name/value pairs that describe the properties of that + * event. Group key/value pairs are upserted into the property map before tracking. + * + * @param eventName The name of the event to send + * @param properties A Map containing the key value pairs of the properties to include in this + * event. Pass null if no extra properties exist. + * @param groups A Map containing the group key value pairs for this event. + *

See also {@link #track(String, org.json.JSONObject)}, {@link #trackMap(String, Map)} + */ + public void trackWithGroups( + String eventName, Map properties, Map groups) { + if (hasOptedOutTracking()) return; + + if (null == groups) { + trackMap(eventName, properties); + } else if (null == properties) { + trackMap(eventName, groups); + } else { + for (Entry e : groups.entrySet()) { + if (e.getValue() != null) { + properties.put(e.getKey(), e.getValue()); + } + } - mFeatureFlagManager = new FeatureFlagManager( - this, - getHttpService(), - new FlagsConfig(options.areFeatureFlagsEnabled(), options.getFeatureFlagsContext()) - ); + trackMap(eventName, properties); + } + } + + /** + * Track an event. + * + *

Every call to track eventually results in a data point sent to Mixpanel. These data points + * are what are measured, counted, and broken down to create your Mixpanel reports. Events have a + * string name, and an optional set of name/value pairs that describe the properties of that + * event. + * + * @param eventName The name of the event to send + * @param properties A JSONObject containing the key value pairs of the properties to include in + * this event. Pass null if no extra properties exist. + */ + public void track(String eventName, JSONObject properties) { + if (hasOptedOutTracking()) return; + track(eventName, properties, false); + } + + /** + * Equivalent to {@link #track(String, JSONObject)} with a null argument for properties. Consider + * adding properties to your tracking to get the best insights and experience from Mixpanel. + * + * @param eventName the name of the event to send + */ + public void track(String eventName) { + if (hasOptedOutTracking()) return; + track(eventName, null); + } + + /** + * Push all queued Mixpanel events and People Analytics changes to Mixpanel servers. + * + *

Events and People messages are pushed gradually throughout the lifetime of your application. + * This means that to ensure that all messages are sent to Mixpanel when your application is shut + * down, you will need to call flush() to let the Mixpanel library know it should send all + * remaining messages to the server. We strongly recommend placing a call to flush() in the + * onDestroy() method of your main application activity. + */ + public void flush() { + if (hasOptedOutTracking()) return; + mMessages.postToServer(new AnalyticsMessages.MixpanelDescription(mToken)); + } + + /** + * Returns a json object of the user's current super properties + * + *

SuperProperties are a collection of properties that will be sent with every event to + * Mixpanel, and persist beyond the lifetime of your application. + * + * @return Super properties for this Mixpanel instance. + */ + public JSONObject getSuperProperties() { + JSONObject ret = new JSONObject(); + mPersistentIdentity.addSuperPropertiesToObject(ret); + return ret; + } + + /** + * Returns the string id currently being used to uniquely identify the user. Before any calls to + * {@link #identify(String)}, this will be an id automatically generated by the library. + * + * @return The distinct id that uniquely identifies the current user. + * @see #identify(String) + */ + public String getDistinctId() { + return mPersistentIdentity.getEventsDistinctId(); + } + + /** + * Returns the anonymoous id currently being used to uniquely identify the device and all with + * events sent using {@link #track(String, JSONObject)} will have this id as a device id + * + * @return The device id associated with event tracking + */ + public String getAnonymousId() { + return mPersistentIdentity.getAnonymousId(); + } + + /** + * Returns the user id with which identify is called and all the with events sent using {@link + * #track(String, JSONObject)} will have this id as a user id + * + * @return The user id associated with event tracking + */ + protected String getUserId() { + return mPersistentIdentity.getEventsUserId(); + } + + /** + * Retrieves the Mixpanel project token. + * + * @return The Mixpanel project token currently being used. + */ + public String getToken() { + return mToken; + } + + /** + * Retrieves the Mixpanel configuration object. + * + * @return The current {@link MPConfig} object containing Mixpanel settings. + */ + public MPConfig getMPConfig() { + return mConfig; + } + + /** + * Register properties that will be sent with every subsequent call to {@link #track(String, + * JSONObject)}. + * + *

SuperProperties are a collection of properties that will be sent with every event to + * Mixpanel, and persist beyond the lifetime of your application. + * + *

Setting a superProperty with registerSuperProperties will store a new superProperty, + * possibly overwriting any existing superProperty with the same name (to set a superProperty only + * if it is currently unset, use {@link #registerSuperPropertiesOnce(JSONObject)}) + * + *

SuperProperties will persist even if your application is taken completely out of memory. to + * remove a superProperty, call {@link #unregisterSuperProperty(String)} or {@link + * #clearSuperProperties()} + * + * @param superProperties A Map containing super properties to register + *

See also {@link #registerSuperProperties(org.json.JSONObject)} + */ + public void registerSuperPropertiesMap(Map superProperties) { + if (hasOptedOutTracking()) return; + if (null == superProperties) { + MPLog.e(LOGTAG, "registerSuperPropertiesMap does not accept null properties"); + return; + } + + try { + registerSuperProperties(new JSONObject(superProperties)); + } catch (NullPointerException e) { + MPLog.w(LOGTAG, "Can't have null keys in the properties of registerSuperPropertiesMap"); + } + } + + /** + * Register properties that will be sent with every subsequent call to {@link #track(String, + * JSONObject)}. + * + *

SuperProperties are a collection of properties that will be sent with every event to + * Mixpanel, and persist beyond the lifetime of your application. + * + *

Setting a superProperty with registerSuperProperties will store a new superProperty, + * possibly overwriting any existing superProperty with the same name (to set a superProperty only + * if it is currently unset, use {@link #registerSuperPropertiesOnce(JSONObject)}) + * + *

SuperProperties will persist even if your application is taken completely out of memory. to + * remove a superProperty, call {@link #unregisterSuperProperty(String)} or {@link + * #clearSuperProperties()} + * + * @param superProperties A JSONObject containing super properties to register + * @see #registerSuperPropertiesOnce(JSONObject) + * @see #unregisterSuperProperty(String) + * @see #clearSuperProperties() + */ + public void registerSuperProperties(JSONObject superProperties) { + if (hasOptedOutTracking()) return; + mPersistentIdentity.registerSuperProperties(superProperties); + } + + /** + * Remove a single superProperty, so that it will not be sent with future calls to {@link + * #track(String, JSONObject)}. + * + *

If there is a superProperty registered with the given name, it will be permanently removed + * from the existing superProperties. To clear all superProperties, use {@link + * #clearSuperProperties()} + * + * @param superPropertyName name of the property to unregister + * @see #registerSuperProperties(JSONObject) + */ + public void unregisterSuperProperty(String superPropertyName) { + if (hasOptedOutTracking()) return; + mPersistentIdentity.unregisterSuperProperty(superPropertyName); + } + + /** + * Register super properties for events, only if no other super property with the same names has + * already been registered. + * + *

Calling registerSuperPropertiesOnce will never overwrite existing properties. + * + * @param superProperties A Map containing the super properties to register. + *

See also {@link #registerSuperPropertiesOnce(org.json.JSONObject)} + */ + public void registerSuperPropertiesOnceMap(Map superProperties) { + if (hasOptedOutTracking()) return; + if (null == superProperties) { + MPLog.e(LOGTAG, "registerSuperPropertiesOnceMap does not accept null properties"); + return; + } + + try { + registerSuperPropertiesOnce(new JSONObject(superProperties)); + } catch (NullPointerException e) { + MPLog.w(LOGTAG, "Can't have null keys in the properties of registerSuperPropertiesOnce!"); + } + } + + /** + * Register super properties for events, only if no other super property with the same names has + * already been registered. + * + *

Calling registerSuperPropertiesOnce will never overwrite existing properties. + * + * @param superProperties A JSONObject containing the super properties to register. + * @see #registerSuperProperties(JSONObject) + */ + public void registerSuperPropertiesOnce(JSONObject superProperties) { + if (hasOptedOutTracking()) return; + mPersistentIdentity.registerSuperPropertiesOnce(superProperties); + } + + /** + * Erase all currently registered superProperties. + * + *

Future tracking calls to Mixpanel will not contain the specific superProperties registered + * before the clearSuperProperties method was called. + * + *

To remove a single superProperty, use {@link #unregisterSuperProperty(String)} + * + * @see #registerSuperProperties(JSONObject) + */ + public void clearSuperProperties() { + mPersistentIdentity.clearSuperProperties(); + } + + /** + * Updates super properties in place. Given a SuperPropertyUpdate object, will pass the current + * values of SuperProperties to that update and replace all results with the return value of the + * update. Updates are synchronized on the underlying super properties store, so they are + * guaranteed to be thread safe (but long running updates may slow down your tracking.) + * + * @param update A function from one set of super properties to another. The update should not + * return null. + */ + public void updateSuperProperties(SuperPropertyUpdate update) { + if (hasOptedOutTracking()) return; + mPersistentIdentity.updateSuperProperties(update); + } + + /** + * Set the group this user belongs to. + * + * @param groupKey The property name associated with this group type (must already have been set + * up). + * @param groupID The group the user belongs to. + */ + public void setGroup(String groupKey, Object groupID) { + if (hasOptedOutTracking()) return; + + List groupIDs = new ArrayList<>(1); + groupIDs.add(groupID); + setGroup(groupKey, groupIDs); + } + + /** + * Set the groups this user belongs to. + * + * @param groupKey The property name associated with this group type (must already have been set + * up). + * @param groupIDs The list of groups the user belongs to. + */ + public void setGroup(String groupKey, List groupIDs) { + if (hasOptedOutTracking()) return; + + JSONArray vals = new JSONArray(); + + for (Object s : groupIDs) { + if (s == null) { + MPLog.w(LOGTAG, "groupID must be non-null"); + } else { + vals.put(s); + } + } - mFeatureFlagManager.loadFlags(); + try { + registerSuperProperties((new JSONObject()).put(groupKey, vals)); + mPeople.set(groupKey, vals); + } catch (JSONException e) { + MPLog.w(LOGTAG, "groupKey must be non-null"); + } + } - if (options.isOptOutTrackingDefault() && (hasOptedOutTracking() || !mPersistentIdentity.hasOptOutFlag(token))) { - optOutTracking(); - } + /** + * Add a group to this user's membership for a particular group key + * + * @param groupKey The property name associated with this group type (must already have been set + * up). + * @param groupID The new group the user belongs to. + */ + public void addGroup(final String groupKey, final Object groupID) { + if (hasOptedOutTracking()) return; - if (options.getSuperProperties() != null) { - registerSuperProperties(options.getSuperProperties()); - } + updateSuperProperties( + new SuperPropertyUpdate() { + public JSONObject update(JSONObject in) { + try { + in.accumulate(groupKey, groupID); + } catch (JSONException e) { + MPLog.e(LOGTAG, "Failed to add groups superProperty", e); + } - final boolean dbExists = MPDbAdapter.getInstance(mContext, mConfig).getDatabaseFile().exists(); + return in; + } + }); - registerMixpanelActivityLifecycleCallbacks(); + // This is a best effort--if the people property is not already a list, this call does nothing. + mPeople.union(groupKey, (new JSONArray()).put(groupID)); + } + + /** + * Remove a group from this user's membership for a particular group key + * + * @param groupKey The property name associated with this group type (must already have been set + * up). + * @param groupID The group value to remove. + */ + public void removeGroup(final String groupKey, final Object groupID) { + if (hasOptedOutTracking()) return; + + updateSuperProperties( + new SuperPropertyUpdate() { + public JSONObject update(JSONObject in) { + try { + JSONArray vals = in.getJSONArray(groupKey); + JSONArray newVals = new JSONArray(); - if (mPersistentIdentity.isFirstLaunch(dbExists, mToken) && mTrackAutomaticEvents) { - track(AutomaticEvents.FIRST_OPEN, null, true); - mPersistentIdentity.setHasLaunched(mToken); - } + if (vals.length() <= 1) { + in.remove(groupKey); - if (sendAppOpen() && mTrackAutomaticEvents) { - track("$app_open", null); - } + // This is a best effort--we can't guarantee people and super properties match + mPeople.unset(groupKey); + } else { - if (mPersistentIdentity.isNewVersion(deviceInfo.get("$android_app_version_code")) && mTrackAutomaticEvents) { - try { - final JSONObject messageProps = new JSONObject(); - messageProps.put(AutomaticEvents.VERSION_UPDATED, deviceInfo.get("$android_app_version")); - track(AutomaticEvents.APP_UPDATED, messageProps, true); - } catch (JSONException e) {} - } + for (int i = 0; i < vals.length(); i++) { + if (!vals.get(i).equals(groupID)) { + newVals.put(vals.get(i)); + } + } - if (!mConfig.getDisableExceptionHandler()) { - ExceptionHandler.init(); - } + in.put(groupKey, newVals); - if (mConfig.getRemoveLegacyResidualFiles()) { - mMessages.removeResidualImageFiles(new File(mContext.getApplicationInfo().dataDir)); - } + // This is a best effort--we can't guarantee people and super properties match + // If people property is not a list, this call does nothing. + mPeople.remove(groupKey, groupID); + } + } catch (JSONException e) { + in.remove(groupKey); - // Event tracking integration w/ Session Replay SDK requires Android 13 or higher. - // It is also NOT supported in "Instant" apps - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !context.getPackageManager().isInstantApp()) { - BroadcastReceiver sessionReplayReceiver = new SessionReplayBroadcastReceiver(this); - ContextCompat.registerReceiver( - mContext.getApplicationContext(), - sessionReplayReceiver, - SessionReplayBroadcastReceiver.INTENT_FILTER, - ContextCompat.RECEIVER_NOT_EXPORTED - ); - } - } + // This is a best effort--we can't guarantee people and super properties match + mPeople.unset(groupKey); + } + + return in; + } + }); + } + + /** + * Returns a Mixpanel.People object that can be used to set and increment People Analytics + * properties. + * + * @return an instance of {@link People} that you can use to update records in Mixpanel People + * Analytics. + */ + public People getPeople() { + return mPeople; + } + + /** + * Returns a Mixpanel.Group object that can be used to set and increment Group Analytics + * properties. + * + * @param groupKey String identifying the type of group (must be already in use as a group key) + * @param groupID Object identifying the specific group + * @return an instance of {@link Group} that you can use to update records in Mixpanel Group + * Analytics + */ + public Group getGroup(String groupKey, Object groupID) { + String mapKey = makeMapKey(groupKey, groupID); + GroupImpl group = mGroups.get(mapKey); + + if (group == null) { + group = new GroupImpl(groupKey, groupID); + mGroups.put(mapKey, group); + } + + if (!(group.mGroupKey.equals(groupKey) && group.mGroupID.equals(groupID))) { + // we hit a map key collision, return a new group with the correct key and ID + MPLog.i(LOGTAG, "groups map key collision " + mapKey); + group = new GroupImpl(groupKey, groupID); + mGroups.put(mapKey, group); + } + + return group; + } + + private String makeMapKey(String groupKey, Object groupID) { + return groupKey + '_' + groupID; + } + + /** + * Returns a {@link Flags} object that can be used to retrieve and manage feature flags from + * Mixpanel. + * + * @return an instance of {@link Flags} that allows you to access feature flag configurations for + * your project. + */ + public Flags getFlags() { + return mFeatureFlagManager; + } + + /** + * Clears tweaks and all distinct_ids, superProperties, and push registrations from persistent + * storage. Will not clear referrer information. + */ + public void reset() { + // Will clear distinct_ids, superProperties, + // and waiting People Analytics properties. Will have no effect + // on messages already queued to send with AnalyticsMessages. + mPersistentIdentity.clearPreferences(); + getAnalyticsMessages() + .clearAnonymousUpdatesMessage(new AnalyticsMessages.MixpanelDescription(mToken)); + identify(getDistinctId(), false); + flush(); + } + + /** + * Returns an unmodifiable map that contains the device description properties that will be sent + * to Mixpanel. These are not all of the default properties, but are a subset that are dependant + * on the user's device or installed version of the host application, and are guaranteed not to + * change while the app is running. + * + * @return Map containing the device description properties that are sent to Mixpanel. + */ + public Map getDeviceInfo() { + return mDeviceInfo; + } + + /** + * Use this method to opt-out a user from tracking. Events and people updates that haven't been + * flushed yet will be deleted. Use {@link #flush()} before calling this method if you want to + * send all the queues to Mixpanel before. + * + *

This method will also remove any user-related information from the device. + */ + public void optOutTracking() { + getAnalyticsMessages().emptyTrackingQueues(new AnalyticsMessages.MixpanelDescription(mToken)); + if (getPeople().isIdentified()) { + getPeople().deleteUser(); + getPeople().clearCharges(); + } + mPersistentIdentity.clearPreferences(); + synchronized (mEventTimings) { + mEventTimings.clear(); + mPersistentIdentity.clearTimedEvents(); + } + mPersistentIdentity.clearReferrerProperties(); + mPersistentIdentity.setOptOutTracking(true, mToken); + } + + /** + * Use this method to opt-in an already opted-out user from tracking. People updates and track + * calls will be sent to Mixpanel after using this method. This method will internally track an + * opt-in event to your project. If you want to identify the opt-in event and/or pass properties + * to the event, see {@link #optInTracking(String)} and {@link #optInTracking(String, JSONObject)} + * + *

See also {@link #optOutTracking()}. + */ + public void optInTracking() { + optInTracking(null, null); + } + + /** + * Use this method to opt-in an already opted-out user from tracking. People updates and track + * calls will be sent to Mixpanel after using this method. This method will internally track an + * opt-in event to your project. + * + * @param distinctId Optional string to use as the distinct ID for events. This will call {@link + * #identify(String)}. + *

See also {@link #optInTracking(String)}, {@link #optInTracking(String, JSONObject)} and + * {@link #optOutTracking()}. + */ + public void optInTracking(String distinctId) { + optInTracking(distinctId, null); + } + + /** + * Use this method to opt-in an already opted-out user from tracking. People updates and track + * calls will be sent to Mixpanel after using this method. This method will internally track an + * opt-in event to your project. + * + * @param distinctId Optional string to use as the distinct ID for events. This will call {@link + * #identify(String)}. + * @param properties Optional JSONObject that could be passed to add properties to the opt-in + * event that is sent to Mixpanel. + *

See also {@link #optInTracking()} and {@link #optOutTracking()}. + */ + public void optInTracking(String distinctId, JSONObject properties) { + mPersistentIdentity.setOptOutTracking(false, mToken); + if (distinctId != null) { + identify(distinctId); + } + track("$opt_in", properties); + } + + /** + * Will return true if the user has opted out from tracking. See {@link #optOutTracking()} and + * {@link MixpanelAPI#getInstance(Context, String, boolean, JSONObject, String, boolean)} for more + * information. + * + * @return true if user has opted out from tracking. Defaults to false. + */ + public boolean hasOptedOutTracking() { + return mPersistentIdentity.getOptOutTracking(mToken); + } + + /** + * Core interface for using Mixpanel People Analytics features. You can get an instance by calling + * {@link MixpanelAPI#getPeople()} + * + *

The People object is used to update properties in a user's People Analytics record. For this + * reason, it's important to call {@link #identify(String)} on the People object before you work + * with it. Once you call identify, the user identity will persist across stops and starts of your + * application, until you make another call to identify using a different id. + * + *

A typical use case for the People object might look like this: + * + *

{@code
+   * public class MainActivity extends Activity {
+   *      MixpanelAPI mMixpanel;
+   *
+   *      public void onCreate(Bundle saved) {
+   *          mMixpanel = MixpanelAPI.getInstance(this, "YOUR MIXPANEL API TOKEN");
+   *          mMixpanel.identify("A UNIQUE ID FOR THIS USER");
+   *          ...
+   *      }
+   *
+   *      public void userUpdatedJobTitle(String newTitle) {
+   *          mMixpanel.getPeople().set("Job Title", newTitle);
+   *          ...
+   *      }
+   *
+   *      public void onDestroy() {
+   *          mMixpanel.flush();
+   *          super.onDestroy();
+   *      }
+   * }
+   *
+   * }
+ * + * @see MixpanelAPI + */ + public interface People { + /** + * @deprecated in 6.2.0 NOTE: This method is deprecated. Please use {@link + * MixpanelAPI#identify(String)} instead. + * @param distinctId a String that uniquely identifies the user. Users identified with the same + * distinct id will be considered to be the same user in Mixpanel, across all platforms and + * devices. We recommend choosing a distinct id that is meaningful to your other systems + * (for example, a server-side account identifier) + * @see MixpanelAPI#identify(String) + */ + @Deprecated + void identify(String distinctId); + + /** + * Sets a single property with the given name and value for this user. The given name and value + * will be assigned to the user in Mixpanel People Analytics, possibly overwriting an existing + * property with the same name. + * + * @param propertyName The name of the Mixpanel property. This must be a String, for example + * "Zip Code" + * @param value The value of the Mixpanel property. For "Zip Code", this value might be the + * String "90210" + */ + void set(String propertyName, Object value); /** - * Get the instance of MixpanelAPI associated with your Mixpanel project token. - * - *

Use getInstance to get a reference to a shared - * instance of MixpanelAPI you can use to send events - * and People Analytics updates to Mixpanel.

- *

getInstance is thread safe, but the returned instance is not, - * and may be shared with other callers of getInstance. - * The best practice is to call getInstance, and use the returned MixpanelAPI, - * object from a single thread (probably the main UI thread of your application).

- *

If you do choose to track events from multiple threads in your application, - * you should synchronize your calls on the instance itself, like so:

- *
-     * {@code
-     * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
-     * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
-     *     instance.track(...)
-     * }
-     * }
-     * 
+ * Set a collection of properties on the identified user all at once. * - * @param context The application context you are tracking - * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web site, - * in the settings dialog. - * @param trackAutomaticEvents Whether or not to collect common mobile events - * include app sessions, first app opens, app updated, etc. - * @return an instance of MixpanelAPI associated with your project + * @param properties a Map containing the collection of properties you wish to apply to the + * identified user. Each key in the Map will be associated with a property name, and the + * value of that key will be assigned to the property. + *

See also {@link #set(org.json.JSONObject)} */ - public static MixpanelAPI getInstance(Context context, String token, boolean trackAutomaticEvents) { - return getInstance(context, token, false, null, null, trackAutomaticEvents); - } + void setMap(Map properties); /** - * Get the instance of MixpanelAPI associated with your Mixpanel project token. - * - *

Use getInstance to get a reference to a shared - * instance of MixpanelAPI you can use to send events - * and People Analytics updates to Mixpanel.

- *

getInstance is thread safe, but the returned instance is not, - * and may be shared with other callers of getInstance. - * The best practice is to call getInstance, and use the returned MixpanelAPI, - * object from a single thread (probably the main UI thread of your application).

- *

If you do choose to track events from multiple threads in your application, - * you should synchronize your calls on the instance itself, like so:

- *
-     * {@code
-     * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
-     * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
-     *     instance.track(...)
-     * }
-     * }
-     * 
+ * Set a collection of properties on the identified user all at once. * - * @param context The application context you are tracking - * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web site, - * in the settings dialog. - * @param instanceName The name you want to uniquely identify the Mixpanel Instance. - * It is useful when you want more than one Mixpanel instance under the same project token - * @param trackAutomaticEvents Whether or not to collect common mobile events - * include app sessions, first app opens, app updated, etc. - * @return an instance of MixpanelAPI associated with your project + * @param properties a JSONObject containing the collection of properties you wish to apply to + * the identified user. Each key in the JSONObject will be associated with a property name, + * and the value of that key will be assigned to the property. */ - public static MixpanelAPI getInstance(Context context, String token, String instanceName, boolean trackAutomaticEvents) { - return getInstance(context, token, false, null, instanceName, trackAutomaticEvents); - } + void set(JSONObject properties); /** - * Get the instance of MixpanelAPI associated with your Mixpanel project token. - * - *

Use getInstance to get a reference to a shared - * instance of MixpanelAPI you can use to send events - * and People Analytics updates to Mixpanel.

- *

getInstance is thread safe, but the returned instance is not, - * and may be shared with other callers of getInstance. - * The best practice is to call getInstance, and use the returned MixpanelAPI, - * object from a single thread (probably the main UI thread of your application).

- *

If you do choose to track events from multiple threads in your application, - * you should synchronize your calls on the instance itself, like so:

- *
-     * {@code
-     * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
-     * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
-     *     instance.track(...)
-     * }
-     * }
-     * 
+ * Works just like {@link People#set(String, Object)}, except it will not overwrite existing + * property values. This is useful for properties like "First login date". * - * @param context The application context you are tracking - * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web site, - * in the settings dialog. - * @param optOutTrackingDefault Whether or not Mixpanel can start tracking by default. See - * {@link #optOutTracking()}. - * @param trackAutomaticEvents Whether or not to collect common mobile events - * include app sessions, first app opens, app updated, etc. - * @return an instance of MixpanelAPI associated with your project + * @param propertyName The name of the Mixpanel property. This must be a String, for example + * "Zip Code" + * @param value The value of the Mixpanel property. For "Zip Code", this value might be the + * String "90210" */ - public static MixpanelAPI getInstance(Context context, String token, boolean optOutTrackingDefault, boolean trackAutomaticEvents) { - return getInstance(context, token, optOutTrackingDefault, null, null, trackAutomaticEvents); - } + void setOnce(String propertyName, Object value); /** - * Get the instance of MixpanelAPI associated with your Mixpanel project token. - * - *

Use getInstance to get a reference to a shared - * instance of MixpanelAPI you can use to send events - * and People Analytics updates to Mixpanel.

- *

getInstance is thread safe, but the returned instance is not, - * and may be shared with other callers of getInstance. - * The best practice is to call getInstance, and use the returned MixpanelAPI, - * object from a single thread (probably the main UI thread of your application).

- *

If you do choose to track events from multiple threads in your application, - * you should synchronize your calls on the instance itself, like so:

- *
-     * {@code
-     * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
-     * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
-     *     instance.track(...)
-     * }
-     * }
-     * 
+ * Like {@link People#set(String, Object)}, but will not set properties that already exist on a + * record. * - * @param context The application context you are tracking - * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web site, - * in the settings dialog. - * @param optOutTrackingDefault Whether or not Mixpanel can start tracking by default. See - * {@link #optOutTracking()}. - * @param instanceName The name you want to uniquely identify the Mixpanel Instance. - It is useful when you want more than one Mixpanel instance under the same project token. - * @param trackAutomaticEvents Whether or not to collect common mobile events - * include app sessions, first app opens, app updated, etc. - * @return an instance of MixpanelAPI associated with your project + * @param properties a Map containing the collection of properties you wish to apply to the + * identified user. Each key in the Map will be associated with a property name, and the + * value of that key will be assigned to the property. + *

See also {@link #setOnce(org.json.JSONObject)} */ - public static MixpanelAPI getInstance(Context context, String token, boolean optOutTrackingDefault, String instanceName, boolean trackAutomaticEvents) { - return getInstance(context, token, optOutTrackingDefault, null, instanceName, trackAutomaticEvents); - } + void setOnceMap(Map properties); /** - * Get the instance of MixpanelAPI associated with your Mixpanel project token. - * - *

Use getInstance to get a reference to a shared - * instance of MixpanelAPI you can use to send events - * and People Analytics updates to Mixpanel.

- *

getInstance is thread safe, but the returned instance is not, - * and may be shared with other callers of getInstance. - * The best practice is to call getInstance, and use the returned MixpanelAPI, - * object from a single thread (probably the main UI thread of your application).

- *

If you do choose to track events from multiple threads in your application, - * you should synchronize your calls on the instance itself, like so:

- *
-     * {@code
-     * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
-     * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
-     *     instance.track(...)
-     * }
-     * }
-     * 
+ * Like {@link People#set(String, Object)}, but will not set properties that already exist on a + * record. * - * @param context The application context you are tracking - * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web site, - * in the settings dialog. - * @param superProperties A JSONObject containing super properties to register. - * @param trackAutomaticEvents Whether or not to collect common mobile events - * include app sessions, first app opens, app updated, etc. - * @return an instance of MixpanelAPI associated with your project + * @param properties a JSONObject containing the collection of properties you wish to apply to + * the identified user. Each key in the JSONObject will be associated with a property name, + * and the value of that key will be assigned to the property. */ - public static MixpanelAPI getInstance(Context context, String token, JSONObject superProperties, boolean trackAutomaticEvents) { - return getInstance(context, token, false, superProperties, null, trackAutomaticEvents); - } + void setOnce(JSONObject properties); /** - * Get the instance of MixpanelAPI associated with your Mixpanel project token. - * - *

Use getInstance to get a reference to a shared - * instance of MixpanelAPI you can use to send events - * and People Analytics updates to Mixpanel.

- *

getInstance is thread safe, but the returned instance is not, - * and may be shared with other callers of getInstance. - * The best practice is to call getInstance, and use the returned MixpanelAPI, - * object from a single thread (probably the main UI thread of your application).

- *

If you do choose to track events from multiple threads in your application, - * you should synchronize your calls on the instance itself, like so:

- *
-     * {@code
-     * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
-     * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
-     *     instance.track(...)
-     * }
-     * }
-     * 
+ * Add the given amount to an existing property on the identified user. If the user does not + * already have the associated property, the amount will be added to zero. To reduce a property, + * provide a negative number for the value. * - * @param context The application context you are tracking - * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web site, - * in the settings dialog. - * @param superProperties A JSONObject containing super properties to register. - * @param instanceName The name you want to uniquely identify the Mixpanel Instance. - * It is useful when you want more than one Mixpanel instance under the same project token - * @param trackAutomaticEvents Whether or not to collect common mobile events - * include app sessions, first app opens, app updated, etc. - * @return an instance of MixpanelAPI associated with your project + * @param name the People Analytics property that should have its value changed + * @param increment the amount to be added to the current value of the named property + * @see #increment(Map) */ - public static MixpanelAPI getInstance(Context context, String token, JSONObject superProperties, String instanceName, boolean trackAutomaticEvents) { - return getInstance(context, token, false, superProperties, instanceName, trackAutomaticEvents); - } + void increment(String name, double increment); + /** - * Get the instance of MixpanelAPI associated with your Mixpanel project token. + * Merge a given JSONObject into the object-valued property named name. If the user does not + * already have the associated property, an new property will be created with the value of the + * given updates. If the user already has a value for the given property, the updates will be + * merged into the existing value, with key/value pairs in updates taking precedence over + * existing key/value pairs where the keys are the same. * - *

Use getInstance to get a reference to a shared - * instance of MixpanelAPI you can use to send events - * and People Analytics updates to Mixpanel.

- *

getInstance is thread safe, but the returned instance is not, - * and may be shared with other callers of getInstance. - * The best practice is to call getInstance, and use the returned MixpanelAPI, - * object from a single thread (probably the main UI thread of your application).

- *

If you do choose to track events from multiple threads in your application, - * you should synchronize your calls on the instance itself, like so:

- *
-     * {@code
-     * MixpanelAPI instance = MixpanelAPI.getInstance(context, token);
-     * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
-     *     instance.track(...)
-     * }
-     * }
-     * 
- * - * @param context The application context you are tracking - * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web site, - * in the settings dialog. - * @param optOutTrackingDefault Whether or not Mixpanel can start tracking by default. See - * {@link #optOutTracking()}. - * @param superProperties A JSONObject containing super properties to register. - * @param instanceName The name you want to uniquely identify the Mixpanel Instance. - * It is useful when you want more than one Mixpanel instance under the same project token - * @param trackAutomaticEvents Whether or not to collect common mobile events - * include app sessions, first app opens, app updated, etc. - * @return an instance of MixpanelAPI associated with your project + * @param name the People Analytics property that should have the update merged into it + * @param updates a JSONObject with keys and values that will be merged into the property */ - public static MixpanelAPI getInstance(Context context, String token, boolean optOutTrackingDefault, JSONObject superProperties, String instanceName, boolean trackAutomaticEvents) { - MixpanelOptions options = new MixpanelOptions.Builder() - .instanceName(instanceName) - .optOutTrackingDefault(optOutTrackingDefault) - .superProperties(superProperties) - .build(); - return getInstance(context, token, trackAutomaticEvents, options); - } + void merge(String name, JSONObject updates); /** - * Get the instance of MixpanelAPI associated with your Mixpanel project token - * and configured with the provided options. + * Change the existing values of multiple People Analytics properties at once. * - *

Use getInstance to get a reference to a shared - * instance of MixpanelAPI you can use to send events - * and People Analytics updates to Mixpanel. This overload allows for more - * detailed configuration via the {@link MixpanelOptions} parameter.

- *

getInstance is thread safe, but the returned instance is not, - * and may be shared with other callers of getInstance. - * The best practice is to call getInstance, and use the returned MixpanelAPI, - * object from a single thread (probably the main UI thread of your application).

- *

If you do choose to track events from multiple threads in your application, - * you should synchronize your calls on the instance itself, like so:

- *
-     * {@code
-     * MixpanelAPI instance = MixpanelAPI.getInstance(context, token, true, options);
-     * synchronized(instance) { // Only necessary if the instance will be used in multiple threads.
-     * instance.track(...)
-     * }
-     * }
-     * 
+ *

If the user does not already have the associated property, the amount will be added to + * zero. To reduce a property, provide a negative number for the value. * - * @param context The application context you are tracking. - * @param token Your Mixpanel project token. You can get your project token on the Mixpanel web site, - * in the settings dialog. - * @param trackAutomaticEvents Whether or not to collect common mobile events - * such as app sessions, first app opens, app updates, etc. - * @param options An instance of {@link MixpanelOptions} to configure the MixpanelAPI instance. - * This allows setting options like {@code optOutTrackingDefault}, - * {@code superProperties}, and {@code instanceName}. Other options within - * MixpanelOptions may be used by other SDK features if applicable. - * @return an instance of MixpanelAPI associated with your project and configured - * with the specified options. + * @param properties A map of String properties names to Long amounts. Each property associated + * with a name in the map will have its value changed by the given amount + * @see #increment(String, double) */ - public static MixpanelAPI getInstance(Context context, String token, boolean trackAutomaticEvents, MixpanelOptions options) { - if (null == token || null == context) { - return null; - } - synchronized (sInstanceMap) { - final Context appContext = context.getApplicationContext(); - - if (null == sReferrerPrefs) { - sReferrerPrefs = sPrefsLoader.loadPreferences(context, MPConfig.REFERRER_PREFS_NAME, null); - } - String instanceKey = options.getInstanceName() != null ? options.getInstanceName() : token; - Map instances = sInstanceMap.get(instanceKey); - if (null == instances) { - instances = new HashMap(); - sInstanceMap.put(instanceKey, instances); - } - - MixpanelAPI instance = instances.get(appContext); - if (null == instance && ConfigurationChecker.checkBasicConfiguration(appContext)) { - instance = new MixpanelAPI(appContext, sReferrerPrefs, token, MPConfig.getInstance(context, options.getInstanceName()), options, trackAutomaticEvents); - registerAppLinksListeners(context, instance); - instances.put(appContext, instance); - } - - checkIntentForInboundAppLink(context); - - return instance; - } - } + void increment(Map properties); /** - * Controls whether to automatically send the client IP Address as part of event tracking. + * Appends a value to a list-valued property. If the property does not currently exist, it will + * be created as a list of one element. If the property does exist and doesn't currently have a + * list value, the append will be ignored. * - *

With an IP address, geo-location is possible down to neighborhoods within a city, - * although the Mixpanel Dashboard will just show you city level location specificity. - * - * @param useIpAddressForGeolocation If true, automatically send the client IP Address. Defaults to true. + * @param name the People Analytics property that should have it's value appended to + * @param value the new value that will appear at the end of the property's list */ - public void setUseIpAddressForGeolocation(boolean useIpAddressForGeolocation) { - mConfig.setUseIpAddressForGeolocation(useIpAddressForGeolocation); - } + void append(String name, Object value); /** - * Controls whether to enable the run time debug logging + * Adds values to a list-valued property only if they are not already present in the list. If + * the property does not currently exist, it will be created with the given list as it's value. + * If the property exists and is not list-valued, the union will be ignored. * - * @param enableLogging If true, emit more detailed log messages. Defaults to false + * @param name name of the list-valued property to set or modify + * @param value an array of values to add to the property value if not already present */ - public void setEnableLogging(boolean enableLogging) { - mConfig.setEnableLogging(enableLogging); - } + void union(String name, JSONArray value); /** - * Set maximum number of events/updates to send in a single network request + * Remove value from a list-valued property only if they are already present in the list. If the + * property does not currently exist, the remove will be ignored. If the property exists and is + * not list-valued, the remove will be ignored. * - * @param flushBatchSize int, the number of events to be flushed at a time, defaults to 50 + * @param name the People Analytics property that should have it's value removed from + * @param value the value that will be removed from the property's list */ - public void setFlushBatchSize(int flushBatchSize) { - mConfig.setFlushBatchSize(flushBatchSize); - } + void remove(String name, Object value); /** - * Get maximum number of events/updates to send in a single network request + * permanently removes the property with the given name from the user's profile * - * @return the integer number of events to be flushed at a time + * @param name name of a property to unset */ - public int getFlushBatchSize() { - return mConfig.getFlushBatchSize(); - } + void unset(String name); /** - * Set whether the request payload should be GZIP-compressed before being sent. + * Track a revenue transaction for the identified people profile. * - * @param shouldGzipRequestPayload boolean, true to enable GZIP compression, false otherwise. + * @param amount the amount of money exchanged. Positive amounts represent purchases or income + * from the customer, negative amounts represent refunds or payments to the customer. + * @param properties an optional collection of properties to associate with this transaction. */ - public void setShouldGzipRequestPayload(boolean shouldGzipRequestPayload) { - mConfig.setShouldGzipRequestPayload(shouldGzipRequestPayload); - } + void trackCharge(double amount, JSONObject properties); + + /** Permanently clear the whole transaction history for the identified people profile. */ + void clearCharges(); /** - * Get whether the request payload is currently set to be GZIP-compressed. + * Permanently deletes the identified user's record from People Analytics. * - * @return boolean, whether GZIP compression is enabled + *

Calling deleteUser deletes an entire record completely. Any future calls to People + * Analytics using the same distinct id will create and store new values. */ - public boolean shouldGzipRequestPayload() { - return mConfig.shouldGzipRequestPayload(); - } - + void deleteUser(); + /** - * Set an integer number of bytes, the maximum size limit to the Mixpanel database. + * Checks if the people profile is identified or not. * - * @param maximumDatabaseLimit an integer number of bytes, the maximum size limit to the Mixpanel database. + * @return Whether the current user is identified or not. */ - public void setMaximumDatabaseLimit(int maximumDatabaseLimit) { - mConfig.setMaximumDatabaseLimit(maximumDatabaseLimit); - } + boolean isIdentified(); /** - * Get the maximum size limit to the Mixpanel database. + * Returns the string id currently being used to uniquely identify the user associated with + * events sent using {@link People#set(String, Object)} and {@link People#increment(String, + * double)}. If no calls to {@link MixpanelAPI#identify(String)} have been made, this method + * will return null. * - * @return an integer number of bytes, the maximum size limit to the Mixpanel database. + * @deprecated in 6.2.0 NOTE: This method is deprecated. Please use {@link + * MixpanelAPI#getDistinctId()} instead. + * @return The distinct id associated with updates to People Analytics + * @see People#identify(String) + * @see MixpanelAPI#getDistinctId() */ - public int getMaximumDatabaseLimit() { - return mConfig.getMaximumDatabaseLimit(); - } + @Deprecated + String getDistinctId(); /** - * Set the base URL used for Mixpanel API requests. - * Useful if you need to proxy Mixpanel requests. Defaults to https://api.mixpanel.com. - * To route data to Mixpanel's EU servers, set to https://api-eu.mixpanel.com + * Return an instance of Mixpanel people with a temporary distinct id. * - * @param serverURL the base URL used for Mixpanel API requests + * @param distinctId Unique identifier (distinct_id) that the people object will have + * @return An instance of {@link MixpanelAPI.People} with the specified distinct_id */ - public void setServerURL(String serverURL) { - mConfig.setServerURL(serverURL); - } + People withIdentity(String distinctId); + } + /** + * Core interface for using Mixpanel Group Analytics features. You can get an instance by calling + * {@link MixpanelAPI#getGroup(String, Object)} + * + *

The Group object is used to update properties in a group's Group Analytics record. + * + *

A typical use case for the Group object might look like this: + * + *

{@code
+   * public class MainActivity extends Activity {
+   *      MixpanelAPI mMixpanel;
+   *
+   *      public void onCreate(Bundle saved) {
+   *          mMixpanel = MixpanelAPI.getInstance(this, "YOUR MIXPANEL API TOKEN");
+   *          ...
+   *      }
+   *
+   *      public void companyPlanTypeChanged(string company, String newPlan) {
+   *          mMixpanel.getGroup("Company", company).set("Plan Type", newPlan);
+   *          ...
+   *      }
+   *
+   *      public void onDestroy() {
+   *          mMixpanel.flush();
+   *          super.onDestroy();
+   *      }
+   * }
+   *
+   * }
+ * + * @see MixpanelAPI + */ + public interface Group { /** - * Set the base URL used for Mixpanel API requests. - * Useful if you need to proxy Mixpanel requests. Defaults to https://api.mixpanel.com. - * To route data to Mixpanel's EU servers, set to https://api-eu.mixpanel.com + * Sets a single property with the given name and value for this group. The given name and value + * will be assigned to the user in Mixpanel Group Analytics, possibly overwriting an existing + * property with the same name. * - * @param serverURL the base URL used for Mixpanel API requests - * @param callback the callback for mixpanel proxy server api headers and status + * @param propertyName The name of the Mixpanel property. This must be a String, for example + * "Zip Code" + * @param value The value of the Mixpanel property. For "Zip Code", this value might be the + * String "90210" */ - public void setServerURL(String serverURL, ProxyServerInteractor callback) { - mConfig.setServerURL(serverURL, callback); - } + void set(String propertyName, Object value); /** - * Set the listener for network errors. - * @param listener + * Set a collection of properties on the identified group all at once. + * + * @param properties a Map containing the collection of properties you wish to apply to the + * identified group. Each key in the Map will be associated with a property name, and the + * value of that key will be assigned to the property. + *

See also {@link #set(org.json.JSONObject)} */ - public void setNetworkErrorListener(MixpanelNetworkErrorListener listener) { - AnalyticsMessages.getInstance(mContext, mConfig).setNetworkErrorListener(listener); - } + void setMap(Map properties); - public Boolean getTrackAutomaticEvents() { return mTrackAutomaticEvents; } /** - * This function creates a distinct_id alias from alias to distinct_id. If distinct_id is null, then it will create an alias - * to the current events distinct_id, which may be the distinct_id randomly generated by the Mixpanel library - * before {@link #identify(String)} is called. - * - *

This call does not identify the user after. You must still call {@link #identify(String)} if you wish the new alias to be used for Events and People. + * Set a collection of properties on the identified group all at once. * - * @param alias the new value that should represent distinct_id. - * @param distinct_id the old distinct_id that alias will be mapped to. + * @param properties a JSONObject containing the collection of properties you wish to apply to + * the identified group. Each key in the JSONObject will be associated with a property name, + * and the value of that key will be assigned to the property. */ - public void alias(String alias, String distinct_id) { - if (hasOptedOutTracking()) return; - if (distinct_id == null) { - distinct_id = getDistinctId(); - } - if (alias.equals(distinct_id)) { - MPLog.w(LOGTAG, "Attempted to alias identical distinct_ids " + alias + ". Alias message will not be sent."); - return; - } - try { - final JSONObject j = new JSONObject(); - j.put("alias", alias); - j.put("distinct_id", distinct_id); - track("$create_alias", j); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Failed to alias", e); - } - flush(); - } + void set(JSONObject properties); /** - * Equivalent to {@link #identify(String, boolean)} with a true argument for usePeople. - * - *

By default, this method will also associate future calls - * to {@link People#set(JSONObject)}, {@link People#increment(Map)}, {@link People#append(String, Object)}, etc... - * with a particular People Analytics user with the distinct id. - * If you do not want to do that, you must call {@link #identify(String, boolean)} with false for second argument. - * NOTE: This behavior changed in version 6.2.0, previously {@link People#identify(String)} had - * to be called separately. + * Works just like {@link Group#set(String, Object)}, except it will not overwrite existing + * property values. This is useful for properties like "First login date". * - * @param distinctId a string uniquely identifying this user. Events sent to - * Mixpanel or Users identified using the same distinct id will be considered associated with the - * same visitor/customer for retention and funnel reporting, so be sure that the given - * value is globally unique for each individual user you intend to track. + * @param propertyName The name of the Mixpanel property. This must be a String, for example + * "Zip Code" + * @param value The value of the Mixpanel property. For "Zip Code", this value might be the + * String "90210" */ - public void identify(String distinctId) { - identify(distinctId, true); - } + void setOnce(String propertyName, Object value); + + /** + * Like {@link Group#set(String, Object)}, but will not set properties that already exist on a + * record. + * + * @param properties a Map containing the collection of properties you wish to apply to the + * identified group. Each key in the Map will be associated with a property name, and the + * value of that key will be assigned to the property. + *

See also {@link #setOnce(org.json.JSONObject)} + */ + void setOnceMap(Map properties); + + /** + * Like {@link Group#set(String, Object)}, but will not set properties that already exist on a + * record. + * + * @param properties a JSONObject containing the collection of properties you wish to apply to + * this group. Each key in the JSONObject will be associated with a property name, and the + * value of that key will be assigned to the property. + */ + void setOnce(JSONObject properties); + + /** + * Adds values to a list-valued property only if they are not already present in the list. If + * the property does not currently exist, it will be created with the given list as its value. + * If the property exists and is not list-valued, the union will be ignored. + * + * @param name name of the list-valued property to set or modify + * @param value an array of values to add to the property value if not already present + */ + void union(String name, JSONArray value); /** - * Associate all future calls to {@link #track(String, JSONObject)} with the user identified by - * the given distinct id. - * - *

Calls to {@link #track(String, JSONObject)} made before corresponding calls to identify - * will use an anonymous locally generated distinct id, which means it is best to call identify - * early to ensure that your Mixpanel funnels and retention analytics can continue to track the - * user throughout their lifetime. We recommend calling identify when the user authenticates. - * - *

Once identify is called, the local distinct id persists across restarts of - * your application. - * - * @param distinctId a string uniquely identifying this user. Events sent to - * Mixpanel using the same disinct id will be considered associated with the - * same visitor/customer for retention and funnel reporting, so be sure that the given - * value is globally unique for each individual user you intend to track. - * - * @param usePeople boolean indicating whether or not to also call - * {@link People#identify(String)} + * Remove value from a list-valued property only if it is already present in the list. If the + * property does not currently exist, the remove will be ignored. If the property exists and is + * not list-valued, the remove will be ignored. * + * @param name the Group Analytics list-valued property that should have a value removed + * @param value the value that will be removed from the list */ - public void identify(String distinctId, boolean usePeople) { - if (hasOptedOutTracking()) return; - if (distinctId == null) { - MPLog.e(LOGTAG, "Can't identify with null distinct_id."); - return; - } - synchronized (mPersistentIdentity) { - String currentEventsDistinctId = mPersistentIdentity.getEventsDistinctId(); - if (!distinctId.equals(currentEventsDistinctId)) { - if (distinctId.startsWith("$device:")) { - MPLog.e(LOGTAG, "Can't identify with '$device:' distinct_id."); - return; - } - - mPersistentIdentity.setEventsDistinctId(distinctId); - mPersistentIdentity.setAnonymousIdIfAbsent(currentEventsDistinctId); - mPersistentIdentity.markEventsUserIdPresent(); - mFeatureFlagManager.loadFlags(); - try { - JSONObject identifyPayload = new JSONObject(); - identifyPayload.put("$anon_distinct_id", currentEventsDistinctId); - track("$identify", identifyPayload); - } catch (JSONException e) { - MPLog.e(LOGTAG, "Could not track $identify event"); - } - } - - if (usePeople) { - mPeople.identify_people(distinctId); - } - } - } + void remove(String name, Object value); /** - * Begin timing of an event. Calling timeEvent("Thing") will not send an event, but - * when you eventually call track("Thing"), your tracked event will be sent with a "$duration" - * property, representing the number of seconds between your calls. + * Permanently removes the property with the given name from the group's profile * - * @param eventName the name of the event to track with timing. + * @param name name of a property to unset */ - public void timeEvent(final String eventName) { - if (hasOptedOutTracking()) return; - final long writeTime = System.currentTimeMillis(); - synchronized (mEventTimings) { - mEventTimings.put(eventName, writeTime); - mPersistentIdentity.addTimeEvent(eventName, writeTime); - } - } + void unset(String name); /** - * Clears all current event timings. + * Permanently deletes this group's record from Group Analytics. * + *

Calling deleteGroup deletes an entire record completely. Any future calls to Group + * Analytics using the same group value will create and store new values. */ - public void clearTimedEvents() { - synchronized (mEventTimings) { - mEventTimings.clear(); - mPersistentIdentity.clearTimedEvents(); - } - } + void deleteGroup(); + } + + /** + * Core interface for using Mixpanel Feature Flags. You can get an instance by calling {@link + * MixpanelAPI#getFlags()} (assuming such a method exists). + * + *

The Flags interface allows you to manage and retrieve feature flags defined in your Mixpanel + * project. Feature flags can be used to remotely configure your application's behavior, roll out + * new features gradually, or run A/B tests. + * + *

It's recommended to load flags early in your application's lifecycle, for example, in your + * main Application class or main Activity's {@code onCreate} method. + * + *

A typical use case for the Flags interface might look like this: + * + *

{@code
+   * public class MainActivity extends Activity {
+   * MixpanelAPI mMixpanel;
+   * Flags mFlags;
+   *
+   * public void onCreate(Bundle saved) {
+   * super.onCreate(saved);
+   * MixanelOptions mpOptions = new MixpanelOptions.Builder().featureFlagsEnabled(true).build();
+   * mMixpanel = MixpanelAPI.getInstance(this, "YOUR MIXPANEL TOKEN", true, mpOptions);
+   * mFlags = mMixpanel.getFlags();
+   *
+   * // Asynchronously load flags
+   * mFlags.loadFlags();
+   *
+   * // Example of checking a flag asynchronously
+   * mFlags.isFlagEnabled("new-checkout-flow", false, isEnabled -> {
+   * if (isEnabled) {
+   * // Show new checkout flow
+   * } else {
+   * // Show old checkout flow
+   * }
+   * });
+   *
+   * // Example of getting a flag value synchronously after ensuring flags are ready
+   * if (mFlags.areFlagsReady()) {
+   * String buttonLabel = (String) mFlags.getVariantValueSync("home-button-label", "Default Label");
+   * // Use buttonLabel
+   * }
+   * }
+   * }
+   *
+   * }
+ * + * @see MixpanelAPI + */ + public interface Flags { + + // --- Public Methods --- /** - * Clears the event timing for an event. - * - * @param eventName the name of the timed event to clear. + * Asynchronously loads flags from the Mixpanel server if they haven't been loaded yet or if the + * cached flags have expired. This method will initiate a network request if necessary. + * Subsequent calls to get flag values (especially asynchronous ones) may trigger this load if + * flags are not yet available. */ - public void clearTimedEvent(final String eventName) { - synchronized (mEventTimings) { - mEventTimings.remove(eventName); - mPersistentIdentity.removeTimedEvent(eventName); - } - } + void loadFlags(); /** - * Retrieves the time elapsed for the named event since timeEvent() was called. - * - * @param eventName the name of the event to be tracked that was previously called with timeEvent() + * Returns true if flags have been successfully loaded from the server and are currently + * available for synchronous access. This is useful to check before calling synchronous flag + * retrieval methods like {@link #getVariantSync(String, MixpanelFlagVariant)} to avoid them + * returning the fallback value immediately. * - * @return Time elapsed since {@link #timeEvent(String)} was called for the given eventName. + * @return true if flags are loaded and ready, false otherwise. */ - public double eventElapsedTime(final String eventName) { - final long currentTime = System.currentTimeMillis(); - Long startTime; - synchronized (mEventTimings) { - startTime = mEventTimings.get(eventName); - } - return startTime == null ? 0 : (double)((currentTime - startTime) / 1000); - } + boolean areFlagsReady(); + + // --- Sync Flag Retrieval --- /** - * Track an event. + * Gets the complete feature flag data (key and value) synchronously. * - *

Every call to track eventually results in a data point sent to Mixpanel. These data points - * are what are measured, counted, and broken down to create your Mixpanel reports. Events - * have a string name, and an optional set of name/value pairs that describe the properties of - * that event. + *

IMPORTANT: This method can block the calling thread if it needs to wait for flags + * to be loaded (though the provided implementation detail suggests it returns fallback + * immediately if not ready). It is strongly recommended NOT to call this from the main UI + * thread if {@link #areFlagsReady()} is false, as it could lead to ANR (Application Not + * Responding) issues if blocking were to occur. * - * @param eventName The name of the event to send - * @param properties A Map containing the key value pairs of the properties to include in this event. - * Pass null if no extra properties exist. + *

If flags are not ready (i.e., {@link #areFlagsReady()} returns false), this method will + * return the provided {@code fallback} value immediately without attempting to fetch flags or + * block. * - * See also {@link #track(String, org.json.JSONObject)} + * @param featureName The unique name (key) of the feature flag to retrieve. + * @param fallback The {@link MixpanelFlagVariant} instance to return if the specified flag is + * not found in the loaded set, or if flags are not ready. This must not be null. + * @return The {@link MixpanelFlagVariant} for the found feature flag, or the {@code fallback} + * if the flag is not found or flags are not ready. + */ + @NonNull + MixpanelFlagVariant getVariantSync( + @NonNull String featureName, @NonNull MixpanelFlagVariant fallback); + + /** + * Gets the value of a specific feature flag synchronously. + * + *

IMPORTANT: Similar to {@link #getVariantSync(String, MixpanelFlagVariant)}, this + * method may involve blocking behavior if flags are being loaded. It's advised to check {@link + * #areFlagsReady()} first and avoid calling this on the main UI thread if flags might not be + * ready. + * + *

If flags are not ready, or if the specified {@code featureName} is not found, this method + * returns the {@code fallbackValue} immediately. + * + * @param featureName The unique name (key) of the feature flag. + * @param fallbackValue The default value to return if the flag is not found, its value is null, + * or if flags are not ready. Can be null. + * @return The value of the feature flag (which could be a String, Boolean, Number, etc.), or + * the {@code fallbackValue}. + */ + @Nullable + Object getVariantValueSync(@NonNull String featureName, @Nullable Object fallbackValue); + + /** + * Checks if a specific feature flag is enabled synchronously. A flag is considered enabled if + * its value evaluates to {@code true}. + * + *

    + *
  • If the flag's value is a Boolean, it's returned directly. + *
  • If the flag's value is a String, it's considered {@code true} if it equals + * (case-insensitive) "true" or "1". + *
  • If the flag's value is a Number, it's considered {@code true} if it's non-zero. + *
  • For other types, or if the flag is not found, it relies on the {@code fallbackValue}. + *
+ * + *

IMPORTANT: See warnings on {@link #getVariantSync(String, MixpanelFlagVariant)} + * regarding potential blocking and the recommendation to check {@link #areFlagsReady()} first, + * especially when calling from the main UI thread. + * + *

Returns {@code fallbackValue} immediately if flags are not ready or the flag is not found. + * + * @param featureName The unique name (key) of the feature flag. + * @param fallbackValue The default boolean value to return if the flag is not found, cannot be + * evaluated as a boolean, or if flags are not ready. + * @return {@code true} if the flag is present and evaluates to true, otherwise {@code false} + * (or the {@code fallbackValue}). */ - public void trackMap(String eventName, Map properties) { - if (hasOptedOutTracking()) return; - if (null == properties) { - track(eventName, null); - } else { - try { - track(eventName, new JSONObject(properties)); - } catch (NullPointerException e) { - MPLog.w(LOGTAG, "Can't have null keys in the properties of trackMap!"); - } - } - } + boolean isEnabledSync(@NonNull String featureName, boolean fallbackValue); + + // --- Async Flag Retrieval --- /** - * Track an event with specific groups. + * Asynchronously gets the complete feature flag data (key and value). * - *

Every call to track eventually results in a data point sent to Mixpanel. These data points - * are what are measured, counted, and broken down to create your Mixpanel reports. Events - * have a string name, and an optional set of name/value pairs that describe the properties of - * that event. Group key/value pairs are upserted into the property map before tracking. + *

If flags are not currently loaded, this method will trigger a fetch from the Mixpanel + * server. The provided {@code completion} callback will be invoked on the main UI thread once + * the operation is complete. * - * @param eventName The name of the event to send - * @param properties A Map containing the key value pairs of the properties to include in this event. - * Pass null if no extra properties exist. - * @param groups A Map containing the group key value pairs for this event. + *

If the fetch fails or the specific flag is not found after a successful fetch, the {@code + * fallback} data will be provided to the completion callback. * - * See also {@link #track(String, org.json.JSONObject)}, {@link #trackMap(String, Map)} + * @param featureName The unique name (key) of the feature flag to retrieve. + * @param fallback The {@link MixpanelFlagVariant} instance to return via the callback if the + * flag is not found or if the fetch operation fails. This must not be null. + * @param completion The {@link FlagCompletionCallback} that will be invoked on the main thread + * with the result (either the found {@link MixpanelFlagVariant} or the {@code fallback}). + * This must not be null. */ - public void trackWithGroups(String eventName, Map properties, Map groups) { - if (hasOptedOutTracking()) return; - - if (null == groups) { - trackMap(eventName, properties); - } else if (null == properties) { - trackMap(eventName, groups); - } else { - for (Entry e : groups.entrySet()) { - if (e.getValue() != null) { - properties.put(e.getKey(), e.getValue()); - } - } - - trackMap(eventName, properties); - } - } + void getVariant( + @NonNull String featureName, + @NonNull MixpanelFlagVariant fallback, + @NonNull FlagCompletionCallback completion); /** - * Track an event. - * - *

Every call to track eventually results in a data point sent to Mixpanel. These data points - * are what are measured, counted, and broken down to create your Mixpanel reports. Events - * have a string name, and an optional set of name/value pairs that describe the properties of - * that event. + * Asynchronously gets the value of a specific feature flag. * - * @param eventName The name of the event to send - * @param properties A JSONObject containing the key value pairs of the properties to include in this event. - * Pass null if no extra properties exist. - */ - public void track(String eventName, JSONObject properties) { - if (hasOptedOutTracking()) return; - track(eventName, properties, false); + *

If flags are not currently loaded, this method will trigger a fetch. The {@code + * completion} callback is invoked on the main UI thread with the flag's value or the {@code + * fallbackValue}. + * + * @param featureName The unique name (key) of the feature flag. + * @param fallbackValue The default value to return via the callback if the flag is not found, + * its value is null, or if the fetch operation fails. Can be null. + * @param completion The {@link FlagCompletionCallback} that will be invoked on the main thread + * with the result (the flag's value or the {@code fallbackValue}). This must not be null. + */ + void getVariantValue( + @NonNull String featureName, + @Nullable Object fallbackValue, + @NonNull FlagCompletionCallback completion); + + /** + * Asynchronously checks if a specific feature flag is enabled. The evaluation of "enabled" + * follows the same rules as {@link #isEnabledSync(String, boolean)}. + * + *

If flags are not currently loaded, this method will trigger a fetch. The {@code + * completion} callback is invoked on the main UI thread with the boolean result. + * + * @param featureName The unique name (key) of the feature flag. + * @param fallbackValue The default boolean value to return via the callback if the flag is not + * found, cannot be evaluated as a boolean, or if the fetch operation fails. + * @param completion The {@link FlagCompletionCallback} that will be invoked on the main thread + * with the boolean result. This must not be null. + */ + void isEnabled( + @NonNull String featureName, + boolean fallbackValue, + @NonNull FlagCompletionCallback completion); + } + + /** + * Attempt to register MixpanelActivityLifecycleCallbacks to the application's event lifecycle. + * Once registered, we can automatically flush on an app background. + * + *

This is only available if the android version is >= 16. + * + *

This function is automatically called when the library is initialized unless you explicitly + * set com.mixpanel.android.MPConfig.AutoShowMixpanelUpdates to false in your AndroidManifest.xml + */ + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) + /* package */ void registerMixpanelActivityLifecycleCallbacks() { + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + if (mContext.getApplicationContext() instanceof Application) { + final Application app = (Application) mContext.getApplicationContext(); + mMixpanelActivityLifecycleCallbacks = new MixpanelActivityLifecycleCallbacks(this, mConfig); + app.registerActivityLifecycleCallbacks(mMixpanelActivityLifecycleCallbacks); + } else { + MPLog.i( + LOGTAG, + "Context is not an Application, Mixpanel won't be able to automatically flush on an app" + + " background."); + } } - - /** - * Equivalent to {@link #track(String, JSONObject)} with a null argument for properties. - * Consider adding properties to your tracking to get the best insights and experience from Mixpanel. - * @param eventName the name of the event to send - */ - public void track(String eventName) { - if (hasOptedOutTracking()) return; - track(eventName, null); + } + + /** + * Based on the application's event lifecycle this method will determine whether the app is + * running in the foreground or not. + * + *

If your build version is below 14 this method will always return false. + * + * @return True if the app is running in the foreground. + */ + public boolean isAppInForeground() { + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + if (mMixpanelActivityLifecycleCallbacks != null) { + return mMixpanelActivityLifecycleCallbacks.isInForeground(); + } + } else { + MPLog.e(LOGTAG, "Your build version is below 14. This method will always return false."); } + return false; + } - /** - * Push all queued Mixpanel events and People Analytics changes to Mixpanel servers. - * - *

Events and People messages are pushed gradually throughout - * the lifetime of your application. This means that to ensure that all messages - * are sent to Mixpanel when your application is shut down, you will - * need to call flush() to let the Mixpanel library know it should - * send all remaining messages to the server. We strongly recommend - * placing a call to flush() in the onDestroy() method of - * your main application activity. - */ - public void flush() { - if (hasOptedOutTracking()) return; - mMessages.postToServer(new AnalyticsMessages.MixpanelDescription(mToken)); + /* package */ void onBackground() { + if (mConfig.getFlushOnBackground()) { + flush(); } + } - /** - * Returns a json object of the user's current super properties - * - *

SuperProperties are a collection of properties that will be sent with every event to Mixpanel, - * and persist beyond the lifetime of your application. - * - * @return Super properties for this Mixpanel instance. - */ - public JSONObject getSuperProperties() { - JSONObject ret = new JSONObject(); - mPersistentIdentity.addSuperPropertiesToObject(ret); - return ret; - } + /* package */ void onForeground() { + mSessionMetadata.initSession(); + } - /** - * Returns the string id currently being used to uniquely identify the user. Before any calls to - * {@link #identify(String)}, this will be an id automatically generated by the library. - * - * - * @return The distinct id that uniquely identifies the current user. - * - * @see #identify(String) - */ - public String getDistinctId() { - return mPersistentIdentity.getEventsDistinctId(); - } + // Package-level access. Used (at least) by MixpanelFCMMessagingService + // when OS-level events occur. + /* package */ interface InstanceProcessor { + void process(MixpanelAPI m); + } - /** - * Returns the anonymoous id currently being used to uniquely identify the device and all - * with events sent using {@link #track(String, JSONObject)} will have this id as a device - * id - * - * @return The device id associated with event tracking - */ - public String getAnonymousId() { - return mPersistentIdentity.getAnonymousId(); + /* package */ static void allInstances(InstanceProcessor processor) { + synchronized (sInstanceMap) { + for (final Map contextInstances : sInstanceMap.values()) { + for (final MixpanelAPI instance : contextInstances.values()) { + processor.process(instance); + } + } } + } + + //////////////////////////////////////////////////////////////////// + // Conveniences for testing. These methods should not be called by + // non-test client code. + + /* package */ AnalyticsMessages getAnalyticsMessages() { + return AnalyticsMessages.getInstance(mContext, mConfig); + } + + /* package */ PersistentIdentity getPersistentIdentity( + final Context context, Future referrerPreferences, final String token) { + return getPersistentIdentity(context, referrerPreferences, token, null); + } + + /* package */ PersistentIdentity getPersistentIdentity( + final Context context, + Future referrerPreferences, + final String token, + final String instanceName) { + final SharedPreferencesLoader.OnPrefsLoadedListener listener = + new SharedPreferencesLoader.OnPrefsLoadedListener() { + @Override + public void onPrefsLoaded(SharedPreferences preferences) { + final String distinctId = PersistentIdentity.getPeopleDistinctId(preferences); + if (null != distinctId) { + pushWaitingPeopleRecord(distinctId); + } + } + }; - /** - * Returns the user id with which identify is called and all the with events sent using - * {@link #track(String, JSONObject)} will have this id as a user id - * - * @return The user id associated with event tracking - */ - protected String getUserId() { - return mPersistentIdentity.getEventsUserId(); + String instanceKey = instanceName != null ? instanceName : token; + final String prefsName = "com.mixpanel.android.mpmetrics.MixpanelAPI_" + instanceKey; + final Future storedPreferences = + sPrefsLoader.loadPreferences(context, prefsName, listener); + + final String timeEventsPrefsName = + "com.mixpanel.android.mpmetrics.MixpanelAPI.TimeEvents_" + instanceKey; + final Future timeEventsPrefs = + sPrefsLoader.loadPreferences(context, timeEventsPrefsName, null); + + final String mixpanelPrefsName = "com.mixpanel.android.mpmetrics.Mixpanel"; + final Future mixpanelPrefs = + sPrefsLoader.loadPreferences(context, mixpanelPrefsName, null); + + return new PersistentIdentity( + referrerPreferences, storedPreferences, timeEventsPrefs, mixpanelPrefs); + } + + /* package */ boolean sendAppOpen() { + return !mConfig.getDisableAppOpenEvent(); + } + + /////////////////////// + + private class PeopleImpl implements People { + @Override + public void identify(String distinctId) { + if (hasOptedOutTracking()) return; + MPLog.w( + LOGTAG, + "People.identify() is deprecated and calling it is no longer necessary, " + + "please use MixpanelAPI.identify() and set 'usePeople' to true instead"); + if (distinctId == null) { + MPLog.e(LOGTAG, "Can't identify with null distinct_id."); + return; + } + if (!distinctId.equals(mPersistentIdentity.getEventsDistinctId())) { + MPLog.w( + LOGTAG, + "Identifying with a distinct_id different from the one being set by" + + " MixpanelAPI.identify() is not supported."); + return; + } + identify_people(distinctId); } - /** - * Retrieves the Mixpanel project token. - * - * @return The Mixpanel project token currently being used. - */ - public String getToken() { - return mToken; + private void identify_people(String distinctId) { + synchronized (mPersistentIdentity) { + mPersistentIdentity.setPeopleDistinctId(distinctId); + } + pushWaitingPeopleRecord(distinctId); } - /** - * Retrieves the Mixpanel configuration object. - * - * @return The current {@link MPConfig} object containing Mixpanel settings. - */ - public MPConfig getMPConfig() { - return mConfig; + @Override + public void setMap(Map properties) { + if (hasOptedOutTracking()) return; + if (null == properties) { + MPLog.e(LOGTAG, "setMap does not accept null properties"); + return; + } + + try { + set(new JSONObject(properties)); + } catch (NullPointerException e) { + MPLog.w(LOGTAG, "Can't have null keys in the properties of setMap!"); + } } - /** - * Register properties that will be sent with every subsequent call to {@link #track(String, JSONObject)}. - * - *

SuperProperties are a collection of properties that will be sent with every event to Mixpanel, - * and persist beyond the lifetime of your application. - * - *

Setting a superProperty with registerSuperProperties will store a new superProperty, - * possibly overwriting any existing superProperty with the same name (to set a - * superProperty only if it is currently unset, use {@link #registerSuperPropertiesOnce(JSONObject)}) - * - *

SuperProperties will persist even if your application is taken completely out of memory. - * to remove a superProperty, call {@link #unregisterSuperProperty(String)} or {@link #clearSuperProperties()} - * - * @param superProperties A Map containing super properties to register - * - * See also {@link #registerSuperProperties(org.json.JSONObject)} - */ - public void registerSuperPropertiesMap(Map superProperties) { - if (hasOptedOutTracking()) return; - if (null == superProperties) { - MPLog.e(LOGTAG, "registerSuperPropertiesMap does not accept null properties"); - return; + @Override + public void set(JSONObject properties) { + if (hasOptedOutTracking()) return; + try { + final JSONObject sendProperties = new JSONObject(mDeviceInfo); + for (final Iterator iter = properties.keys(); iter.hasNext(); ) { + final String key = (String) iter.next(); + sendProperties.put(key, properties.get(key)); } - try { - registerSuperProperties(new JSONObject(superProperties)); - } catch (NullPointerException e) { - MPLog.w(LOGTAG, "Can't have null keys in the properties of registerSuperPropertiesMap"); - } + final JSONObject message = stdPeopleMessage("$set", sendProperties); + recordPeopleMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception setting people properties", e); + } } - /** - * Register properties that will be sent with every subsequent call to {@link #track(String, JSONObject)}. - * - *

SuperProperties are a collection of properties that will be sent with every event to Mixpanel, - * and persist beyond the lifetime of your application. - * - *

Setting a superProperty with registerSuperProperties will store a new superProperty, - * possibly overwriting any existing superProperty with the same name (to set a - * superProperty only if it is currently unset, use {@link #registerSuperPropertiesOnce(JSONObject)}) - * - *

SuperProperties will persist even if your application is taken completely out of memory. - * to remove a superProperty, call {@link #unregisterSuperProperty(String)} or {@link #clearSuperProperties()} - * - * @param superProperties A JSONObject containing super properties to register - * @see #registerSuperPropertiesOnce(JSONObject) - * @see #unregisterSuperProperty(String) - * @see #clearSuperProperties() - */ - public void registerSuperProperties(JSONObject superProperties) { - if (hasOptedOutTracking()) return; - mPersistentIdentity.registerSuperProperties(superProperties); + @Override + public void set(String property, Object value) { + if (hasOptedOutTracking()) return; + try { + set(new JSONObject().put(property, value)); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "set", e); + } } - /** - * Remove a single superProperty, so that it will not be sent with future calls to {@link #track(String, JSONObject)}. - * - *

If there is a superProperty registered with the given name, it will be permanently - * removed from the existing superProperties. - * To clear all superProperties, use {@link #clearSuperProperties()} - * - * @param superPropertyName name of the property to unregister - * @see #registerSuperProperties(JSONObject) - */ - public void unregisterSuperProperty(String superPropertyName) { - if (hasOptedOutTracking()) return; - mPersistentIdentity.unregisterSuperProperty(superPropertyName); + @Override + public void setOnceMap(Map properties) { + if (hasOptedOutTracking()) return; + if (null == properties) { + MPLog.e(LOGTAG, "setOnceMap does not accept null properties"); + return; + } + try { + setOnce(new JSONObject(properties)); + } catch (NullPointerException e) { + MPLog.w(LOGTAG, "Can't have null keys in the properties setOnceMap!"); + } } - /** - * Register super properties for events, only if no other super property with the - * same names has already been registered. - * - *

Calling registerSuperPropertiesOnce will never overwrite existing properties. - * - * @param superProperties A Map containing the super properties to register. - * - * See also {@link #registerSuperPropertiesOnce(org.json.JSONObject)} - */ - public void registerSuperPropertiesOnceMap(Map superProperties) { - if (hasOptedOutTracking()) return; - if (null == superProperties) { - MPLog.e(LOGTAG, "registerSuperPropertiesOnceMap does not accept null properties"); - return; - } + @Override + public void setOnce(JSONObject properties) { + if (hasOptedOutTracking()) return; + try { + final JSONObject message = stdPeopleMessage("$set_once", properties); + recordPeopleMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception setting people properties"); + } + } - try { - registerSuperPropertiesOnce(new JSONObject(superProperties)); - } catch (NullPointerException e) { - MPLog.w(LOGTAG, "Can't have null keys in the properties of registerSuperPropertiesOnce!"); - } + @Override + public void setOnce(String property, Object value) { + if (hasOptedOutTracking()) return; + try { + setOnce(new JSONObject().put(property, value)); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "set", e); + } } - /** - * Register super properties for events, only if no other super property with the - * same names has already been registered. - * - *

Calling registerSuperPropertiesOnce will never overwrite existing properties. - * - * @param superProperties A JSONObject containing the super properties to register. - * @see #registerSuperProperties(JSONObject) - */ - public void registerSuperPropertiesOnce(JSONObject superProperties) { - if (hasOptedOutTracking()) return; - mPersistentIdentity.registerSuperPropertiesOnce(superProperties); + @Override + public void increment(Map properties) { + if (hasOptedOutTracking()) return; + final JSONObject json = new JSONObject(properties); + try { + final JSONObject message = stdPeopleMessage("$add", json); + recordPeopleMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception incrementing properties", e); + } } - /** - * Erase all currently registered superProperties. - * - *

Future tracking calls to Mixpanel will not contain the specific - * superProperties registered before the clearSuperProperties method was called. - * - *

To remove a single superProperty, use {@link #unregisterSuperProperty(String)} - * - * @see #registerSuperProperties(JSONObject) - */ - public void clearSuperProperties() { - mPersistentIdentity.clearSuperProperties(); + @Override + // Must be thread safe + public void merge(String property, JSONObject updates) { + if (hasOptedOutTracking()) return; + final JSONObject mergeMessage = new JSONObject(); + try { + mergeMessage.put(property, updates); + final JSONObject message = stdPeopleMessage("$merge", mergeMessage); + recordPeopleMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception merging a property", e); + } } - /** - * Updates super properties in place. Given a SuperPropertyUpdate object, will - * pass the current values of SuperProperties to that update and replace all - * results with the return value of the update. Updates are synchronized on - * the underlying super properties store, so they are guaranteed to be thread safe - * (but long running updates may slow down your tracking.) - * - * @param update A function from one set of super properties to another. The update should not return null. - */ - public void updateSuperProperties(SuperPropertyUpdate update) { - if (hasOptedOutTracking()) return; - mPersistentIdentity.updateSuperProperties(update); + @Override + public void increment(String property, double value) { + if (hasOptedOutTracking()) return; + final Map map = new HashMap(); + map.put(property, value); + increment(map); + } + + @Override + public void append(String name, Object value) { + if (hasOptedOutTracking()) return; + try { + final JSONObject properties = new JSONObject(); + properties.put(name, value); + final JSONObject message = stdPeopleMessage("$append", properties); + recordPeopleMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception appending a property", e); + } } - /** - * Set the group this user belongs to. - * - * @param groupKey The property name associated with this group type (must already have been set up). - * @param groupID The group the user belongs to. - */ - public void setGroup(String groupKey, Object groupID) { - if (hasOptedOutTracking()) return; + @Override + public void union(String name, JSONArray value) { + if (hasOptedOutTracking()) return; + try { + final JSONObject properties = new JSONObject(); + properties.put(name, value); + final JSONObject message = stdPeopleMessage("$union", properties); + recordPeopleMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception unioning a property"); + } + } - List groupIDs = new ArrayList<>(1); - groupIDs.add(groupID); - setGroup(groupKey, groupIDs); + @Override + public void remove(String name, Object value) { + if (hasOptedOutTracking()) return; + try { + final JSONObject properties = new JSONObject(); + properties.put(name, value); + final JSONObject message = stdPeopleMessage("$remove", properties); + recordPeopleMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception appending a property", e); + } } - /** - * Set the groups this user belongs to. - * - * @param groupKey The property name associated with this group type (must already have been set up). - * @param groupIDs The list of groups the user belongs to. - */ - public void setGroup(String groupKey, List groupIDs) { - if (hasOptedOutTracking()) return; + @Override + public void unset(String name) { + if (hasOptedOutTracking()) return; + try { + final JSONArray names = new JSONArray(); + names.put(name); + final JSONObject message = stdPeopleMessage("$unset", names); + recordPeopleMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception unsetting a property", e); + } + } - JSONArray vals = new JSONArray(); + @Override + public void trackCharge(double amount, JSONObject properties) { + if (hasOptedOutTracking()) return; + final Date now = new Date(); + final DateFormat dateFormat = new SimpleDateFormat(ENGAGE_DATE_FORMAT_STRING, Locale.US); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); - for (Object s : groupIDs) { - if (s == null) { - MPLog.w(LOGTAG, "groupID must be non-null"); - } else { - vals.put(s); - } - } + try { + final JSONObject transactionValue = new JSONObject(); + transactionValue.put("$amount", amount); + transactionValue.put("$time", dateFormat.format(now)); - try { - registerSuperProperties((new JSONObject()).put(groupKey, vals)); - mPeople.set(groupKey, vals); - } catch (JSONException e) { - MPLog.w(LOGTAG, "groupKey must be non-null"); + if (null != properties) { + for (final Iterator iter = properties.keys(); iter.hasNext(); ) { + final String key = (String) iter.next(); + transactionValue.put(key, properties.get(key)); + } } - } - - /** - * Add a group to this user's membership for a particular group key - * - * @param groupKey The property name associated with this group type (must already have been set up). - * @param groupID The new group the user belongs to. - */ - public void addGroup(final String groupKey, final Object groupID) { - if (hasOptedOutTracking()) return; - - updateSuperProperties(new SuperPropertyUpdate() { - public JSONObject update(JSONObject in) { - try { - in.accumulate(groupKey, groupID); - } catch (JSONException e) { - MPLog.e(LOGTAG, "Failed to add groups superProperty", e); - } - return in; - } - }); - - // This is a best effort--if the people property is not already a list, this call does nothing. - mPeople.union(groupKey, (new JSONArray()).put(groupID)); + this.append("$transactions", transactionValue); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception creating new charge", e); + } } - /** - * Remove a group from this user's membership for a particular group key - * - * @param groupKey The property name associated with this group type (must already have been set up). - * @param groupID The group value to remove. - */ - public void removeGroup(final String groupKey, final Object groupID) { - if (hasOptedOutTracking()) return; - - updateSuperProperties(new SuperPropertyUpdate() { - public JSONObject update(JSONObject in) { - try { - JSONArray vals = in.getJSONArray(groupKey); - JSONArray newVals = new JSONArray(); - - if (vals.length() <= 1) { - in.remove(groupKey); - - // This is a best effort--we can't guarantee people and super properties match - mPeople.unset(groupKey); - } else { - - for (int i = 0; i < vals.length(); i++) { - if (!vals.get(i).equals(groupID)) { - newVals.put(vals.get(i)); - } - } - - in.put(groupKey, newVals); - - // This is a best effort--we can't guarantee people and super properties match - // If people property is not a list, this call does nothing. - mPeople.remove(groupKey, groupID); - } - } catch (JSONException e) { - in.remove(groupKey); - - // This is a best effort--we can't guarantee people and super properties match - mPeople.unset(groupKey); - } - - return in; - } - }); + /** Permanently clear the whole transaction history for the identified people profile. */ + @Override + public void clearCharges() { + this.unset("$transactions"); } - - /** - * Returns a Mixpanel.People object that can be used to set and increment - * People Analytics properties. - * - * @return an instance of {@link People} that you can use to update - * records in Mixpanel People Analytics. - */ - public People getPeople() { - return mPeople; + @Override + public void deleteUser() { + try { + final JSONObject message = stdPeopleMessage("$delete", JSONObject.NULL); + recordPeopleMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception deleting a user"); + } } - /** - * Returns a Mixpanel.Group object that can be used to set and increment - * Group Analytics properties. - * - * @param groupKey String identifying the type of group (must be already in use as a group key) - * @param groupID Object identifying the specific group - * @return an instance of {@link Group} that you can use to update - * records in Mixpanel Group Analytics - */ - public Group getGroup(String groupKey, Object groupID) { - String mapKey = makeMapKey(groupKey, groupID); - GroupImpl group = mGroups.get(mapKey); + @Override + public String getDistinctId() { + return mPersistentIdentity.getPeopleDistinctId(); + } - if (group == null) { - group = new GroupImpl(groupKey, groupID); - mGroups.put(mapKey, group); + @Override + public People withIdentity(final String distinctId) { + if (null == distinctId) { + return null; + } + return new PeopleImpl() { + @Override + public String getDistinctId() { + return distinctId; } - if (!(group.mGroupKey.equals(groupKey) && group.mGroupID.equals(groupID))) { - // we hit a map key collision, return a new group with the correct key and ID - MPLog.i(LOGTAG, "groups map key collision " + mapKey); - group = new GroupImpl(groupKey, groupID); - mGroups.put(mapKey, group); + @Override + public void identify(String distinctId) { + throw new RuntimeException("This MixpanelPeople object has a fixed, constant distinctId"); } - - return group; + }; } - private String makeMapKey(String groupKey, Object groupID) { - return groupKey + '_' + groupID; + private JSONObject stdPeopleMessage(String actionType, Object properties) throws JSONException { + final JSONObject dataObj = new JSONObject(); + final String distinctId = getDistinctId(); // TODO ensure getDistinctId is thread safe + final String anonymousId = getAnonymousId(); + dataObj.put(actionType, properties); + dataObj.put("$token", mToken); + dataObj.put("$time", System.currentTimeMillis()); + dataObj.put("$had_persisted_distinct_id", mPersistentIdentity.getHadPersistedDistinctId()); + if (null != anonymousId) { + dataObj.put("$device_id", anonymousId); + } + if (null != distinctId) { + dataObj.put("$distinct_id", distinctId); + dataObj.put("$user_id", distinctId); + } + dataObj.put("$mp_metadata", mSessionMetadata.getMetadataForPeople()); + return dataObj; } - /** - * Returns a {@link Flags} object that can be used to retrieve and manage - * feature flags from Mixpanel. - * - * @return an instance of {@link Flags} that allows you to access feature flag - * configurations for your project. - */ - public Flags getFlags() { - return mFeatureFlagManager; + @Override + public boolean isIdentified() { + return getDistinctId() != null; } + } // PeopleImpl - /** - * Clears tweaks and all distinct_ids, superProperties, and push registrations from persistent storage. - * Will not clear referrer information. - */ - public void reset() { - // Will clear distinct_ids, superProperties, - // and waiting People Analytics properties. Will have no effect - // on messages already queued to send with AnalyticsMessages. - mPersistentIdentity.clearPreferences(); - getAnalyticsMessages().clearAnonymousUpdatesMessage(new AnalyticsMessages.MixpanelDescription(mToken)); - identify(getDistinctId(), false); - flush(); - } + private class GroupImpl implements Group { + private final String mGroupKey; + private final Object mGroupID; - /** - * Returns an unmodifiable map that contains the device description properties - * that will be sent to Mixpanel. These are not all of the default properties, - * but are a subset that are dependant on the user's device or installed version - * of the host application, and are guaranteed not to change while the app is running. - * - * @return Map containing the device description properties that are sent to Mixpanel. - */ - public Map getDeviceInfo() { - return mDeviceInfo; - } - - /** - * Use this method to opt-out a user from tracking. Events and people updates that haven't been - * flushed yet will be deleted. Use {@link #flush()} before calling this method if you want - * to send all the queues to Mixpanel before. - * - * This method will also remove any user-related information from the device. - */ - public void optOutTracking() { - getAnalyticsMessages().emptyTrackingQueues(new AnalyticsMessages.MixpanelDescription(mToken)); - if (getPeople().isIdentified()) { - getPeople().deleteUser(); - getPeople().clearCharges(); - } - mPersistentIdentity.clearPreferences(); - synchronized (mEventTimings) { - mEventTimings.clear(); - mPersistentIdentity.clearTimedEvents(); - } - mPersistentIdentity.clearReferrerProperties(); - mPersistentIdentity.setOptOutTracking(true, mToken); + public GroupImpl(String groupKey, Object groupID) { + mGroupKey = groupKey; + mGroupID = groupID; } - /** - * Use this method to opt-in an already opted-out user from tracking. People updates and track - * calls will be sent to Mixpanel after using this method. - * This method will internally track an opt-in event to your project. If you want to identify - * the opt-in event and/or pass properties to the event, see {@link #optInTracking(String)} and - * {@link #optInTracking(String, JSONObject)} - * - * See also {@link #optOutTracking()}. - */ - public void optInTracking() { - optInTracking(null, null); - } + @Override + public void setMap(Map properties) { + if (hasOptedOutTracking()) return; + if (null == properties) { + MPLog.e(LOGTAG, "setMap does not accept null properties"); + return; + } - /** - * Use this method to opt-in an already opted-out user from tracking. People updates and track - * calls will be sent to Mixpanel after using this method. - * This method will internally track an opt-in event to your project. - * - * @param distinctId Optional string to use as the distinct ID for events. - * This will call {@link #identify(String)}. - * - * See also {@link #optInTracking(String)}, {@link #optInTracking(String, JSONObject)} and - * {@link #optOutTracking()}. - */ - public void optInTracking(String distinctId) { - optInTracking(distinctId, null); + set(new JSONObject(properties)); } - /** - * Use this method to opt-in an already opted-out user from tracking. People updates and track - * calls will be sent to Mixpanel after using this method. - * This method will internally track an opt-in event to your project. - * - * @param distinctId Optional string to use as the distinct ID for events. - * This will call {@link #identify(String)}. - * - * @param properties Optional JSONObject that could be passed to add properties to the - * opt-in event that is sent to Mixpanel. - * - * See also {@link #optInTracking()} and {@link #optOutTracking()}. - */ - public void optInTracking(String distinctId, JSONObject properties) { - mPersistentIdentity.setOptOutTracking(false, mToken); - if (distinctId != null) { - identify(distinctId); + @Override + public void set(JSONObject properties) { + if (hasOptedOutTracking()) return; + try { + final JSONObject sendProperties = new JSONObject(); + for (final Iterator iter = properties.keys(); iter.hasNext(); ) { + final String key = (String) iter.next(); + sendProperties.put(key, properties.get(key)); } - track("$opt_in", properties); - } - /** - * Will return true if the user has opted out from tracking. See {@link #optOutTracking()} and - * {@link - * MixpanelAPI#getInstance(Context, String, boolean, JSONObject, String, boolean)} for more information. - * - * @return true if user has opted out from tracking. Defaults to false. - */ - public boolean hasOptedOutTracking() { - return mPersistentIdentity.getOptOutTracking(mToken); - } - /** - * Core interface for using Mixpanel People Analytics features. - * You can get an instance by calling {@link MixpanelAPI#getPeople()} - * - *

The People object is used to update properties in a user's People Analytics record. - * For this reason, it's important to call {@link #identify(String)} on the People - * object before you work with it. Once you call identify, the user identity will - * persist across stops and starts of your application, until you make another - * call to identify using a different id. - * - * A typical use case for the People object might look like this: - * - *

-     * {@code
-     *
-     * public class MainActivity extends Activity {
-     *      MixpanelAPI mMixpanel;
-     *
-     *      public void onCreate(Bundle saved) {
-     *          mMixpanel = MixpanelAPI.getInstance(this, "YOUR MIXPANEL API TOKEN");
-     *          mMixpanel.identify("A UNIQUE ID FOR THIS USER");
-     *          ...
-     *      }
-     *
-     *      public void userUpdatedJobTitle(String newTitle) {
-     *          mMixpanel.getPeople().set("Job Title", newTitle);
-     *          ...
-     *      }
-     *
-     *      public void onDestroy() {
-     *          mMixpanel.flush();
-     *          super.onDestroy();
-     *      }
-     * }
-     *
-     * }
-     * 
- * - * @see MixpanelAPI - */ - public interface People { - /** - * @deprecated in 6.2.0 - * NOTE: This method is deprecated. Please use {@link MixpanelAPI#identify(String)} instead. - * - * - * @param distinctId a String that uniquely identifies the user. Users identified with - * the same distinct id will be considered to be the same user in Mixpanel, - * across all platforms and devices. We recommend choosing a distinct id - * that is meaningful to your other systems (for example, a server-side account - * identifier) - * - * @see MixpanelAPI#identify(String) - */ - @Deprecated - void identify(String distinctId); - - /** - * Sets a single property with the given name and value for this user. - * The given name and value will be assigned to the user in Mixpanel People Analytics, - * possibly overwriting an existing property with the same name. - * - * @param propertyName The name of the Mixpanel property. This must be a String, for example "Zip Code" - * @param value The value of the Mixpanel property. For "Zip Code", this value might be the String "90210" - */ - void set(String propertyName, Object value); - - /** - * Set a collection of properties on the identified user all at once. - * - * @param properties a Map containing the collection of properties you wish to apply - * to the identified user. Each key in the Map will be associated with - * a property name, and the value of that key will be assigned to the property. - * - * See also {@link #set(org.json.JSONObject)} - */ - void setMap(Map properties); - - /** - * Set a collection of properties on the identified user all at once. - * - * @param properties a JSONObject containing the collection of properties you wish to apply - * to the identified user. Each key in the JSONObject will be associated with - * a property name, and the value of that key will be assigned to the property. - */ - void set(JSONObject properties); - - /** - * Works just like {@link People#set(String, Object)}, except it will not overwrite existing property values. This is useful for properties like "First login date". - * - * @param propertyName The name of the Mixpanel property. This must be a String, for example "Zip Code" - * @param value The value of the Mixpanel property. For "Zip Code", this value might be the String "90210" - */ - void setOnce(String propertyName, Object value); - - /** - * Like {@link People#set(String, Object)}, but will not set properties that already exist on a record. - * - * @param properties a Map containing the collection of properties you wish to apply - * to the identified user. Each key in the Map will be associated with - * a property name, and the value of that key will be assigned to the property. - * - * See also {@link #setOnce(org.json.JSONObject)} - */ - void setOnceMap(Map properties); - - /** - * Like {@link People#set(String, Object)}, but will not set properties that already exist on a record. - * - * @param properties a JSONObject containing the collection of properties you wish to apply - * to the identified user. Each key in the JSONObject will be associated with - * a property name, and the value of that key will be assigned to the property. - */ - void setOnce(JSONObject properties); - - /** - * Add the given amount to an existing property on the identified user. If the user does not already - * have the associated property, the amount will be added to zero. To reduce a property, - * provide a negative number for the value. - * - * @param name the People Analytics property that should have its value changed - * @param increment the amount to be added to the current value of the named property - * - * @see #increment(Map) - */ - void increment(String name, double increment); - - /** - * Merge a given JSONObject into the object-valued property named name. If the user does not - * already have the associated property, an new property will be created with the value of - * the given updates. If the user already has a value for the given property, the updates will - * be merged into the existing value, with key/value pairs in updates taking precedence over - * existing key/value pairs where the keys are the same. - * - * @param name the People Analytics property that should have the update merged into it - * @param updates a JSONObject with keys and values that will be merged into the property - */ - void merge(String name, JSONObject updates); - - /** - * Change the existing values of multiple People Analytics properties at once. - * - *

If the user does not already have the associated property, the amount will - * be added to zero. To reduce a property, provide a negative number for the value. - * - * @param properties A map of String properties names to Long amounts. Each - * property associated with a name in the map will have its value changed by the given amount - * - * @see #increment(String, double) - */ - void increment(Map properties); - - /** - * Appends a value to a list-valued property. If the property does not currently exist, - * it will be created as a list of one element. If the property does exist and doesn't - * currently have a list value, the append will be ignored. - * @param name the People Analytics property that should have it's value appended to - * @param value the new value that will appear at the end of the property's list - */ - void append(String name, Object value); - - /** - * Adds values to a list-valued property only if they are not already present in the list. - * If the property does not currently exist, it will be created with the given list as it's value. - * If the property exists and is not list-valued, the union will be ignored. - * - * @param name name of the list-valued property to set or modify - * @param value an array of values to add to the property value if not already present - */ - void union(String name, JSONArray value); - - /** - * Remove value from a list-valued property only if they are already present in the list. - * If the property does not currently exist, the remove will be ignored. - * If the property exists and is not list-valued, the remove will be ignored. - * @param name the People Analytics property that should have it's value removed from - * @param value the value that will be removed from the property's list - */ - void remove(String name, Object value); - - /** - * permanently removes the property with the given name from the user's profile - * @param name name of a property to unset - */ - void unset(String name); - - /** - * Track a revenue transaction for the identified people profile. - * - * @param amount the amount of money exchanged. Positive amounts represent purchases or income from the customer, negative amounts represent refunds or payments to the customer. - * @param properties an optional collection of properties to associate with this transaction. - */ - void trackCharge(double amount, JSONObject properties); - - /** - * Permanently clear the whole transaction history for the identified people profile. - */ - void clearCharges(); - - /** - * Permanently deletes the identified user's record from People Analytics. - * - *

Calling deleteUser deletes an entire record completely. Any future calls - * to People Analytics using the same distinct id will create and store new values. - */ - void deleteUser(); - - /** - * Checks if the people profile is identified or not. - * - * @return Whether the current user is identified or not. - */ - boolean isIdentified(); - - /** - * Returns the string id currently being used to uniquely identify the user associated - * with events sent using {@link People#set(String, Object)} and {@link People#increment(String, double)}. - * If no calls to {@link MixpanelAPI#identify(String)} have been made, this method will return null. - * - * @deprecated in 6.2.0 - * NOTE: This method is deprecated. Please use {@link MixpanelAPI#getDistinctId()} instead. - * - * @return The distinct id associated with updates to People Analytics - * - * @see People#identify(String) - * @see MixpanelAPI#getDistinctId() - */ - @Deprecated - String getDistinctId(); - - /** - * Return an instance of Mixpanel people with a temporary distinct id. - * - * @param distinctId Unique identifier (distinct_id) that the people object will have - * - * @return An instance of {@link MixpanelAPI.People} with the specified distinct_id - */ - People withIdentity(String distinctId); + final JSONObject message = stdGroupMessage("$set", sendProperties); + recordGroupMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception setting group properties", e); + } } - /** - * Core interface for using Mixpanel Group Analytics features. - * You can get an instance by calling {@link MixpanelAPI#getGroup(String, Object)} - * - *

The Group object is used to update properties in a group's Group Analytics record. - * - * A typical use case for the Group object might look like this: - * - *

-     * {@code
-     *
-     * public class MainActivity extends Activity {
-     *      MixpanelAPI mMixpanel;
-     *
-     *      public void onCreate(Bundle saved) {
-     *          mMixpanel = MixpanelAPI.getInstance(this, "YOUR MIXPANEL API TOKEN");
-     *          ...
-     *      }
-     *
-     *      public void companyPlanTypeChanged(string company, String newPlan) {
-     *          mMixpanel.getGroup("Company", company).set("Plan Type", newPlan);
-     *          ...
-     *      }
-     *
-     *      public void onDestroy() {
-     *          mMixpanel.flush();
-     *          super.onDestroy();
-     *      }
-     * }
-     *
-     * }
-     * 
- * - * @see MixpanelAPI - */ - public interface Group { - /** - * Sets a single property with the given name and value for this group. - * The given name and value will be assigned to the user in Mixpanel Group Analytics, - * possibly overwriting an existing property with the same name. - * - * @param propertyName The name of the Mixpanel property. This must be a String, for example "Zip Code" - * @param value The value of the Mixpanel property. For "Zip Code", this value might be the String "90210" - */ - void set(String propertyName, Object value); - - /** - * Set a collection of properties on the identified group all at once. - * - * @param properties a Map containing the collection of properties you wish to apply - * to the identified group. Each key in the Map will be associated with - * a property name, and the value of that key will be assigned to the property. - * - * See also {@link #set(org.json.JSONObject)} - */ - void setMap(Map properties); - - /** - * Set a collection of properties on the identified group all at once. - * - * @param properties a JSONObject containing the collection of properties you wish to apply - * to the identified group. Each key in the JSONObject will be associated with - * a property name, and the value of that key will be assigned to the property. - */ - void set(JSONObject properties); - - /** - * Works just like {@link Group#set(String, Object)}, except it will not overwrite existing property values. This is useful for properties like "First login date". - * - * @param propertyName The name of the Mixpanel property. This must be a String, for example "Zip Code" - * @param value The value of the Mixpanel property. For "Zip Code", this value might be the String "90210" - */ - void setOnce(String propertyName, Object value); - - /** - * Like {@link Group#set(String, Object)}, but will not set properties that already exist on a record. - * - * @param properties a Map containing the collection of properties you wish to apply - * to the identified group. Each key in the Map will be associated with - * a property name, and the value of that key will be assigned to the property. - * - * See also {@link #setOnce(org.json.JSONObject)} - */ - void setOnceMap(Map properties); - - /** - * Like {@link Group#set(String, Object)}, but will not set properties that already exist on a record. - * - * @param properties a JSONObject containing the collection of properties you wish to apply - * to this group. Each key in the JSONObject will be associated with - * a property name, and the value of that key will be assigned to the property. - */ - void setOnce(JSONObject properties); - - /** - * Adds values to a list-valued property only if they are not already present in the list. - * If the property does not currently exist, it will be created with the given list as its value. - * If the property exists and is not list-valued, the union will be ignored. - * - * @param name name of the list-valued property to set or modify - * @param value an array of values to add to the property value if not already present - */ - void union(String name, JSONArray value); - - /** - * Remove value from a list-valued property only if it is already present in the list. - * If the property does not currently exist, the remove will be ignored. - * If the property exists and is not list-valued, the remove will be ignored. - * - * @param name the Group Analytics list-valued property that should have a value removed - * @param value the value that will be removed from the list - */ - void remove(String name, Object value); - - /** - * Permanently removes the property with the given name from the group's profile - * @param name name of a property to unset - */ - void unset(String name); - - - /** - * Permanently deletes this group's record from Group Analytics. - * - *

Calling deleteGroup deletes an entire record completely. Any future calls - * to Group Analytics using the same group value will create and store new values. - */ - void deleteGroup(); + @Override + public void set(String property, Object value) { + if (hasOptedOutTracking()) return; + try { + set(new JSONObject().put(property, value)); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "set", e); + } } - - /** - * Core interface for using Mixpanel Feature Flags. - * You can get an instance by calling {@link MixpanelAPI#getFlags()} (assuming such a method exists). - * - *

The Flags interface allows you to manage and retrieve feature flags defined in your Mixpanel project. - * Feature flags can be used to remotely configure your application's behavior, roll out new features - * gradually, or run A/B tests. - * - *

It's recommended to load flags early in your application's lifecycle, for example, - * in your main Application class or main Activity's {@code onCreate} method. - * - *

A typical use case for the Flags interface might look like this: - * - *

-     * {@code
-     *
-     * public class MainActivity extends Activity {
-     * MixpanelAPI mMixpanel;
-     * Flags mFlags;
-     *
-     * public void onCreate(Bundle saved) {
-     * super.onCreate(saved);
-     * MixanelOptions mpOptions = new MixpanelOptions.Builder().featureFlagsEnabled(true).build();
-     * mMixpanel = MixpanelAPI.getInstance(this, "YOUR MIXPANEL TOKEN", true, mpOptions);
-     * mFlags = mMixpanel.getFlags();
-     *
-     * // Asynchronously load flags
-     * mFlags.loadFlags();
-     *
-     * // Example of checking a flag asynchronously
-     * mFlags.isFlagEnabled("new-checkout-flow", false, isEnabled -> {
-     * if (isEnabled) {
-     * // Show new checkout flow
-     * } else {
-     * // Show old checkout flow
-     * }
-     * });
-     *
-     * // Example of getting a flag value synchronously after ensuring flags are ready
-     * if (mFlags.areFlagsReady()) {
-     * String buttonLabel = (String) mFlags.getVariantValueSync("home-button-label", "Default Label");
-     * // Use buttonLabel
-     * }
-     * }
-     * }
-     *
-     * }
-     * 
- * - * @see MixpanelAPI - */ - public interface Flags { - - // --- Public Methods --- - - /** - * Asynchronously loads flags from the Mixpanel server if they haven't been loaded yet - * or if the cached flags have expired. This method will initiate a network request - * if necessary. Subsequent calls to get flag values (especially asynchronous ones) - * may trigger this load if flags are not yet available. - */ - void loadFlags(); - - /** - * Returns true if flags have been successfully loaded from the server and are - * currently available for synchronous access. This is useful to check before - * calling synchronous flag retrieval methods like {@link #getVariantSync(String, MixpanelFlagVariant)} - * to avoid them returning the fallback value immediately. - * - * @return true if flags are loaded and ready, false otherwise. - */ - boolean areFlagsReady(); - - // --- Sync Flag Retrieval --- - - /** - * Gets the complete feature flag data (key and value) synchronously. - * - *

IMPORTANT: This method can block the calling thread if it needs to wait for - * flags to be loaded (though the provided implementation detail suggests it returns - * fallback immediately if not ready). It is strongly recommended NOT to call this - * from the main UI thread if {@link #areFlagsReady()} is false, as it could lead - * to ANR (Application Not Responding) issues if blocking were to occur. - * - *

If flags are not ready (i.e., {@link #areFlagsReady()} returns false), this method - * will return the provided {@code fallback} value immediately without attempting to - * fetch flags or block. - * - * @param featureName The unique name (key) of the feature flag to retrieve. - * @param fallback The {@link MixpanelFlagVariant} instance to return if the specified - * flag is not found in the loaded set, or if flags are not ready. - * This must not be null. - * @return The {@link MixpanelFlagVariant} for the found feature flag, or the {@code fallback} - * if the flag is not found or flags are not ready. - */ - @NonNull - MixpanelFlagVariant getVariantSync(@NonNull String featureName, @NonNull MixpanelFlagVariant fallback); - - /** - * Gets the value of a specific feature flag synchronously. - * - *

IMPORTANT: Similar to {@link #getVariantSync(String, MixpanelFlagVariant)}, this method - * may involve blocking behavior if flags are being loaded. It's advised to check - * {@link #areFlagsReady()} first and avoid calling this on the main UI thread if flags - * might not be ready. - * - *

If flags are not ready, or if the specified {@code featureName} is not found, - * this method returns the {@code fallbackValue} immediately. - * - * @param featureName The unique name (key) of the feature flag. - * @param fallbackValue The default value to return if the flag is not found, - * its value is null, or if flags are not ready. Can be null. - * @return The value of the feature flag (which could be a String, Boolean, Number, etc.), - * or the {@code fallbackValue}. - */ - @Nullable - Object getVariantValueSync(@NonNull String featureName, @Nullable Object fallbackValue); - - /** - * Checks if a specific feature flag is enabled synchronously. A flag is considered - * enabled if its value evaluates to {@code true}. - * - *

    - *
  • If the flag's value is a Boolean, it's returned directly.
  • - *
  • If the flag's value is a String, it's considered {@code true} if it equals (case-insensitive) "true" or "1".
  • - *
  • If the flag's value is a Number, it's considered {@code true} if it's non-zero.
  • - *
  • For other types, or if the flag is not found, it relies on the {@code fallbackValue}.
  • - *
- * - *

IMPORTANT: See warnings on {@link #getVariantSync(String, MixpanelFlagVariant)} regarding - * potential blocking and the recommendation to check {@link #areFlagsReady()} first, - * especially when calling from the main UI thread. - * - *

Returns {@code fallbackValue} immediately if flags are not ready or the flag is not found. - * - * @param featureName The unique name (key) of the feature flag. - * @param fallbackValue The default boolean value to return if the flag is not found, - * cannot be evaluated as a boolean, or if flags are not ready. - * @return {@code true} if the flag is present and evaluates to true, otherwise {@code false} - * (or the {@code fallbackValue}). - */ - boolean isEnabledSync(@NonNull String featureName, boolean fallbackValue); - - // --- Async Flag Retrieval --- - - /** - * Asynchronously gets the complete feature flag data (key and value). - * - *

If flags are not currently loaded, this method will trigger a fetch from the - * Mixpanel server. The provided {@code completion} callback will be invoked on the - * main UI thread once the operation is complete. - * - *

If the fetch fails or the specific flag is not found after a successful fetch, - * the {@code fallback} data will be provided to the completion callback. - * - * @param featureName The unique name (key) of the feature flag to retrieve. - * @param fallback The {@link MixpanelFlagVariant} instance to return via the callback - * if the flag is not found or if the fetch operation fails. - * This must not be null. - * @param completion The {@link FlagCompletionCallback} that will be invoked on the main - * thread with the result (either the found {@link MixpanelFlagVariant} or - * the {@code fallback}). This must not be null. - */ - void getVariant( - @NonNull String featureName, - @NonNull MixpanelFlagVariant fallback, - @NonNull FlagCompletionCallback completion - ); - - /** - * Asynchronously gets the value of a specific feature flag. - * - *

If flags are not currently loaded, this method will trigger a fetch. The - * {@code completion} callback is invoked on the main UI thread with the flag's - * value or the {@code fallbackValue}. - * - * @param featureName The unique name (key) of the feature flag. - * @param fallbackValue The default value to return via the callback if the flag is - * not found, its value is null, or if the fetch operation fails. - * Can be null. - * @param completion The {@link FlagCompletionCallback} that will be invoked on the main - * thread with the result (the flag's value or the {@code fallbackValue}). - * This must not be null. - */ - void getVariantValue( - @NonNull String featureName, - @Nullable Object fallbackValue, - @NonNull FlagCompletionCallback completion - ); - - - /** - * Asynchronously checks if a specific feature flag is enabled. The evaluation of - * "enabled" follows the same rules as {@link #isEnabledSync(String, boolean)}. - * - *

If flags are not currently loaded, this method will trigger a fetch. The - * {@code completion} callback is invoked on the main UI thread with the boolean result. - * - * @param featureName The unique name (key) of the feature flag. - * @param fallbackValue The default boolean value to return via the callback if the flag - * is not found, cannot be evaluated as a boolean, or if the - * fetch operation fails. - * @param completion The {@link FlagCompletionCallback} that will be invoked on the main - * thread with the boolean result. This must not be null. - */ - void isEnabled( - @NonNull String featureName, - boolean fallbackValue, - @NonNull FlagCompletionCallback completion - ); + @Override + public void setOnceMap(Map properties) { + if (hasOptedOutTracking()) return; + if (null == properties) { + MPLog.e(LOGTAG, "setOnceMap does not accept null properties"); + return; + } + try { + setOnce(new JSONObject(properties)); + } catch (NullPointerException e) { + MPLog.w(LOGTAG, "Can't have null keys in the properties for setOnceMap!"); + } } - - - - - - - /** - * Attempt to register MixpanelActivityLifecycleCallbacks to the application's event lifecycle. - * Once registered, we can automatically flush on an app background. - * - * This is only available if the android version is >= 16. - * - * This function is automatically called when the library is initialized unless you explicitly - * set com.mixpanel.android.MPConfig.AutoShowMixpanelUpdates to false in your AndroidManifest.xml - */ - @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) - /* package */ void registerMixpanelActivityLifecycleCallbacks() { - if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { - if (mContext.getApplicationContext() instanceof Application) { - final Application app = (Application) mContext.getApplicationContext(); - mMixpanelActivityLifecycleCallbacks = new MixpanelActivityLifecycleCallbacks(this, mConfig); - app.registerActivityLifecycleCallbacks(mMixpanelActivityLifecycleCallbacks); - } else { - MPLog.i(LOGTAG, "Context is not an Application, Mixpanel won't be able to automatically flush on an app background."); - } - } + @Override + public void setOnce(JSONObject properties) { + if (hasOptedOutTracking()) return; + try { + final JSONObject message = stdGroupMessage("$set_once", properties); + recordGroupMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception setting group properties"); + } } - /** - * Based on the application's event lifecycle this method will determine whether the app - * is running in the foreground or not. - * - * If your build version is below 14 this method will always return false. - * - * @return True if the app is running in the foreground. - */ - public boolean isAppInForeground() { - if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { - if (mMixpanelActivityLifecycleCallbacks != null) { - return mMixpanelActivityLifecycleCallbacks.isInForeground(); - } - } else { - MPLog.e(LOGTAG, "Your build version is below 14. This method will always return false."); - } - return false; + @Override + public void setOnce(String property, Object value) { + if (hasOptedOutTracking()) return; + try { + setOnce(new JSONObject().put(property, value)); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Property name cannot be null", e); + } } - /* package */ void onBackground() { - if (mConfig.getFlushOnBackground()) { - flush(); - } + @Override + public void union(String name, JSONArray value) { + if (hasOptedOutTracking()) return; + try { + final JSONObject properties = new JSONObject(); + properties.put(name, value); + final JSONObject message = stdGroupMessage("$union", properties); + recordGroupMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception unioning a property", e); + } } - /* package */ void onForeground() { - mSessionMetadata.initSession(); + @Override + public void remove(String name, Object value) { + if (hasOptedOutTracking()) return; + try { + final JSONObject properties = new JSONObject(); + properties.put(name, value); + final JSONObject message = stdGroupMessage("$remove", properties); + recordGroupMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception removing a property", e); + } } - // Package-level access. Used (at least) by MixpanelFCMMessagingService - // when OS-level events occur. - /* package */ interface InstanceProcessor { - void process(MixpanelAPI m); + @Override + public void unset(String name) { + if (hasOptedOutTracking()) return; + try { + final JSONArray names = new JSONArray(); + names.put(name); + final JSONObject message = stdGroupMessage("$unset", names); + recordGroupMessage(message); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception unsetting a property", e); + } } - /* package */ static void allInstances(InstanceProcessor processor) { - synchronized (sInstanceMap) { - for (final Map contextInstances : sInstanceMap.values()) { - for (final MixpanelAPI instance : contextInstances.values()) { - processor.process(instance); - } - } - } + @Override + public void deleteGroup() { + try { + final JSONObject message = stdGroupMessage("$delete", JSONObject.NULL); + recordGroupMessage(message); + mGroups.remove(makeMapKey(mGroupKey, mGroupID)); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception deleting a group", e); + } } - //////////////////////////////////////////////////////////////////// - // Conveniences for testing. These methods should not be called by - // non-test client code. - - /* package */ AnalyticsMessages getAnalyticsMessages() { - return AnalyticsMessages.getInstance(mContext, mConfig); - } + private JSONObject stdGroupMessage(String actionType, Object properties) throws JSONException { + final JSONObject dataObj = new JSONObject(); + dataObj.put(actionType, properties); + dataObj.put("$token", mToken); + dataObj.put("$time", System.currentTimeMillis()); + dataObj.put("$group_key", mGroupKey); + dataObj.put("$group_id", mGroupID); + dataObj.put("$mp_metadata", mSessionMetadata.getMetadataForPeople()); - /* package */ PersistentIdentity getPersistentIdentity(final Context context, Future referrerPreferences, final String token) { - return getPersistentIdentity(context, referrerPreferences, token, null); + return dataObj; } + } // GroupImpl - /* package */ PersistentIdentity getPersistentIdentity(final Context context, Future referrerPreferences, final String token, final String instanceName) { - final SharedPreferencesLoader.OnPrefsLoadedListener listener = new SharedPreferencesLoader.OnPrefsLoadedListener() { - @Override - public void onPrefsLoaded(SharedPreferences preferences) { - final String distinctId = PersistentIdentity.getPeopleDistinctId(preferences); - if (null != distinctId) { - pushWaitingPeopleRecord(distinctId); - } - } - }; - - String instanceKey = instanceName != null ? instanceName : token; - final String prefsName = "com.mixpanel.android.mpmetrics.MixpanelAPI_" + instanceKey; - final Future storedPreferences = sPrefsLoader.loadPreferences(context, prefsName, listener); - - final String timeEventsPrefsName = "com.mixpanel.android.mpmetrics.MixpanelAPI.TimeEvents_" + instanceKey; - final Future timeEventsPrefs = sPrefsLoader.loadPreferences(context, timeEventsPrefsName, null); - - final String mixpanelPrefsName = "com.mixpanel.android.mpmetrics.Mixpanel"; - final Future mixpanelPrefs = sPrefsLoader.loadPreferences(context, mixpanelPrefsName, null); - - return new PersistentIdentity(referrerPreferences, storedPreferences, timeEventsPrefs, mixpanelPrefs); + protected void track(String eventName, JSONObject properties, boolean isAutomaticEvent) { + if (hasOptedOutTracking() || (isAutomaticEvent && !mTrackAutomaticEvents)) { + return; } - /* package */ boolean sendAppOpen() { - return !mConfig.getDisableAppOpenEvent(); + final Long eventBegin; + synchronized (mEventTimings) { + eventBegin = mEventTimings.get(eventName); + mEventTimings.remove(eventName); + mPersistentIdentity.removeTimedEvent(eventName); } - /////////////////////// + try { + final JSONObject messageProps = new JSONObject(); - private class PeopleImpl implements People { - @Override - public void identify(String distinctId) { - if (hasOptedOutTracking()) return; - MPLog.w(LOGTAG, "People.identify() is deprecated and calling it is no longer necessary, " + - "please use MixpanelAPI.identify() and set 'usePeople' to true instead"); - if (distinctId == null) { - MPLog.e(LOGTAG, "Can't identify with null distinct_id."); - return; - } - if (!distinctId.equals(mPersistentIdentity.getEventsDistinctId())) { - MPLog.w(LOGTAG, "Identifying with a distinct_id different from the one being set by MixpanelAPI.identify() is not supported."); - return; - } - identify_people(distinctId); - } - - private void identify_people(String distinctId) { - synchronized (mPersistentIdentity) { - mPersistentIdentity.setPeopleDistinctId(distinctId); - } - pushWaitingPeopleRecord(distinctId); - } - - @Override - public void setMap(Map properties) { - if (hasOptedOutTracking()) return; - if (null == properties) { - MPLog.e(LOGTAG, "setMap does not accept null properties"); - return; - } - - try { - set(new JSONObject(properties)); - } catch (NullPointerException e) { - MPLog.w(LOGTAG, "Can't have null keys in the properties of setMap!"); - } - } - - @Override - public void set(JSONObject properties) { - if (hasOptedOutTracking()) return; - try { - final JSONObject sendProperties = new JSONObject(mDeviceInfo); - for (final Iterator iter = properties.keys(); iter.hasNext();) { - final String key = (String) iter.next(); - sendProperties.put(key, properties.get(key)); - } - - final JSONObject message = stdPeopleMessage("$set", sendProperties); - recordPeopleMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception setting people properties", e); - } - } - - @Override - public void set(String property, Object value) { - if (hasOptedOutTracking()) return; - try { - set(new JSONObject().put(property, value)); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "set", e); - } - } - - @Override - public void setOnceMap(Map properties) { - if (hasOptedOutTracking()) return; - if (null == properties) { - MPLog.e(LOGTAG, "setOnceMap does not accept null properties"); - return; - } - try { - setOnce(new JSONObject(properties)); - } catch (NullPointerException e) { - MPLog.w(LOGTAG, "Can't have null keys in the properties setOnceMap!"); - } - } - - @Override - public void setOnce(JSONObject properties) { - if (hasOptedOutTracking()) return; - try { - final JSONObject message = stdPeopleMessage("$set_once", properties); - recordPeopleMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception setting people properties"); - } - } - - @Override - public void setOnce(String property, Object value) { - if (hasOptedOutTracking()) return; - try { - setOnce(new JSONObject().put(property, value)); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "set", e); - } - } - - @Override - public void increment(Map properties) { - if (hasOptedOutTracking()) return; - final JSONObject json = new JSONObject(properties); - try { - final JSONObject message = stdPeopleMessage("$add", json); - recordPeopleMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception incrementing properties", e); - } - } - - @Override - // Must be thread safe - public void merge(String property, JSONObject updates) { - if (hasOptedOutTracking()) return; - final JSONObject mergeMessage = new JSONObject(); - try { - mergeMessage.put(property, updates); - final JSONObject message = stdPeopleMessage("$merge", mergeMessage); - recordPeopleMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception merging a property", e); - } - } - - @Override - public void increment(String property, double value) { - if (hasOptedOutTracking()) return; - final Map map = new HashMap(); - map.put(property, value); - increment(map); - } - - @Override - public void append(String name, Object value) { - if (hasOptedOutTracking()) return; - try { - final JSONObject properties = new JSONObject(); - properties.put(name, value); - final JSONObject message = stdPeopleMessage("$append", properties); - recordPeopleMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception appending a property", e); - } - } - - @Override - public void union(String name, JSONArray value) { - if (hasOptedOutTracking()) return; - try { - final JSONObject properties = new JSONObject(); - properties.put(name, value); - final JSONObject message = stdPeopleMessage("$union", properties); - recordPeopleMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception unioning a property"); - } - } - - @Override - public void remove(String name, Object value) { - if (hasOptedOutTracking()) return; - try { - final JSONObject properties = new JSONObject(); - properties.put(name, value); - final JSONObject message = stdPeopleMessage("$remove", properties); - recordPeopleMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception appending a property", e); - } - } - - @Override - public void unset(String name) { - if (hasOptedOutTracking()) return; - try { - final JSONArray names = new JSONArray(); - names.put(name); - final JSONObject message = stdPeopleMessage("$unset", names); - recordPeopleMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception unsetting a property", e); - } - } - - @Override - public void trackCharge(double amount, JSONObject properties) { - if (hasOptedOutTracking()) return; - final Date now = new Date(); - final DateFormat dateFormat = new SimpleDateFormat(ENGAGE_DATE_FORMAT_STRING, Locale.US); - dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); - - try { - final JSONObject transactionValue = new JSONObject(); - transactionValue.put("$amount", amount); - transactionValue.put("$time", dateFormat.format(now)); - - if (null != properties) { - for (final Iterator iter = properties.keys(); iter.hasNext();) { - final String key = (String) iter.next(); - transactionValue.put(key, properties.get(key)); - } - } - - this.append("$transactions", transactionValue); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception creating new charge", e); - } - } - - /** - * Permanently clear the whole transaction history for the identified people profile. - */ - @Override - public void clearCharges() { - this.unset("$transactions"); - } - - @Override - public void deleteUser() { - try { - final JSONObject message = stdPeopleMessage("$delete", JSONObject.NULL); - recordPeopleMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception deleting a user"); - } - } - - @Override - public String getDistinctId() { - return mPersistentIdentity.getPeopleDistinctId(); - } - - @Override - public People withIdentity(final String distinctId) { - if (null == distinctId) { - return null; - } - return new PeopleImpl() { - @Override - public String getDistinctId() { - return distinctId; - } - - @Override - public void identify(String distinctId) { - throw new RuntimeException("This MixpanelPeople object has a fixed, constant distinctId"); - } - }; - } - - private JSONObject stdPeopleMessage(String actionType, Object properties) - throws JSONException { - final JSONObject dataObj = new JSONObject(); - final String distinctId = getDistinctId(); // TODO ensure getDistinctId is thread safe - final String anonymousId = getAnonymousId(); - dataObj.put(actionType, properties); - dataObj.put("$token", mToken); - dataObj.put("$time", System.currentTimeMillis()); - dataObj.put("$had_persisted_distinct_id", mPersistentIdentity.getHadPersistedDistinctId()); - if (null != anonymousId) { - dataObj.put("$device_id", anonymousId); - } - if (null != distinctId) { - dataObj.put("$distinct_id", distinctId); - dataObj.put("$user_id", distinctId); - } - dataObj.put("$mp_metadata", mSessionMetadata.getMetadataForPeople()); - return dataObj; - } - - @Override - public boolean isIdentified() { - return getDistinctId() != null; - } - }// PeopleImpl - - - private class GroupImpl implements Group { - private final String mGroupKey; - private final Object mGroupID; - - public GroupImpl(String groupKey, Object groupID) { - mGroupKey = groupKey; - mGroupID = groupID; - } - - @Override - public void setMap(Map properties) { - if (hasOptedOutTracking()) return; - if (null == properties) { - MPLog.e(LOGTAG, "setMap does not accept null properties"); - return; - } - - set(new JSONObject(properties)); - } - - @Override - public void set(JSONObject properties) { - if (hasOptedOutTracking()) return; - try { - final JSONObject sendProperties = new JSONObject(); - for (final Iterator iter = properties.keys(); iter.hasNext();) { - final String key = (String) iter.next(); - sendProperties.put(key, properties.get(key)); - } - - final JSONObject message = stdGroupMessage("$set", sendProperties); - recordGroupMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception setting group properties", e); - } - } - - @Override - public void set(String property, Object value) { - if (hasOptedOutTracking()) return; - try { - set(new JSONObject().put(property, value)); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "set", e); - } - } - - @Override - public void setOnceMap(Map properties) { - if (hasOptedOutTracking()) return; - if (null == properties) { - MPLog.e(LOGTAG, "setOnceMap does not accept null properties"); - return; - } - try { - setOnce(new JSONObject(properties)); - } catch (NullPointerException e) { - MPLog.w(LOGTAG, "Can't have null keys in the properties for setOnceMap!"); - } - } - - @Override - public void setOnce(JSONObject properties) { - if (hasOptedOutTracking()) return; - try { - final JSONObject message = stdGroupMessage("$set_once", properties); - recordGroupMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception setting group properties"); - } - } - - @Override - public void setOnce(String property, Object value) { - if (hasOptedOutTracking()) return; - try { - setOnce(new JSONObject().put(property, value)); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Property name cannot be null", e); - } - } - - @Override - public void union(String name, JSONArray value) { - if (hasOptedOutTracking()) return; - try { - final JSONObject properties = new JSONObject(); - properties.put(name, value); - final JSONObject message = stdGroupMessage("$union", properties); - recordGroupMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception unioning a property", e); - } - } - - @Override - public void remove(String name, Object value) { - if (hasOptedOutTracking()) return; - try { - final JSONObject properties = new JSONObject(); - properties.put(name, value); - final JSONObject message = stdGroupMessage("$remove", properties); - recordGroupMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception removing a property", e); - } - } - - @Override - public void unset(String name) { - if (hasOptedOutTracking()) return; - try { - final JSONArray names = new JSONArray(); - names.put(name); - final JSONObject message = stdGroupMessage("$unset", names); - recordGroupMessage(message); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception unsetting a property", e); - } - } - - @Override - public void deleteGroup() { - try { - final JSONObject message = stdGroupMessage("$delete", JSONObject.NULL); - recordGroupMessage(message); - mGroups.remove(makeMapKey(mGroupKey, mGroupID)); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception deleting a group", e); - } - } - - private JSONObject stdGroupMessage(String actionType, Object properties) - throws JSONException { - final JSONObject dataObj = new JSONObject(); - - dataObj.put(actionType, properties); - dataObj.put("$token", mToken); - dataObj.put("$time", System.currentTimeMillis()); - dataObj.put("$group_key", mGroupKey); - dataObj.put("$group_id", mGroupID); - dataObj.put("$mp_metadata", mSessionMetadata.getMetadataForPeople()); - - return dataObj; - } - }// GroupImpl - - protected void track(String eventName, JSONObject properties, boolean isAutomaticEvent) { - if (hasOptedOutTracking() || (isAutomaticEvent && !mTrackAutomaticEvents)) { - return; - } - - final Long eventBegin; - synchronized (mEventTimings) { - eventBegin = mEventTimings.get(eventName); - mEventTimings.remove(eventName); - mPersistentIdentity.removeTimedEvent(eventName); - } - - try { - final JSONObject messageProps = new JSONObject(); - - final Map referrerProperties = mPersistentIdentity.getReferrerProperties(); - for (final Map.Entry entry : referrerProperties.entrySet()) { - final String key = entry.getKey(); - final String value = entry.getValue(); - messageProps.put(key, value); - } - - mPersistentIdentity.addSuperPropertiesToObject(messageProps); - - // Don't allow super properties or referral properties to override these fields, - // but DO allow the caller to override them in their given properties. - final double timeSecondsDouble = (System.currentTimeMillis()) / 1000.0; - final String distinctId = getDistinctId(); - final String anonymousId = getAnonymousId(); - final String userId = getUserId(); - messageProps.put("time", System.currentTimeMillis()); - messageProps.put("distinct_id", distinctId); - messageProps.put("$had_persisted_distinct_id", mPersistentIdentity.getHadPersistedDistinctId()); - if(anonymousId != null) { - messageProps.put("$device_id", anonymousId); - } - if(userId != null) { - messageProps.put("$user_id", userId); - } - - if (null != eventBegin) { - final double eventBeginDouble = ((double) eventBegin) / 1000.0; - final double secondsElapsed = timeSecondsDouble - eventBeginDouble; - messageProps.put("$duration", secondsElapsed); - } - - if (null != properties) { - final Iterator propIter = properties.keys(); - while (propIter.hasNext()) { - final String key = (String) propIter.next(); - messageProps.put(key, properties.opt(key)); - } - } + final Map referrerProperties = mPersistentIdentity.getReferrerProperties(); + for (final Map.Entry entry : referrerProperties.entrySet()) { + final String key = entry.getKey(); + final String value = entry.getValue(); + messageProps.put(key, value); + } - final AnalyticsMessages.EventDescription eventDescription = - new AnalyticsMessages.EventDescription(eventName, messageProps, - mToken, isAutomaticEvent, mSessionMetadata.getMetadataForEvent()); - mMessages.eventsMessage(eventDescription); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception tracking event " + eventName, e); - } - } + mPersistentIdentity.addSuperPropertiesToObject(messageProps); + + // Don't allow super properties or referral properties to override these fields, + // but DO allow the caller to override them in their given properties. + final double timeSecondsDouble = (System.currentTimeMillis()) / 1000.0; + final String distinctId = getDistinctId(); + final String anonymousId = getAnonymousId(); + final String userId = getUserId(); + messageProps.put("time", System.currentTimeMillis()); + messageProps.put("distinct_id", distinctId); + messageProps.put( + "$had_persisted_distinct_id", mPersistentIdentity.getHadPersistedDistinctId()); + if (anonymousId != null) { + messageProps.put("$device_id", anonymousId); + } + if (userId != null) { + messageProps.put("$user_id", userId); + } - private void recordPeopleMessage(JSONObject message) { - if (hasOptedOutTracking()) return; - mMessages.peopleMessage(new AnalyticsMessages.PeopleDescription(message, mToken)); - } + if (null != eventBegin) { + final double eventBeginDouble = ((double) eventBegin) / 1000.0; + final double secondsElapsed = timeSecondsDouble - eventBeginDouble; + messageProps.put("$duration", secondsElapsed); + } - private void recordGroupMessage(JSONObject message) { - if (hasOptedOutTracking()) return; - if (message.has("$group_key") && message.has("$group_id")) { - mMessages.groupMessage(new AnalyticsMessages.GroupDescription(message, mToken)); - } else { - MPLog.e(LOGTAG, "Attempt to update group without key and value--this should not happen."); + if (null != properties) { + final Iterator propIter = properties.keys(); + while (propIter.hasNext()) { + final String key = (String) propIter.next(); + messageProps.put(key, properties.opt(key)); } - } - - private void pushWaitingPeopleRecord(String distinctId) { - mMessages.pushAnonymousPeopleMessage(new AnalyticsMessages.PushAnonymousPeopleDescription(distinctId, mToken)); - } + } - private static void registerAppLinksListeners(Context context, final MixpanelAPI mixpanel) { - // Register a BroadcastReceiver to receive com.parse.bolts.measurement_event and track a call to mixpanel - try { - final Class clazz = Class.forName("androidx.localbroadcastmanager.content.LocalBroadcastManager"); - final Method methodGetInstance = clazz.getMethod("getInstance", Context.class); - final Method methodRegisterReceiver = clazz.getMethod("registerReceiver", BroadcastReceiver.class, IntentFilter.class); - final Object localBroadcastManager = methodGetInstance.invoke(null, context); - methodRegisterReceiver.invoke(localBroadcastManager, new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - final JSONObject properties = new JSONObject(); - final Bundle args = intent.getBundleExtra("event_args"); - if (args != null) { - for (final String key : args.keySet()) { - try { - properties.put(key, args.get(key)); - } catch (final JSONException e) { - MPLog.e(APP_LINKS_LOGTAG, "failed to add key \"" + key + "\" to properties for tracking bolts event", e); - } - } - } - mixpanel.track("$" + intent.getStringExtra("event_name"), properties); + final AnalyticsMessages.EventDescription eventDescription = + new AnalyticsMessages.EventDescription( + eventName, + messageProps, + mToken, + isAutomaticEvent, + mSessionMetadata.getMetadataForEvent()); + mMessages.eventsMessage(eventDescription); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception tracking event " + eventName, e); + } + } + + private void recordPeopleMessage(JSONObject message) { + if (hasOptedOutTracking()) return; + mMessages.peopleMessage(new AnalyticsMessages.PeopleDescription(message, mToken)); + } + + private void recordGroupMessage(JSONObject message) { + if (hasOptedOutTracking()) return; + if (message.has("$group_key") && message.has("$group_id")) { + mMessages.groupMessage(new AnalyticsMessages.GroupDescription(message, mToken)); + } else { + MPLog.e(LOGTAG, "Attempt to update group without key and value--this should not happen."); + } + } + + private void pushWaitingPeopleRecord(String distinctId) { + mMessages.pushAnonymousPeopleMessage( + new AnalyticsMessages.PushAnonymousPeopleDescription(distinctId, mToken)); + } + + private static void registerAppLinksListeners(Context context, final MixpanelAPI mixpanel) { + // Register a BroadcastReceiver to receive com.parse.bolts.measurement_event and track a call to + // mixpanel + try { + final Class clazz = + Class.forName("androidx.localbroadcastmanager.content.LocalBroadcastManager"); + final Method methodGetInstance = clazz.getMethod("getInstance", Context.class); + final Method methodRegisterReceiver = + clazz.getMethod("registerReceiver", BroadcastReceiver.class, IntentFilter.class); + final Object localBroadcastManager = methodGetInstance.invoke(null, context); + methodRegisterReceiver.invoke( + localBroadcastManager, + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + final JSONObject properties = new JSONObject(); + final Bundle args = intent.getBundleExtra("event_args"); + if (args != null) { + for (final String key : args.keySet()) { + try { + properties.put(key, args.get(key)); + } catch (final JSONException e) { + MPLog.e( + APP_LINKS_LOGTAG, + "failed to add key \"" + key + "\" to properties for tracking bolts event", + e); + } } - }, new IntentFilter("com.parse.bolts.measurement_event")); - } catch (final InvocationTargetException e) { - MPLog.d(APP_LINKS_LOGTAG, "Failed to invoke LocalBroadcastManager.registerReceiver() -- App Links tracking will not be enabled due to this exception", e); - } catch (final ClassNotFoundException e) { - MPLog.d(APP_LINKS_LOGTAG, "To enable App Links tracking, add implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0': " + e.getMessage()); - } catch (final NoSuchMethodException e) { - MPLog.d(APP_LINKS_LOGTAG, "To enable App Links tracking, add implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0': " + e.getMessage()); - } catch (final IllegalAccessException e) { - MPLog.d(APP_LINKS_LOGTAG, "App Links tracking will not be enabled due to this exception: " + e.getMessage()); - } - } - - private static void checkIntentForInboundAppLink(Context context) { - // call the Bolts getTargetUrlFromInboundIntent method simply for a side effect - // if the intent is the result of an App Link, it'll trigger al_nav_in - // https://github.com/BoltsFramework/Bolts-Android/blob/1.1.2/Bolts/src/bolts/AppLinks.java#L86 - if (context instanceof Activity) { - try { - final Class clazz = Class.forName("bolts.AppLinks"); - final Intent intent = ((Activity) context).getIntent(); - final Method getTargetUrlFromInboundIntent = clazz.getMethod("getTargetUrlFromInboundIntent", Context.class, Intent.class); - getTargetUrlFromInboundIntent.invoke(null, context, intent); - } catch (final InvocationTargetException e) { - MPLog.d(APP_LINKS_LOGTAG, "Failed to invoke bolts.AppLinks.getTargetUrlFromInboundIntent() -- Unable to detect inbound App Links", e); - } catch (final ClassNotFoundException e) { - MPLog.d(APP_LINKS_LOGTAG, "Please install the Bolts library >= 1.1.2 to track App Links: " + e.getMessage()); - } catch (final NoSuchMethodException e) { - MPLog.d(APP_LINKS_LOGTAG, "Please install the Bolts library >= 1.1.2 to track App Links: " + e.getMessage()); - } catch (final IllegalAccessException e) { - MPLog.d(APP_LINKS_LOGTAG, "Unable to detect inbound App Links: " + e.getMessage()); + } + mixpanel.track("$" + intent.getStringExtra("event_name"), properties); } - } else { - MPLog.d(APP_LINKS_LOGTAG, "Context is not an instance of Activity. To detect inbound App Links, pass an instance of an Activity to getInstance."); - } - } - - /* package */ Context getContext() { - return mContext; - } - - RemoteService getHttpService() { - if (this.mHttpService == null) { - this.mHttpService = new HttpService(false, null); - } - return this.mHttpService; - } - - private final Context mContext; - private final AnalyticsMessages mMessages; - private final MPConfig mConfig; - private final Boolean mTrackAutomaticEvents; - private final String mToken; - private final String mInstanceName; - private final PeopleImpl mPeople; - private final Map mGroups; - private final PersistentIdentity mPersistentIdentity; - private final Map mDeviceInfo; - private final Map mEventTimings; - private MixpanelActivityLifecycleCallbacks mMixpanelActivityLifecycleCallbacks; - private final SessionMetadata mSessionMetadata; - private FeatureFlagManager mFeatureFlagManager; - private RemoteService mHttpService; - - // Maps each token to a singleton MixpanelAPI instance - private static final Map> sInstanceMap = new HashMap>(); - private static final SharedPreferencesLoader sPrefsLoader = new SharedPreferencesLoader(); - private static Future sReferrerPrefs; - - private static final String LOGTAG = "MixpanelAPI.API"; - private static final String APP_LINKS_LOGTAG = "MixpanelAPI.AL"; - private static final String ENGAGE_DATE_FORMAT_STRING = "yyyy-MM-dd'T'HH:mm:ss"; + }, + new IntentFilter("com.parse.bolts.measurement_event")); + } catch (final InvocationTargetException e) { + MPLog.d( + APP_LINKS_LOGTAG, + "Failed to invoke LocalBroadcastManager.registerReceiver() -- App Links tracking will not" + + " be enabled due to this exception", + e); + } catch (final ClassNotFoundException e) { + MPLog.d( + APP_LINKS_LOGTAG, + "To enable App Links tracking, add implementation" + + " 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0': " + + e.getMessage()); + } catch (final NoSuchMethodException e) { + MPLog.d( + APP_LINKS_LOGTAG, + "To enable App Links tracking, add implementation" + + " 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0': " + + e.getMessage()); + } catch (final IllegalAccessException e) { + MPLog.d( + APP_LINKS_LOGTAG, + "App Links tracking will not be enabled due to this exception: " + e.getMessage()); + } + } + + private static void checkIntentForInboundAppLink(Context context) { + // call the Bolts getTargetUrlFromInboundIntent method simply for a side effect + // if the intent is the result of an App Link, it'll trigger al_nav_in + // https://github.com/BoltsFramework/Bolts-Android/blob/1.1.2/Bolts/src/bolts/AppLinks.java#L86 + if (context instanceof Activity) { + try { + final Class clazz = Class.forName("bolts.AppLinks"); + final Intent intent = ((Activity) context).getIntent(); + final Method getTargetUrlFromInboundIntent = + clazz.getMethod("getTargetUrlFromInboundIntent", Context.class, Intent.class); + getTargetUrlFromInboundIntent.invoke(null, context, intent); + } catch (final InvocationTargetException e) { + MPLog.d( + APP_LINKS_LOGTAG, + "Failed to invoke bolts.AppLinks.getTargetUrlFromInboundIntent() -- Unable to detect" + + " inbound App Links", + e); + } catch (final ClassNotFoundException e) { + MPLog.d( + APP_LINKS_LOGTAG, + "Please install the Bolts library >= 1.1.2 to track App Links: " + e.getMessage()); + } catch (final NoSuchMethodException e) { + MPLog.d( + APP_LINKS_LOGTAG, + "Please install the Bolts library >= 1.1.2 to track App Links: " + e.getMessage()); + } catch (final IllegalAccessException e) { + MPLog.d(APP_LINKS_LOGTAG, "Unable to detect inbound App Links: " + e.getMessage()); + } + } else { + MPLog.d( + APP_LINKS_LOGTAG, + "Context is not an instance of Activity. To detect inbound App Links, pass an instance of" + + " an Activity to getInstance."); + } + } + + /* package */ Context getContext() { + return mContext; + } + + RemoteService getHttpService() { + if (this.mHttpService == null) { + this.mHttpService = new HttpService(false, null); + } + return this.mHttpService; + } + + private final Context mContext; + private final AnalyticsMessages mMessages; + private final MPConfig mConfig; + private final Boolean mTrackAutomaticEvents; + private final String mToken; + private final String mInstanceName; + private final PeopleImpl mPeople; + private final Map mGroups; + private final PersistentIdentity mPersistentIdentity; + private final Map mDeviceInfo; + private final Map mEventTimings; + private MixpanelActivityLifecycleCallbacks mMixpanelActivityLifecycleCallbacks; + private final SessionMetadata mSessionMetadata; + private FeatureFlagManager mFeatureFlagManager; + private RemoteService mHttpService; + + // Maps each token to a singleton MixpanelAPI instance + private static final Map> sInstanceMap = + new HashMap>(); + private static final SharedPreferencesLoader sPrefsLoader = new SharedPreferencesLoader(); + private static Future sReferrerPrefs; + + private static final String LOGTAG = "MixpanelAPI.API"; + private static final String APP_LINKS_LOGTAG = "MixpanelAPI.AL"; + private static final String ENGAGE_DATE_FORMAT_STRING = "yyyy-MM-dd'T'HH:mm:ss"; } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelActivityLifecycleCallbacks.java b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelActivityLifecycleCallbacks.java index 2561f5a9d..02c2a10cb 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelActivityLifecycleCallbacks.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelActivityLifecycleCallbacks.java @@ -7,103 +7,108 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; - +import java.lang.ref.WeakReference; import org.json.JSONException; import org.json.JSONObject; -import java.lang.ref.WeakReference; - @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) -/* package */ class MixpanelActivityLifecycleCallbacks implements Application.ActivityLifecycleCallbacks { - private final Handler mHandler = new Handler(Looper.getMainLooper()); - private Runnable check; - private boolean mIsForeground = false; - private boolean mPaused = true; - private static Double sStartSessionTime; - public static final int CHECK_DELAY = 500; - - public MixpanelActivityLifecycleCallbacks(MixpanelAPI mpInstance, MPConfig config) { - mMpInstance = mpInstance; - mConfig = config; - if (sStartSessionTime == null) { - sStartSessionTime = (double) System.currentTimeMillis(); - } +/* package */ class MixpanelActivityLifecycleCallbacks + implements Application.ActivityLifecycleCallbacks { + private final Handler mHandler = new Handler(Looper.getMainLooper()); + private Runnable check; + private boolean mIsForeground = false; + private boolean mPaused = true; + private static Double sStartSessionTime; + public static final int CHECK_DELAY = 500; + + public MixpanelActivityLifecycleCallbacks(MixpanelAPI mpInstance, MPConfig config) { + mMpInstance = mpInstance; + mConfig = config; + if (sStartSessionTime == null) { + sStartSessionTime = (double) System.currentTimeMillis(); } + } - @Override - public void onActivityStarted(Activity activity) { - } + @Override + public void onActivityStarted(Activity activity) {} - @Override - public void onActivityCreated(Activity activity, Bundle savedInstanceState) { } + @Override + public void onActivityCreated(Activity activity, Bundle savedInstanceState) {} - @Override - public void onActivityPaused(final Activity activity) { - mPaused = true; + @Override + public void onActivityPaused(final Activity activity) { + mPaused = true; - if (check != null) { - mHandler.removeCallbacks(check); - } - mCurrentActivity = null; + if (check != null) { + mHandler.removeCallbacks(check); + } + mCurrentActivity = null; - mHandler.postDelayed(check = new Runnable(){ - @Override - public void run() { + mHandler.postDelayed( + check = + new Runnable() { + @Override + public void run() { if (mIsForeground && mPaused) { - mIsForeground = false; - try { - double sessionLength = System.currentTimeMillis() - sStartSessionTime; - if (sessionLength >= mConfig.getMinimumSessionDuration() && sessionLength < mConfig.getSessionTimeoutDuration() && mMpInstance.getTrackAutomaticEvents()) { - double elapsedTime = sessionLength / 1000; - double elapsedTimeRounded = Math.round(elapsedTime * 10.0) / 10.0; - JSONObject sessionProperties = new JSONObject(); - sessionProperties.put(AutomaticEvents.SESSION_LENGTH, elapsedTimeRounded); - mMpInstance.getPeople().increment(AutomaticEvents.TOTAL_SESSIONS, 1); - mMpInstance.getPeople().increment(AutomaticEvents.TOTAL_SESSIONS_LENGTH, elapsedTimeRounded); - mMpInstance.track(AutomaticEvents.SESSION, sessionProperties, true); - } - } catch (JSONException e) { - e.printStackTrace(); + mIsForeground = false; + try { + double sessionLength = System.currentTimeMillis() - sStartSessionTime; + if (sessionLength >= mConfig.getMinimumSessionDuration() + && sessionLength < mConfig.getSessionTimeoutDuration() + && mMpInstance.getTrackAutomaticEvents()) { + double elapsedTime = sessionLength / 1000; + double elapsedTimeRounded = Math.round(elapsedTime * 10.0) / 10.0; + JSONObject sessionProperties = new JSONObject(); + sessionProperties.put(AutomaticEvents.SESSION_LENGTH, elapsedTimeRounded); + mMpInstance.getPeople().increment(AutomaticEvents.TOTAL_SESSIONS, 1); + mMpInstance + .getPeople() + .increment(AutomaticEvents.TOTAL_SESSIONS_LENGTH, elapsedTimeRounded); + mMpInstance.track(AutomaticEvents.SESSION, sessionProperties, true); } - mMpInstance.onBackground(); + } catch (JSONException e) { + e.printStackTrace(); + } + mMpInstance.onBackground(); } - } - }, CHECK_DELAY); - } + } + }, + CHECK_DELAY); + } - @Override - public void onActivityDestroyed(Activity activity) { } + @Override + public void onActivityDestroyed(Activity activity) {} - @Override - public void onActivitySaveInstanceState(Activity activity, Bundle outState) { } + @Override + public void onActivitySaveInstanceState(Activity activity, Bundle outState) {} - @Override - public void onActivityResumed(Activity activity) { - mCurrentActivity = new WeakReference<>(activity); + @Override + public void onActivityResumed(Activity activity) { + mCurrentActivity = new WeakReference<>(activity); - mPaused = false; - boolean wasBackground = !mIsForeground; - mIsForeground = true; + mPaused = false; + boolean wasBackground = !mIsForeground; + mIsForeground = true; - if (check != null) { - mHandler.removeCallbacks(check); - } + if (check != null) { + mHandler.removeCallbacks(check); + } - if (wasBackground) { - // App is in foreground now - sStartSessionTime = (double) System.currentTimeMillis(); - mMpInstance.onForeground(); - } + if (wasBackground) { + // App is in foreground now + sStartSessionTime = (double) System.currentTimeMillis(); + mMpInstance.onForeground(); } + } - @Override - public void onActivityStopped(Activity activity) { } + @Override + public void onActivityStopped(Activity activity) {} - protected boolean isInForeground() { - return mIsForeground; - } + protected boolean isInForeground() { + return mIsForeground; + } - private final MixpanelAPI mMpInstance; - private final MPConfig mConfig; - private WeakReference mCurrentActivity; + private final MixpanelAPI mMpInstance; + private final MPConfig mConfig; + private WeakReference mCurrentActivity; } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java index cc84b6f65..eee64c13c 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java @@ -4,73 +4,73 @@ import androidx.annotation.Nullable; /** - * Represents the data associated with a feature flag variant from the Mixpanel API. - * This class stores the key and value of a specific variant for a feature flag. - * It can be instantiated either by parsing an API response or by creating a fallback instance. + * Represents the data associated with a feature flag variant from the Mixpanel API. This class + * stores the key and value of a specific variant for a feature flag. It can be instantiated either + * by parsing an API response or by creating a fallback instance. */ public class MixpanelFlagVariant { - /** - * The key of the feature flag variant. This corresponds to the 'variant_key' - * field in the Mixpanel API response. It cannot be null. - */ - @NonNull - public final String key; + /** + * The key of the feature flag variant. This corresponds to the 'variant_key' field in the + * Mixpanel API response. It cannot be null. + */ + @NonNull public final String key; - /** - * The value of the feature flag variant. This corresponds to the 'variant_value' - * field in the Mixpanel API response. The value can be of type Boolean, String, - * Number (Integer, Double, Float, Long), JSONArray, JSONObject, or it can be null. - */ - @Nullable - public final Object value; + /** + * The value of the feature flag variant. This corresponds to the 'variant_value' field in the + * Mixpanel API response. The value can be of type Boolean, String, Number (Integer, Double, + * Float, Long), JSONArray, JSONObject, or it can be null. + */ + @Nullable public final Object value; - /** - * Constructs a {@code FeatureFlagData} object when parsing an API response. - * - * @param key The key of the feature flag variant. Corresponds to 'variant_key' from the API. Cannot be null. - * @param value The value of the feature flag variant. Corresponds to 'variant_value' from the API. - * Can be Boolean, String, Number, JSONArray, JSONObject, or null. - */ - public MixpanelFlagVariant(@NonNull String key, @Nullable Object value) { - this.key = key; - this.value = value; - } + /** + * Constructs a {@code FeatureFlagData} object when parsing an API response. + * + * @param key The key of the feature flag variant. Corresponds to 'variant_key' from the API. + * Cannot be null. + * @param value The value of the feature flag variant. Corresponds to 'variant_value' from the + * API. Can be Boolean, String, Number, JSONArray, JSONObject, or null. + */ + public MixpanelFlagVariant(@NonNull String key, @Nullable Object value) { + this.key = key; + this.value = value; + } - /** - * Constructs a {@code FeatureFlagData} object for creating fallback instances. - * In this case, the provided {@code keyAndValue} is used as both the key and the value - * for the feature flag data. This is typically used when a flag is not found - * and a default string value needs to be returned. - * - * @param keyAndValue The string value to be used as both the key and the value for this fallback. Cannot be null. - */ - public MixpanelFlagVariant(@NonNull String keyAndValue) { - this.key = keyAndValue; // Default key to the value itself - this.value = keyAndValue; - } + /** + * Constructs a {@code FeatureFlagData} object for creating fallback instances. In this case, the + * provided {@code keyAndValue} is used as both the key and the value for the feature flag data. + * This is typically used when a flag is not found and a default string value needs to be + * returned. + * + * @param keyAndValue The string value to be used as both the key and the value for this fallback. + * Cannot be null. + */ + public MixpanelFlagVariant(@NonNull String keyAndValue) { + this.key = keyAndValue; // Default key to the value itself + this.value = keyAndValue; + } - /** - * Constructs a {@code FeatureFlagData} object for creating fallback instances. - * In this version, the key is set to an empty string (""), and the provided {@code value} - * is used as the value for the feature flag data. This is typically used when a - * flag is not found or an error occurs, and a default value needs to be provided. - * - * @param value The object value to be used for this fallback. Cannot be null. - * This can be of type Boolean, String, Number, JSONArray, or JSONObject. - */ - public MixpanelFlagVariant(@NonNull Object value) { - this.key = ""; - this.value = value; - } + /** + * Constructs a {@code FeatureFlagData} object for creating fallback instances. In this version, + * the key is set to an empty string (""), and the provided {@code value} is used as the value for + * the feature flag data. This is typically used when a flag is not found or an error occurs, and + * a default value needs to be provided. + * + * @param value The object value to be used for this fallback. Cannot be null. This can be of type + * Boolean, String, Number, JSONArray, or JSONObject. + */ + public MixpanelFlagVariant(@NonNull Object value) { + this.key = ""; + this.value = value; + } - /** - * Default constructor that initializes an empty {@code FeatureFlagData} object. - * The key is set to an empty string ("") and the value is set to null. - * This constructor might be used internally or for specific default cases. - */ - MixpanelFlagVariant() { - this.key = ""; - this.value = null; - } -} \ No newline at end of file + /** + * Default constructor that initializes an empty {@code FeatureFlagData} object. The key is set to + * an empty string ("") and the value is set to null. This constructor might be used internally or + * for specific default cases. + */ + MixpanelFlagVariant() { + this.key = ""; + this.value = null; + } +} diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelOptions.java b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelOptions.java index 637ee9164..36f45b73f 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelOptions.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelOptions.java @@ -3,163 +3,161 @@ import static com.mixpanel.android.mpmetrics.ConfigurationChecker.LOGTAG; import com.mixpanel.android.util.MPLog; - import org.json.JSONObject; public class MixpanelOptions { - private final String instanceName; - private final boolean optOutTrackingDefault; - private final JSONObject superProperties; - private final boolean featureFlagsEnabled; - private final JSONObject featureFlagsContext; - - private MixpanelOptions(Builder builder) { - this.instanceName = builder.instanceName; - this.optOutTrackingDefault = builder.optOutTrackingDefault; - this.superProperties = builder.superProperties; - this.featureFlagsEnabled = builder.featureFlagsEnabled; - this.featureFlagsContext = builder.featureFlagsContext; + private final String instanceName; + private final boolean optOutTrackingDefault; + private final JSONObject superProperties; + private final boolean featureFlagsEnabled; + private final JSONObject featureFlagsContext; + + private MixpanelOptions(Builder builder) { + this.instanceName = builder.instanceName; + this.optOutTrackingDefault = builder.optOutTrackingDefault; + this.superProperties = builder.superProperties; + this.featureFlagsEnabled = builder.featureFlagsEnabled; + this.featureFlagsContext = builder.featureFlagsContext; + } + + public String getInstanceName() { + return instanceName; + } + + public boolean isOptOutTrackingDefault() { + return optOutTrackingDefault; + } + + public JSONObject getSuperProperties() { + // Defensive copy to prevent modification of the internal JSONObject + if (superProperties == null) { + return null; + } + try { + return new JSONObject(superProperties.toString()); + } catch (Exception e) { + // This should ideally not happen if superProperties was a valid JSONObject + MPLog.e(LOGTAG, "Invalid super properties", e); + return null; } + } + + public boolean areFeatureFlagsEnabled() { + return featureFlagsEnabled; + } - public String getInstanceName() { - return instanceName; + public JSONObject getFeatureFlagsContext() { + // Defensive copy + if (featureFlagsContext == null) { + return new JSONObject(); + } + try { + return new JSONObject(featureFlagsContext.toString()); + } catch (Exception e) { + // This should ideally not happen if featureFlagsContext was a valid JSONObject + MPLog.e(LOGTAG, "Invalid feature flags context", e); + return new JSONObject(); + } + } + + public static class Builder { + private String instanceName; + private boolean optOutTrackingDefault = false; + private JSONObject superProperties; + private boolean featureFlagsEnabled = false; + private JSONObject featureFlagsContext = new JSONObject(); + + public Builder() {} + + /** + * Sets the distinct instance name for the MixpanelAPI. This is useful if you want to manage + * multiple Mixpanel project instances. + * + * @param instanceName The unique name for the Mixpanel instance. + * @return This Builder instance for chaining. + */ + public Builder instanceName(String instanceName) { + this.instanceName = instanceName; + return this; } - public boolean isOptOutTrackingDefault() { - return optOutTrackingDefault; + /** + * Sets the default opt-out tracking state. If true, the SDK will not send any events or profile + * updates by default. This can be overridden at runtime. + * + * @param optOutTrackingDefault True to opt-out of tracking by default, false otherwise. + * @return This Builder instance for chaining. + */ + public Builder optOutTrackingDefault(boolean optOutTrackingDefault) { + this.optOutTrackingDefault = optOutTrackingDefault; + return this; } - public JSONObject getSuperProperties() { - // Defensive copy to prevent modification of the internal JSONObject - if (superProperties == null) { - return null; - } + /** + * Sets the super properties to be sent with every event. These properties are persistently + * stored. + * + * @param superProperties A JSONObject containing key-value pairs for super properties. The + * provided JSONObject will be defensively copied. + * @return This Builder instance for chaining. + */ + public Builder superProperties(JSONObject superProperties) { + if (superProperties == null) { + this.superProperties = null; + } else { try { - return new JSONObject(superProperties.toString()); + // Defensive copy + this.superProperties = new JSONObject(superProperties.toString()); } catch (Exception e) { - // This should ideally not happen if superProperties was a valid JSONObject - MPLog.e(LOGTAG, "Invalid super properties", e); - return null; + // Log error or handle as appropriate if JSON is invalid + this.superProperties = null; } + } + return this; } - public boolean areFeatureFlagsEnabled() { - return featureFlagsEnabled; + /** + * Enables or disables the Mixpanel feature flags functionality. + * + * @param featureFlagsEnabled True to enable feature flags, false to disable. + * @return This Builder instance for chaining. + */ + public Builder featureFlagsEnabled(boolean featureFlagsEnabled) { + this.featureFlagsEnabled = featureFlagsEnabled; + return this; } - public JSONObject getFeatureFlagsContext() { - // Defensive copy - if (featureFlagsContext == null) { - return new JSONObject(); - } + /** + * Sets the context to be used for evaluating feature flags. This can include properties like + * distinct_id or other custom properties. + * + * @param featureFlagsContext A JSONObject containing key-value pairs for the feature flags + * context. The provided JSONObject will be defensively copied. + * @return This Builder instance for chaining. + */ + public Builder featureFlagsContext(JSONObject featureFlagsContext) { + if (featureFlagsContext == null) { + this.featureFlagsContext = new JSONObject(); + } else { try { - return new JSONObject(featureFlagsContext.toString()); + // Defensive copy + this.featureFlagsContext = new JSONObject(featureFlagsContext.toString()); } catch (Exception e) { - // This should ideally not happen if featureFlagsContext was a valid JSONObject - MPLog.e(LOGTAG, "Invalid feature flags context", e); - return new JSONObject(); + // Log error or handle as appropriate if JSON is invalid + this.featureFlagsContext = null; } + } + return this; } - public static class Builder { - private String instanceName; - private boolean optOutTrackingDefault = false; - private JSONObject superProperties; - private boolean featureFlagsEnabled = false; - private JSONObject featureFlagsContext = new JSONObject(); - - public Builder() { - } - - /** - * Sets the distinct instance name for the MixpanelAPI. This is useful if you want to - * manage multiple Mixpanel project instances. - * - * @param instanceName The unique name for the Mixpanel instance. - * @return This Builder instance for chaining. - */ - public Builder instanceName(String instanceName) { - this.instanceName = instanceName; - return this; - } - - /** - * Sets the default opt-out tracking state. If true, the SDK will not send any - * events or profile updates by default. This can be overridden at runtime. - * - * @param optOutTrackingDefault True to opt-out of tracking by default, false otherwise. - * @return This Builder instance for chaining. - */ - public Builder optOutTrackingDefault(boolean optOutTrackingDefault) { - this.optOutTrackingDefault = optOutTrackingDefault; - return this; - } - - /** - * Sets the super properties to be sent with every event. - * These properties are persistently stored. - * - * @param superProperties A JSONObject containing key-value pairs for super properties. - * The provided JSONObject will be defensively copied. - * @return This Builder instance for chaining. - */ - public Builder superProperties(JSONObject superProperties) { - if (superProperties == null) { - this.superProperties = null; - } else { - try { - // Defensive copy - this.superProperties = new JSONObject(superProperties.toString()); - } catch (Exception e) { - // Log error or handle as appropriate if JSON is invalid - this.superProperties = null; - } - } - return this; - } - - /** - * Enables or disables the Mixpanel feature flags functionality. - * - * @param featureFlagsEnabled True to enable feature flags, false to disable. - * @return This Builder instance for chaining. - */ - public Builder featureFlagsEnabled(boolean featureFlagsEnabled) { - this.featureFlagsEnabled = featureFlagsEnabled; - return this; - } - - /** - * Sets the context to be used for evaluating feature flags. - * This can include properties like distinct_id or other custom properties. - * - * @param featureFlagsContext A JSONObject containing key-value pairs for the feature flags context. - * The provided JSONObject will be defensively copied. - * @return This Builder instance for chaining. - */ - public Builder featureFlagsContext(JSONObject featureFlagsContext) { - if (featureFlagsContext == null) { - this.featureFlagsContext = new JSONObject(); - } else { - try { - // Defensive copy - this.featureFlagsContext = new JSONObject(featureFlagsContext.toString()); - } catch (Exception e) { - // Log error or handle as appropriate if JSON is invalid - this.featureFlagsContext = null; - } - } - return this; - } - - /** - * Builds and returns a {@link MixpanelOptions} instance with the configured settings. - * - * @return A new {@link MixpanelOptions} instance. - */ - public MixpanelOptions build() { - return new MixpanelOptions(this); - } + /** + * Builds and returns a {@link MixpanelOptions} instance with the configured settings. + * + * @return A new {@link MixpanelOptions} instance. + */ + public MixpanelOptions build() { + return new MixpanelOptions(this); } + } } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/PersistentIdentity.java b/src/main/java/com/mixpanel/android/mpmetrics/PersistentIdentity.java index 1298f2335..952669c7b 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/PersistentIdentity.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/PersistentIdentity.java @@ -1,690 +1,700 @@ package com.mixpanel.android.mpmetrics; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Looper; +import com.mixpanel.android.util.MPLog; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; - import org.json.JSONException; import org.json.JSONObject; -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.SharedPreferences; -import android.os.Looper; - -import com.mixpanel.android.util.MPLog; - // In order to use writeEdits, we have to suppress the linter's check for commit()/apply() @SuppressLint("CommitPrefEdits") - /* package */ class PersistentIdentity { - // Should ONLY be called from an OnPrefsLoadedListener (since it should NEVER be called concurrently) - public static String getPeopleDistinctId(SharedPreferences storedPreferences) { - return storedPreferences.getString("people_distinct_id", null); - } - - public static void writeReferrerPrefs(Context context, String preferencesName, Map properties) { - synchronized (sReferrerPrefsLock) { - final SharedPreferences referralInfo = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE); - final SharedPreferences.Editor editor = referralInfo.edit(); - editor.clear(); - for (final Map.Entry entry : properties.entrySet()) { - editor.putString(entry.getKey(), entry.getValue()); - } - writeEdits(editor); - sReferrerPrefsDirty = true; - } - } - - public PersistentIdentity(Future referrerPreferences, Future storedPreferences, Future timeEventsPreferences, Future mixpanelPreferences) { - mLoadReferrerPreferences = referrerPreferences; - mLoadStoredPreferences = storedPreferences; - mTimeEventsPreferences = timeEventsPreferences; - mMixpanelPreferences = mixpanelPreferences; - mSuperPropertiesCache = null; - mReferrerPropertiesCache = null; - mIdentitiesLoaded = false; - mReferrerChangeListener = (sharedPreferences, key) -> { - synchronized (sReferrerPrefsLock) { - readReferrerProperties(); - sReferrerPrefsDirty = false; - } +/* package */ class PersistentIdentity { + // Should ONLY be called from an OnPrefsLoadedListener (since it should NEVER be called + // concurrently) + public static String getPeopleDistinctId(SharedPreferences storedPreferences) { + return storedPreferences.getString("people_distinct_id", null); + } + + public static void writeReferrerPrefs( + Context context, String preferencesName, Map properties) { + synchronized (sReferrerPrefsLock) { + final SharedPreferences referralInfo = + context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE); + final SharedPreferences.Editor editor = referralInfo.edit(); + editor.clear(); + for (final Map.Entry entry : properties.entrySet()) { + editor.putString(entry.getKey(), entry.getValue()); + } + writeEdits(editor); + sReferrerPrefsDirty = true; + } + } + + public PersistentIdentity( + Future referrerPreferences, + Future storedPreferences, + Future timeEventsPreferences, + Future mixpanelPreferences) { + mLoadReferrerPreferences = referrerPreferences; + mLoadStoredPreferences = storedPreferences; + mTimeEventsPreferences = timeEventsPreferences; + mMixpanelPreferences = mixpanelPreferences; + mSuperPropertiesCache = null; + mReferrerPropertiesCache = null; + mIdentitiesLoaded = false; + mReferrerChangeListener = + (sharedPreferences, key) -> { + synchronized (sReferrerPrefsLock) { + readReferrerProperties(); + sReferrerPrefsDirty = false; + } }; - // Preload time events in the background to avoid main thread disk reads - preloadTimeEventsAsync(); - } - - // Super properties - public void addSuperPropertiesToObject(JSONObject ob) { - synchronized (mSuperPropsLock) { - final JSONObject superProperties = this.getSuperPropertiesCache(); - final Iterator superIter = superProperties.keys(); - while (superIter.hasNext()) { - final String key = (String) superIter.next(); - - try { - ob.put(key, superProperties.get(key)); - } catch (JSONException e) { - MPLog.e(LOGTAG, "Object read from one JSON Object cannot be written to another", e); - } - } - } - } - - public void updateSuperProperties(SuperPropertyUpdate updates) { - synchronized (mSuperPropsLock) { - final JSONObject oldPropCache = getSuperPropertiesCache(); - final JSONObject copy = new JSONObject(); - - try { - final Iterator keys = oldPropCache.keys(); - while (keys.hasNext()) { - final String k = keys.next(); - final Object v = oldPropCache.get(k); - copy.put(k, v); - } - } catch (JSONException e) { - MPLog.e(LOGTAG, "Can't copy from one JSONObject to another", e); - return; - } - - final JSONObject replacementCache = updates.update(copy); - if (replacementCache == null) { - MPLog.w(LOGTAG, "An update to Mixpanel's super properties returned null, and will have no effect."); - return; - } - - mSuperPropertiesCache = replacementCache; - storeSuperProperties(); - } - } - - public void registerSuperProperties(JSONObject superProperties) { - synchronized (mSuperPropsLock) { - final JSONObject propCache = getSuperPropertiesCache(); - - for (final Iterator iter = superProperties.keys(); iter.hasNext(); ) { - final String key = (String) iter.next(); - try { - propCache.put(key, superProperties.get(key)); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception registering super property.", e); - } - } - - storeSuperProperties(); - } - } - - public void unregisterSuperProperty(String superPropertyName) { - synchronized (mSuperPropsLock) { - final JSONObject propCache = getSuperPropertiesCache(); - propCache.remove(superPropertyName); - - storeSuperProperties(); - } - } - - public void registerSuperPropertiesOnce(JSONObject superProperties) { - synchronized (mSuperPropsLock) { - final JSONObject propCache = getSuperPropertiesCache(); - - for (final Iterator iter = superProperties.keys(); iter.hasNext(); ) { - final String key = (String) iter.next(); - if (! propCache.has(key)) { - try { - propCache.put(key, superProperties.get(key)); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Exception registering super property.", e); - } - } - }// for - - storeSuperProperties(); - } - } - - public void clearSuperProperties() { - synchronized (mSuperPropsLock) { - mSuperPropertiesCache = new JSONObject(); - storeSuperProperties(); - } - } - - public Map getReferrerProperties() { - synchronized (sReferrerPrefsLock) { - if (sReferrerPrefsDirty || null == mReferrerPropertiesCache) { - readReferrerProperties(); - sReferrerPrefsDirty = false; - } - } - return mReferrerPropertiesCache; - } - - public void clearReferrerProperties() { - synchronized (sReferrerPrefsLock) { - try { - final SharedPreferences referrerPrefs = mLoadReferrerPreferences.get(); - final SharedPreferences.Editor prefsEdit = referrerPrefs.edit(); - prefsEdit.clear(); - writeEdits(prefsEdit); - } catch (final ExecutionException e) { - MPLog.e(LOGTAG, "Cannot load referrer properties from shared preferences.", e.getCause()); - } catch (final InterruptedException e) { - MPLog.e(LOGTAG, "Cannot load referrer properties from shared preferences.", e); - } - } - } - - public synchronized String getAnonymousId() { - if (! mIdentitiesLoaded) { - readIdentities(); - } - return mAnonymousId; - } - - public synchronized boolean getHadPersistedDistinctId() { - if (! mIdentitiesLoaded) { - readIdentities(); - } - return mHadPersistedDistinctId; - } - - public synchronized String getEventsDistinctId() { - if (! mIdentitiesLoaded) { - readIdentities(); - } - return mEventsDistinctId; - } + // Preload time events in the background to avoid main thread disk reads + preloadTimeEventsAsync(); + } - public synchronized String getEventsUserId() { - if (! mIdentitiesLoaded) { - readIdentities(); - } - if(mEventsUserIdPresent) { - return mEventsDistinctId; - } - return null; - } - - public synchronized void setAnonymousIdIfAbsent(String anonymousId) { - if (! mIdentitiesLoaded) { - readIdentities(); - } - if (mAnonymousId != null) { - return; - } - mAnonymousId = anonymousId; - mHadPersistedDistinctId = true; - writeIdentities(); - } - - public synchronized void setEventsDistinctId(String eventsDistinctId) { - if(!mIdentitiesLoaded) { - readIdentities(); - } - mEventsDistinctId = eventsDistinctId; - writeIdentities(); - } - - public synchronized void markEventsUserIdPresent() { - if(!mIdentitiesLoaded) { - readIdentities(); - } - mEventsUserIdPresent = true; - writeIdentities(); - } - - public synchronized String getPeopleDistinctId() { - if (! mIdentitiesLoaded) { - readIdentities(); - } - return mPeopleDistinctId; - } - - public synchronized void setPeopleDistinctId(String peopleDistinctId) { - if (! mIdentitiesLoaded) { - readIdentities(); - } - mPeopleDistinctId = peopleDistinctId; - writeIdentities(); - } - - public synchronized void clearPreferences() { - // Will clear distinct_ids, superProperties, - // and waiting People Analytics properties. Will have no effect - // on messages already queued to send with AnalyticsMessages. + // Super properties + public void addSuperPropertiesToObject(JSONObject ob) { + synchronized (mSuperPropsLock) { + final JSONObject superProperties = this.getSuperPropertiesCache(); + final Iterator superIter = superProperties.keys(); + while (superIter.hasNext()) { + final String key = (String) superIter.next(); try { - final SharedPreferences prefs = mLoadStoredPreferences.get(); - final SharedPreferences.Editor prefsEdit = prefs.edit(); - prefsEdit.clear(); - writeEdits(prefsEdit); - readSuperProperties(); - readIdentities(); - } catch (final ExecutionException | InterruptedException e) { - throw new RuntimeException(e.getCause()); - } - } - - public void clearTimedEvents() { + ob.put(key, superProperties.get(key)); + } catch (JSONException e) { + MPLog.e(LOGTAG, "Object read from one JSON Object cannot be written to another", e); + } + } + } + } + + public void updateSuperProperties(SuperPropertyUpdate updates) { + synchronized (mSuperPropsLock) { + final JSONObject oldPropCache = getSuperPropertiesCache(); + final JSONObject copy = new JSONObject(); + + try { + final Iterator keys = oldPropCache.keys(); + while (keys.hasNext()) { + final String k = keys.next(); + final Object v = oldPropCache.get(k); + copy.put(k, v); + } + } catch (JSONException e) { + MPLog.e(LOGTAG, "Can't copy from one JSONObject to another", e); + return; + } + + final JSONObject replacementCache = updates.update(copy); + if (replacementCache == null) { + MPLog.w( + LOGTAG, + "An update to Mixpanel's super properties returned null, and will have no effect."); + return; + } + + mSuperPropertiesCache = replacementCache; + storeSuperProperties(); + } + } + + public void registerSuperProperties(JSONObject superProperties) { + synchronized (mSuperPropsLock) { + final JSONObject propCache = getSuperPropertiesCache(); + + for (final Iterator iter = superProperties.keys(); iter.hasNext(); ) { + final String key = (String) iter.next(); try { - final SharedPreferences prefs = mTimeEventsPreferences.get(); - final SharedPreferences.Editor editor = prefs.edit(); - editor.clear(); - writeEdits(editor); - - // Clear cache if initialized - synchronized (mTimeEventsCacheLock) { - if (mTimeEventsCache != null) { - mTimeEventsCache.clear(); - } - } - } catch (InterruptedException e) { - MPLog.e(LOGTAG, "Failed to clear time events", e); - } catch (ExecutionException e) { - MPLog.e(LOGTAG, "Failed to clear time events", e.getCause()); - } - } - - public Map getTimeEvents() { - // First check if cache is already loaded - synchronized (mTimeEventsCacheLock) { - if (mTimeEventsCache != null) { - return new HashMap<>(mTimeEventsCache); - } - - // Detect if we're on the main thread - if (Looper.getMainLooper().getThread() == Thread.currentThread()) { - // Running on main thread - return empty map and load asynchronously - final Map emptyMap = new HashMap<>(); - - // Only start a new thread if we're not already loading - if (!mTimeEventsCacheLoading) { - mTimeEventsCacheLoading = true; - new Thread(this::loadTimeEventsCache).start(); - } - - return emptyMap; - } else { - // Not on main thread - safe to load synchronously - return loadTimeEventsCache(); - } - } - } - - // Helper method to load time events - private Map loadTimeEventsCache() { - synchronized (mTimeEventsCacheLock) { - if (mTimeEventsCache != null) { - return new HashMap<>(mTimeEventsCache); - } - - mTimeEventsCache = new HashMap<>(); - - try { - final SharedPreferences prefs = mTimeEventsPreferences.get(); - Map allEntries = prefs.getAll(); - for (Map.Entry entry : allEntries.entrySet()) { - mTimeEventsCache.put(entry.getKey(), Long.valueOf(entry.getValue().toString())); - } - } catch (InterruptedException e) { - MPLog.e(LOGTAG, "Failed to load time events", e); - } catch (ExecutionException e) { - MPLog.e(LOGTAG, "Failed to load time events", e.getCause()); - } finally { - // Reset the loading flag when done - mTimeEventsCacheLoading = false; - } - - return new HashMap<>(mTimeEventsCache); + propCache.put(key, superProperties.get(key)); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception registering super property.", e); } - } + } - // Method to explicitly preload the cache - public void preloadTimeEventsAsync() { - synchronized (mTimeEventsCacheLock) { - if (mTimeEventsCache == null) { - if (!mTimeEventsCacheLoading) { - mTimeEventsCacheLoading = true; - new Thread(this::loadTimeEventsCache).start(); - } - } - } + storeSuperProperties(); } + } - // access is synchronized outside (mEventTimings) - public void removeTimedEvent(String timeEventName) { - try { - final SharedPreferences prefs = mTimeEventsPreferences.get(); - final SharedPreferences.Editor editor = prefs.edit(); - editor.remove(timeEventName); - writeEdits(editor); - - // Update cache if initialized - synchronized (mTimeEventsCacheLock) { - if (mTimeEventsCache != null) { - mTimeEventsCache.remove(timeEventName); - } - } - } catch (InterruptedException e) { - MPLog.e(LOGTAG, "Failed to remove time event", e); - } catch (ExecutionException e) { - MPLog.e(LOGTAG, "Failed to remove time event", e.getCause()); - } - } + public void unregisterSuperProperty(String superPropertyName) { + synchronized (mSuperPropsLock) { + final JSONObject propCache = getSuperPropertiesCache(); + propCache.remove(superPropertyName); - // access is synchronized outside (mEventTimings) - public void addTimeEvent(String timeEventName, Long timeEventTimestamp) { - try { - final SharedPreferences prefs = mTimeEventsPreferences.get(); - final SharedPreferences.Editor editor = prefs.edit(); - editor.putLong(timeEventName, timeEventTimestamp); - writeEdits(editor); - - // Update cache if initialized - synchronized (mTimeEventsCacheLock) { - if (mTimeEventsCache != null) { - mTimeEventsCache.put(timeEventName, timeEventTimestamp); - } - } - } catch (InterruptedException e) { - MPLog.e(LOGTAG, "Failed to add time event", e); - } catch (ExecutionException e) { - MPLog.e(LOGTAG, "Failed to add time event", e.getCause()); - } + storeSuperProperties(); } + } - public synchronized boolean isNewVersion(String versionCode) { - if (versionCode == null) { - return false; - } - - Integer version = Integer.valueOf(versionCode); - try { - if (sPreviousVersionCode == null) { - SharedPreferences mixpanelPreferences = mMixpanelPreferences.get(); - sPreviousVersionCode = mixpanelPreferences.getInt("latest_version_code", -1); - if (sPreviousVersionCode == -1) { - sPreviousVersionCode = version; - SharedPreferences.Editor mixpanelPreferencesEditor = mMixpanelPreferences.get().edit(); - mixpanelPreferencesEditor.putInt("latest_version_code", version); - writeEdits(mixpanelPreferencesEditor); - } - } - - if (sPreviousVersionCode < version) { - SharedPreferences.Editor mixpanelPreferencesEditor = mMixpanelPreferences.get().edit(); - mixpanelPreferencesEditor.putInt("latest_version_code", version); - writeEdits(mixpanelPreferencesEditor); - return true; - } - } catch (ExecutionException e) { - MPLog.e(LOGTAG, "Couldn't write internal Mixpanel shared preferences.", e.getCause()); - } catch (InterruptedException e) { - MPLog.e(LOGTAG, "Couldn't write internal Mixpanel from shared preferences.", e); - } + public void registerSuperPropertiesOnce(JSONObject superProperties) { + synchronized (mSuperPropsLock) { + final JSONObject propCache = getSuperPropertiesCache(); - return false; - } - - public synchronized boolean isFirstLaunch(boolean dbExists, String token) { - if (sIsFirstAppLaunch == null) { - try { - SharedPreferences mixpanelPreferences = mMixpanelPreferences.get(); - boolean hasLaunched = mixpanelPreferences.getBoolean("has_launched_" + token, false); - if (hasLaunched) { - sIsFirstAppLaunch = false; - } else { - sIsFirstAppLaunch = !dbExists; - if (!sIsFirstAppLaunch) { - setHasLaunched(token); - } - } - } catch (ExecutionException | InterruptedException e) { - sIsFirstAppLaunch = false; - } + for (final Iterator iter = superProperties.keys(); iter.hasNext(); ) { + final String key = (String) iter.next(); + if (!propCache.has(key)) { + try { + propCache.put(key, superProperties.get(key)); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Exception registering super property.", e); + } } + } // for - return sIsFirstAppLaunch; + storeSuperProperties(); } + } - public synchronized void setHasLaunched(String token) { - try { - SharedPreferences.Editor mixpanelPreferencesEditor = mMixpanelPreferences.get().edit(); - mixpanelPreferencesEditor.putBoolean("has_launched_" + token, true); - writeEdits(mixpanelPreferencesEditor); - } catch (ExecutionException e) { - MPLog.e(LOGTAG, "Couldn't write internal Mixpanel shared preferences.", e.getCause()); - } catch (InterruptedException e) { - MPLog.e(LOGTAG, "Couldn't write internal Mixpanel shared preferences.", e); - } + public void clearSuperProperties() { + synchronized (mSuperPropsLock) { + mSuperPropertiesCache = new JSONObject(); + storeSuperProperties(); } + } - public synchronized void setOptOutTracking(boolean optOutTracking, String token) { - mIsUserOptOut = optOutTracking; - writeOptOutFlag(token); + public Map getReferrerProperties() { + synchronized (sReferrerPrefsLock) { + if (sReferrerPrefsDirty || null == mReferrerPropertiesCache) { + readReferrerProperties(); + sReferrerPrefsDirty = false; + } } + return mReferrerPropertiesCache; + } - public synchronized boolean getOptOutTracking(String token) { - if (mIsUserOptOut == null) { - readOptOutFlag(token); - if (mIsUserOptOut == null) { - mIsUserOptOut = false; - } - } - - return mIsUserOptOut; + public void clearReferrerProperties() { + synchronized (sReferrerPrefsLock) { + try { + final SharedPreferences referrerPrefs = mLoadReferrerPreferences.get(); + final SharedPreferences.Editor prefsEdit = referrerPrefs.edit(); + prefsEdit.clear(); + writeEdits(prefsEdit); + } catch (final ExecutionException e) { + MPLog.e(LOGTAG, "Cannot load referrer properties from shared preferences.", e.getCause()); + } catch (final InterruptedException e) { + MPLog.e(LOGTAG, "Cannot load referrer properties from shared preferences.", e); + } } + } - ////////////////////////////////////////////////// - - // Must be called from a synchronized setting - private JSONObject getSuperPropertiesCache() { - if (mSuperPropertiesCache == null) { - readSuperProperties(); - } - return mSuperPropertiesCache; + public synchronized String getAnonymousId() { + if (!mIdentitiesLoaded) { + readIdentities(); } + return mAnonymousId; + } - // All access should be synchronized on this - private void readSuperProperties() { - try { - final SharedPreferences prefs = mLoadStoredPreferences.get(); - final String props = prefs.getString("super_properties", "{}"); - MPLog.v(LOGTAG, "Loading Super Properties " + props); - mSuperPropertiesCache = new JSONObject(props); - } catch (final ExecutionException e) { - MPLog.e(LOGTAG, "Cannot load superProperties from SharedPreferences.", e.getCause()); - } catch (final InterruptedException e) { - MPLog.e(LOGTAG, "Cannot load superProperties from SharedPreferences.", e); - } catch (final JSONException e) { - MPLog.e(LOGTAG, "Cannot parse stored superProperties"); - storeSuperProperties(); - } finally { - if (mSuperPropertiesCache == null) { - mSuperPropertiesCache = new JSONObject(); - } - } + public synchronized boolean getHadPersistedDistinctId() { + if (!mIdentitiesLoaded) { + readIdentities(); } + return mHadPersistedDistinctId; + } - // All access should be synchronized on this - private void readReferrerProperties() { - mReferrerPropertiesCache = new HashMap<>(); - - try { - final SharedPreferences referrerPrefs = mLoadReferrerPreferences.get(); - referrerPrefs.unregisterOnSharedPreferenceChangeListener(mReferrerChangeListener); - referrerPrefs.registerOnSharedPreferenceChangeListener(mReferrerChangeListener); - - final Map prefsMap = referrerPrefs.getAll(); - for (final Map.Entry entry : prefsMap.entrySet()) { - final String prefsName = entry.getKey(); - final Object prefsVal = entry.getValue(); - mReferrerPropertiesCache.put(prefsName, prefsVal.toString()); - } - } catch (final ExecutionException e) { - MPLog.e(LOGTAG, "Cannot load referrer properties from shared preferences.", e.getCause()); - } catch (final InterruptedException e) { - MPLog.e(LOGTAG, "Cannot load referrer properties from shared preferences.", e); - } - } - - // All access should be synchronized on this - private void storeSuperProperties() { - if (mSuperPropertiesCache == null) { - MPLog.e(LOGTAG, "storeSuperProperties should not be called with uninitialized superPropertiesCache."); - return; - } - - final String props = mSuperPropertiesCache.toString(); - MPLog.v(LOGTAG, "Storing Super Properties " + props); - - try { - final SharedPreferences prefs = mLoadStoredPreferences.get(); - final SharedPreferences.Editor editor = prefs.edit(); - editor.putString("super_properties", props); - writeEdits(editor); - } catch (final ExecutionException e) { - MPLog.e(LOGTAG, "Cannot store superProperties in shared preferences.", e.getCause()); - } catch (final InterruptedException e) { - MPLog.e(LOGTAG, "Cannot store superProperties in shared preferences.", e); - } - } - - // All access should be synchronized on this - private void readIdentities() { - SharedPreferences prefs = null; - try { - prefs = mLoadStoredPreferences.get(); - } catch (final ExecutionException e) { - MPLog.e(LOGTAG, "Cannot read distinct ids from sharedPreferences.", e.getCause()); - } catch (final InterruptedException e) { - MPLog.e(LOGTAG, "Cannot read distinct ids from sharedPreferences.", e); - } - - if (prefs == null) { - return; - } - - mEventsDistinctId = prefs.getString("events_distinct_id", null); - mEventsUserIdPresent = prefs.getBoolean("events_user_id_present", false); - mPeopleDistinctId = prefs.getString("people_distinct_id", null); - mAnonymousId = prefs.getString("anonymous_id", null); - mHadPersistedDistinctId = prefs.getBoolean("had_persisted_distinct_id", false); - - if (mEventsDistinctId == null) { - mAnonymousId = UUID.randomUUID().toString(); - mEventsDistinctId = "$device:" + mAnonymousId; - mEventsUserIdPresent = false; - writeIdentities(); - } - mIdentitiesLoaded = true; - } - - private void readOptOutFlag(String token) { - SharedPreferences prefs = null; - try { - prefs = mMixpanelPreferences.get(); - } catch (final ExecutionException e) { - MPLog.e(LOGTAG, "Cannot read opt out flag from sharedPreferences.", e.getCause()); - } catch (final InterruptedException e) { - MPLog.e(LOGTAG, "Cannot read opt out flag from sharedPreferences.", e); - } - - if (prefs == null) { - return; - } - mIsUserOptOut = prefs.getBoolean("opt_out_" + token, false); - } - - private void writeOptOutFlag(String token) { - try { - final SharedPreferences prefs = mMixpanelPreferences.get(); - final SharedPreferences.Editor prefsEditor = prefs.edit(); - prefsEditor.putBoolean("opt_out_" + token, mIsUserOptOut); - writeEdits(prefsEditor); - } catch (final ExecutionException e) { - MPLog.e(LOGTAG, "Can't write opt-out shared preferences.", e.getCause()); - } catch (final InterruptedException e) { - MPLog.e(LOGTAG, "Can't write opt-out shared preferences.", e); - } - } - - protected void removeOptOutFlag(String token) { - try { - final SharedPreferences prefs = mMixpanelPreferences.get(); - final SharedPreferences.Editor prefsEditor = prefs.edit(); - prefsEditor.remove("opt_out_" + token); - writeEdits(prefsEditor); - } catch (final ExecutionException e) { - MPLog.e(LOGTAG, "Can't remove opt-out shared preferences.", e.getCause()); - } catch (final InterruptedException e) { - MPLog.e(LOGTAG, "Can't remove opt-out shared preferences.", e); - } - } - - protected boolean hasOptOutFlag(String token) { - try { - final SharedPreferences prefs = mMixpanelPreferences.get(); - return prefs.contains("opt_out_" + token); - } catch (final ExecutionException e) { - MPLog.e(LOGTAG, "Can't read opt-out shared preferences.", e.getCause()); - } catch (final InterruptedException e) { - MPLog.e(LOGTAG, "Can't read opt-out shared preferences.", e); - } - return false; - } - // All access should be synchronized on this - private void writeIdentities() { - try { - final SharedPreferences prefs = mLoadStoredPreferences.get(); - final SharedPreferences.Editor prefsEditor = prefs.edit(); - - prefsEditor.putString("events_distinct_id", mEventsDistinctId); - prefsEditor.putBoolean("events_user_id_present", mEventsUserIdPresent); - prefsEditor.putString("people_distinct_id", mPeopleDistinctId); - prefsEditor.putString("anonymous_id", mAnonymousId); - prefsEditor.putBoolean("had_persisted_distinct_id", mHadPersistedDistinctId); - writeEdits(prefsEditor); - } catch (final ExecutionException e) { - MPLog.e(LOGTAG, "Can't write distinct ids to shared preferences.", e.getCause()); - } catch (final InterruptedException e) { - MPLog.e(LOGTAG, "Can't write distinct ids to shared preferences.", e); - } + public synchronized String getEventsDistinctId() { + if (!mIdentitiesLoaded) { + readIdentities(); } + return mEventsDistinctId; + } + + public synchronized String getEventsUserId() { + if (!mIdentitiesLoaded) { + readIdentities(); + } + if (mEventsUserIdPresent) { + return mEventsDistinctId; + } + return null; + } - private static void writeEdits(final SharedPreferences.Editor editor) { - editor.apply(); - } - - private final Future mLoadStoredPreferences; - private final Future mLoadReferrerPreferences; - private final Future mTimeEventsPreferences; - private final Future mMixpanelPreferences; - private final SharedPreferences.OnSharedPreferenceChangeListener mReferrerChangeListener; - private JSONObject mSuperPropertiesCache; - private final Object mSuperPropsLock = new Object(); - private Map mReferrerPropertiesCache; - private boolean mIdentitiesLoaded; - private String mEventsDistinctId; - private boolean mEventsUserIdPresent; - private String mPeopleDistinctId; - private String mAnonymousId; - private boolean mHadPersistedDistinctId; - private Boolean mIsUserOptOut; - private static Integer sPreviousVersionCode; - private static Boolean sIsFirstAppLaunch; - - // Time events caching - private Map mTimeEventsCache = null; - private final Object mTimeEventsCacheLock = new Object(); - private boolean mTimeEventsCacheLoading = false; - - private static boolean sReferrerPrefsDirty = true; - private static final Object sReferrerPrefsLock = new Object(); - private static final String LOGTAG = "MixpanelAPI.PIdentity"; + public synchronized void setAnonymousIdIfAbsent(String anonymousId) { + if (!mIdentitiesLoaded) { + readIdentities(); + } + if (mAnonymousId != null) { + return; + } + mAnonymousId = anonymousId; + mHadPersistedDistinctId = true; + writeIdentities(); + } + + public synchronized void setEventsDistinctId(String eventsDistinctId) { + if (!mIdentitiesLoaded) { + readIdentities(); + } + mEventsDistinctId = eventsDistinctId; + writeIdentities(); + } + + public synchronized void markEventsUserIdPresent() { + if (!mIdentitiesLoaded) { + readIdentities(); + } + mEventsUserIdPresent = true; + writeIdentities(); + } + + public synchronized String getPeopleDistinctId() { + if (!mIdentitiesLoaded) { + readIdentities(); + } + return mPeopleDistinctId; + } + + public synchronized void setPeopleDistinctId(String peopleDistinctId) { + if (!mIdentitiesLoaded) { + readIdentities(); + } + mPeopleDistinctId = peopleDistinctId; + writeIdentities(); + } + + public synchronized void clearPreferences() { + // Will clear distinct_ids, superProperties, + // and waiting People Analytics properties. Will have no effect + // on messages already queued to send with AnalyticsMessages. + + try { + final SharedPreferences prefs = mLoadStoredPreferences.get(); + final SharedPreferences.Editor prefsEdit = prefs.edit(); + prefsEdit.clear(); + writeEdits(prefsEdit); + readSuperProperties(); + readIdentities(); + } catch (final ExecutionException | InterruptedException e) { + throw new RuntimeException(e.getCause()); + } + } + + public void clearTimedEvents() { + try { + final SharedPreferences prefs = mTimeEventsPreferences.get(); + final SharedPreferences.Editor editor = prefs.edit(); + editor.clear(); + writeEdits(editor); + + // Clear cache if initialized + synchronized (mTimeEventsCacheLock) { + if (mTimeEventsCache != null) { + mTimeEventsCache.clear(); + } + } + } catch (InterruptedException e) { + MPLog.e(LOGTAG, "Failed to clear time events", e); + } catch (ExecutionException e) { + MPLog.e(LOGTAG, "Failed to clear time events", e.getCause()); + } + } + + public Map getTimeEvents() { + // First check if cache is already loaded + synchronized (mTimeEventsCacheLock) { + if (mTimeEventsCache != null) { + return new HashMap<>(mTimeEventsCache); + } + + // Detect if we're on the main thread + if (Looper.getMainLooper().getThread() == Thread.currentThread()) { + // Running on main thread - return empty map and load asynchronously + final Map emptyMap = new HashMap<>(); + + // Only start a new thread if we're not already loading + if (!mTimeEventsCacheLoading) { + mTimeEventsCacheLoading = true; + new Thread(this::loadTimeEventsCache).start(); + } + + return emptyMap; + } else { + // Not on main thread - safe to load synchronously + return loadTimeEventsCache(); + } + } + } + + // Helper method to load time events + private Map loadTimeEventsCache() { + synchronized (mTimeEventsCacheLock) { + if (mTimeEventsCache != null) { + return new HashMap<>(mTimeEventsCache); + } + + mTimeEventsCache = new HashMap<>(); + + try { + final SharedPreferences prefs = mTimeEventsPreferences.get(); + Map allEntries = prefs.getAll(); + for (Map.Entry entry : allEntries.entrySet()) { + mTimeEventsCache.put(entry.getKey(), Long.valueOf(entry.getValue().toString())); + } + } catch (InterruptedException e) { + MPLog.e(LOGTAG, "Failed to load time events", e); + } catch (ExecutionException e) { + MPLog.e(LOGTAG, "Failed to load time events", e.getCause()); + } finally { + // Reset the loading flag when done + mTimeEventsCacheLoading = false; + } + + return new HashMap<>(mTimeEventsCache); + } + } + + // Method to explicitly preload the cache + public void preloadTimeEventsAsync() { + synchronized (mTimeEventsCacheLock) { + if (mTimeEventsCache == null) { + if (!mTimeEventsCacheLoading) { + mTimeEventsCacheLoading = true; + new Thread(this::loadTimeEventsCache).start(); + } + } + } + } + + // access is synchronized outside (mEventTimings) + public void removeTimedEvent(String timeEventName) { + try { + final SharedPreferences prefs = mTimeEventsPreferences.get(); + final SharedPreferences.Editor editor = prefs.edit(); + editor.remove(timeEventName); + writeEdits(editor); + + // Update cache if initialized + synchronized (mTimeEventsCacheLock) { + if (mTimeEventsCache != null) { + mTimeEventsCache.remove(timeEventName); + } + } + } catch (InterruptedException e) { + MPLog.e(LOGTAG, "Failed to remove time event", e); + } catch (ExecutionException e) { + MPLog.e(LOGTAG, "Failed to remove time event", e.getCause()); + } + } + + // access is synchronized outside (mEventTimings) + public void addTimeEvent(String timeEventName, Long timeEventTimestamp) { + try { + final SharedPreferences prefs = mTimeEventsPreferences.get(); + final SharedPreferences.Editor editor = prefs.edit(); + editor.putLong(timeEventName, timeEventTimestamp); + writeEdits(editor); + + // Update cache if initialized + synchronized (mTimeEventsCacheLock) { + if (mTimeEventsCache != null) { + mTimeEventsCache.put(timeEventName, timeEventTimestamp); + } + } + } catch (InterruptedException e) { + MPLog.e(LOGTAG, "Failed to add time event", e); + } catch (ExecutionException e) { + MPLog.e(LOGTAG, "Failed to add time event", e.getCause()); + } + } + + public synchronized boolean isNewVersion(String versionCode) { + if (versionCode == null) { + return false; + } + + Integer version = Integer.valueOf(versionCode); + try { + if (sPreviousVersionCode == null) { + SharedPreferences mixpanelPreferences = mMixpanelPreferences.get(); + sPreviousVersionCode = mixpanelPreferences.getInt("latest_version_code", -1); + if (sPreviousVersionCode == -1) { + sPreviousVersionCode = version; + SharedPreferences.Editor mixpanelPreferencesEditor = mMixpanelPreferences.get().edit(); + mixpanelPreferencesEditor.putInt("latest_version_code", version); + writeEdits(mixpanelPreferencesEditor); + } + } + + if (sPreviousVersionCode < version) { + SharedPreferences.Editor mixpanelPreferencesEditor = mMixpanelPreferences.get().edit(); + mixpanelPreferencesEditor.putInt("latest_version_code", version); + writeEdits(mixpanelPreferencesEditor); + return true; + } + } catch (ExecutionException e) { + MPLog.e(LOGTAG, "Couldn't write internal Mixpanel shared preferences.", e.getCause()); + } catch (InterruptedException e) { + MPLog.e(LOGTAG, "Couldn't write internal Mixpanel from shared preferences.", e); + } + + return false; + } + + public synchronized boolean isFirstLaunch(boolean dbExists, String token) { + if (sIsFirstAppLaunch == null) { + try { + SharedPreferences mixpanelPreferences = mMixpanelPreferences.get(); + boolean hasLaunched = mixpanelPreferences.getBoolean("has_launched_" + token, false); + if (hasLaunched) { + sIsFirstAppLaunch = false; + } else { + sIsFirstAppLaunch = !dbExists; + if (!sIsFirstAppLaunch) { + setHasLaunched(token); + } + } + } catch (ExecutionException | InterruptedException e) { + sIsFirstAppLaunch = false; + } + } + + return sIsFirstAppLaunch; + } + + public synchronized void setHasLaunched(String token) { + try { + SharedPreferences.Editor mixpanelPreferencesEditor = mMixpanelPreferences.get().edit(); + mixpanelPreferencesEditor.putBoolean("has_launched_" + token, true); + writeEdits(mixpanelPreferencesEditor); + } catch (ExecutionException e) { + MPLog.e(LOGTAG, "Couldn't write internal Mixpanel shared preferences.", e.getCause()); + } catch (InterruptedException e) { + MPLog.e(LOGTAG, "Couldn't write internal Mixpanel shared preferences.", e); + } + } + + public synchronized void setOptOutTracking(boolean optOutTracking, String token) { + mIsUserOptOut = optOutTracking; + writeOptOutFlag(token); + } + + public synchronized boolean getOptOutTracking(String token) { + if (mIsUserOptOut == null) { + readOptOutFlag(token); + if (mIsUserOptOut == null) { + mIsUserOptOut = false; + } + } + + return mIsUserOptOut; + } + + ////////////////////////////////////////////////// + + // Must be called from a synchronized setting + private JSONObject getSuperPropertiesCache() { + if (mSuperPropertiesCache == null) { + readSuperProperties(); + } + return mSuperPropertiesCache; + } + + // All access should be synchronized on this + private void readSuperProperties() { + try { + final SharedPreferences prefs = mLoadStoredPreferences.get(); + final String props = prefs.getString("super_properties", "{}"); + MPLog.v(LOGTAG, "Loading Super Properties " + props); + mSuperPropertiesCache = new JSONObject(props); + } catch (final ExecutionException e) { + MPLog.e(LOGTAG, "Cannot load superProperties from SharedPreferences.", e.getCause()); + } catch (final InterruptedException e) { + MPLog.e(LOGTAG, "Cannot load superProperties from SharedPreferences.", e); + } catch (final JSONException e) { + MPLog.e(LOGTAG, "Cannot parse stored superProperties"); + storeSuperProperties(); + } finally { + if (mSuperPropertiesCache == null) { + mSuperPropertiesCache = new JSONObject(); + } + } + } + + // All access should be synchronized on this + private void readReferrerProperties() { + mReferrerPropertiesCache = new HashMap<>(); + + try { + final SharedPreferences referrerPrefs = mLoadReferrerPreferences.get(); + referrerPrefs.unregisterOnSharedPreferenceChangeListener(mReferrerChangeListener); + referrerPrefs.registerOnSharedPreferenceChangeListener(mReferrerChangeListener); + + final Map prefsMap = referrerPrefs.getAll(); + for (final Map.Entry entry : prefsMap.entrySet()) { + final String prefsName = entry.getKey(); + final Object prefsVal = entry.getValue(); + mReferrerPropertiesCache.put(prefsName, prefsVal.toString()); + } + } catch (final ExecutionException e) { + MPLog.e(LOGTAG, "Cannot load referrer properties from shared preferences.", e.getCause()); + } catch (final InterruptedException e) { + MPLog.e(LOGTAG, "Cannot load referrer properties from shared preferences.", e); + } + } + + // All access should be synchronized on this + private void storeSuperProperties() { + if (mSuperPropertiesCache == null) { + MPLog.e( + LOGTAG, + "storeSuperProperties should not be called with uninitialized superPropertiesCache."); + return; + } + + final String props = mSuperPropertiesCache.toString(); + MPLog.v(LOGTAG, "Storing Super Properties " + props); + + try { + final SharedPreferences prefs = mLoadStoredPreferences.get(); + final SharedPreferences.Editor editor = prefs.edit(); + editor.putString("super_properties", props); + writeEdits(editor); + } catch (final ExecutionException e) { + MPLog.e(LOGTAG, "Cannot store superProperties in shared preferences.", e.getCause()); + } catch (final InterruptedException e) { + MPLog.e(LOGTAG, "Cannot store superProperties in shared preferences.", e); + } + } + + // All access should be synchronized on this + private void readIdentities() { + SharedPreferences prefs = null; + try { + prefs = mLoadStoredPreferences.get(); + } catch (final ExecutionException e) { + MPLog.e(LOGTAG, "Cannot read distinct ids from sharedPreferences.", e.getCause()); + } catch (final InterruptedException e) { + MPLog.e(LOGTAG, "Cannot read distinct ids from sharedPreferences.", e); + } + + if (prefs == null) { + return; + } + + mEventsDistinctId = prefs.getString("events_distinct_id", null); + mEventsUserIdPresent = prefs.getBoolean("events_user_id_present", false); + mPeopleDistinctId = prefs.getString("people_distinct_id", null); + mAnonymousId = prefs.getString("anonymous_id", null); + mHadPersistedDistinctId = prefs.getBoolean("had_persisted_distinct_id", false); + + if (mEventsDistinctId == null) { + mAnonymousId = UUID.randomUUID().toString(); + mEventsDistinctId = "$device:" + mAnonymousId; + mEventsUserIdPresent = false; + writeIdentities(); + } + mIdentitiesLoaded = true; + } + + private void readOptOutFlag(String token) { + SharedPreferences prefs = null; + try { + prefs = mMixpanelPreferences.get(); + } catch (final ExecutionException e) { + MPLog.e(LOGTAG, "Cannot read opt out flag from sharedPreferences.", e.getCause()); + } catch (final InterruptedException e) { + MPLog.e(LOGTAG, "Cannot read opt out flag from sharedPreferences.", e); + } + + if (prefs == null) { + return; + } + mIsUserOptOut = prefs.getBoolean("opt_out_" + token, false); + } + + private void writeOptOutFlag(String token) { + try { + final SharedPreferences prefs = mMixpanelPreferences.get(); + final SharedPreferences.Editor prefsEditor = prefs.edit(); + prefsEditor.putBoolean("opt_out_" + token, mIsUserOptOut); + writeEdits(prefsEditor); + } catch (final ExecutionException e) { + MPLog.e(LOGTAG, "Can't write opt-out shared preferences.", e.getCause()); + } catch (final InterruptedException e) { + MPLog.e(LOGTAG, "Can't write opt-out shared preferences.", e); + } + } + + protected void removeOptOutFlag(String token) { + try { + final SharedPreferences prefs = mMixpanelPreferences.get(); + final SharedPreferences.Editor prefsEditor = prefs.edit(); + prefsEditor.remove("opt_out_" + token); + writeEdits(prefsEditor); + } catch (final ExecutionException e) { + MPLog.e(LOGTAG, "Can't remove opt-out shared preferences.", e.getCause()); + } catch (final InterruptedException e) { + MPLog.e(LOGTAG, "Can't remove opt-out shared preferences.", e); + } + } + + protected boolean hasOptOutFlag(String token) { + try { + final SharedPreferences prefs = mMixpanelPreferences.get(); + return prefs.contains("opt_out_" + token); + } catch (final ExecutionException e) { + MPLog.e(LOGTAG, "Can't read opt-out shared preferences.", e.getCause()); + } catch (final InterruptedException e) { + MPLog.e(LOGTAG, "Can't read opt-out shared preferences.", e); + } + return false; + } + + // All access should be synchronized on this + private void writeIdentities() { + try { + final SharedPreferences prefs = mLoadStoredPreferences.get(); + final SharedPreferences.Editor prefsEditor = prefs.edit(); + + prefsEditor.putString("events_distinct_id", mEventsDistinctId); + prefsEditor.putBoolean("events_user_id_present", mEventsUserIdPresent); + prefsEditor.putString("people_distinct_id", mPeopleDistinctId); + prefsEditor.putString("anonymous_id", mAnonymousId); + prefsEditor.putBoolean("had_persisted_distinct_id", mHadPersistedDistinctId); + writeEdits(prefsEditor); + } catch (final ExecutionException e) { + MPLog.e(LOGTAG, "Can't write distinct ids to shared preferences.", e.getCause()); + } catch (final InterruptedException e) { + MPLog.e(LOGTAG, "Can't write distinct ids to shared preferences.", e); + } + } + + private static void writeEdits(final SharedPreferences.Editor editor) { + editor.apply(); + } + + private final Future mLoadStoredPreferences; + private final Future mLoadReferrerPreferences; + private final Future mTimeEventsPreferences; + private final Future mMixpanelPreferences; + private final SharedPreferences.OnSharedPreferenceChangeListener mReferrerChangeListener; + private JSONObject mSuperPropertiesCache; + private final Object mSuperPropsLock = new Object(); + private Map mReferrerPropertiesCache; + private boolean mIdentitiesLoaded; + private String mEventsDistinctId; + private boolean mEventsUserIdPresent; + private String mPeopleDistinctId; + private String mAnonymousId; + private boolean mHadPersistedDistinctId; + private Boolean mIsUserOptOut; + private static Integer sPreviousVersionCode; + private static Boolean sIsFirstAppLaunch; + + // Time events caching + private Map mTimeEventsCache = null; + private final Object mTimeEventsCacheLock = new Object(); + private boolean mTimeEventsCacheLoading = false; + + private static boolean sReferrerPrefsDirty = true; + private static final Object sReferrerPrefsLock = new Object(); + private static final String LOGTAG = "MixpanelAPI.PIdentity"; } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/ResourceIds.java b/src/main/java/com/mixpanel/android/mpmetrics/ResourceIds.java index 2dc9df4e0..e94a33bd9 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/ResourceIds.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/ResourceIds.java @@ -1,11 +1,13 @@ package com.mixpanel.android.mpmetrics; /** - * This interface is for internal use in the Mixpanel library, and should not be included in - * client code. + * This interface is for internal use in the Mixpanel library, and should not be included in client + * code. */ public interface ResourceIds { - boolean knownIdName(String name); - int idFromName(String name); - String nameForId(int id); + boolean knownIdName(String name); + + int idFromName(String name); + + String nameForId(int id); } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/ResourceReader.java b/src/main/java/com/mixpanel/android/mpmetrics/ResourceReader.java index dd89c8079..bb6fcfb00 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/ResourceReader.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/ResourceReader.java @@ -2,153 +2,162 @@ import android.content.Context; import android.util.SparseArray; - import com.mixpanel.android.util.MPLog; - import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.util.HashMap; import java.util.Map; /** - * This class is for internal use in the Mixpanel library, and should not be imported into - * client code. + * This class is for internal use in the Mixpanel library, and should not be imported into client + * code. */ public abstract class ResourceReader implements ResourceIds { - public static class Ids extends ResourceReader { - public Ids(String resourcePackageName, Context context) { - super(context); - mResourcePackageName = resourcePackageName; - initialize(); - } - - @Override - protected Class getSystemClass() { - return android.R.id.class; - } - - @Override - protected String getLocalClassName(Context context) { - return mResourcePackageName + ".R$id"; - } - - private final String mResourcePackageName; + public static class Ids extends ResourceReader { + public Ids(String resourcePackageName, Context context) { + super(context); + mResourcePackageName = resourcePackageName; + initialize(); } - public static class Drawables extends ResourceReader { - protected Drawables(String resourcePackageName, Context context) { - super(context); - mResourcePackageName = resourcePackageName; - initialize(); - } - - @Override - protected Class getSystemClass() { - return android.R.drawable.class; - } - - @Override - protected String getLocalClassName(Context context) { - return mResourcePackageName + ".R$drawable"; - } - - private final String mResourcePackageName; + @Override + protected Class getSystemClass() { + return android.R.id.class; } - protected ResourceReader(Context context) { - mContext = context; - mIdNameToId = new HashMap(); - mIdToIdName = new SparseArray(); + @Override + protected String getLocalClassName(Context context) { + return mResourcePackageName + ".R$id"; } - @Override - public boolean knownIdName(String name) { - return mIdNameToId.containsKey(name); + private final String mResourcePackageName; + } + + public static class Drawables extends ResourceReader { + protected Drawables(String resourcePackageName, Context context) { + super(context); + mResourcePackageName = resourcePackageName; + initialize(); } @Override - public int idFromName(String name) { - return mIdNameToId.get(name); + protected Class getSystemClass() { + return android.R.drawable.class; } @Override - public String nameForId(int id) { - return mIdToIdName.get(id); + protected String getLocalClassName(Context context) { + return mResourcePackageName + ".R$drawable"; } - private static void readClassIds(Class platformIdClass, String namespace, Map namesToIds) { - try { - final Field[] fields = platformIdClass.getFields(); - for (int i = 0; i < fields.length; i++) { - final Field field = fields[i]; - final int modifiers = field.getModifiers(); - if (Modifier.isStatic(modifiers)) { - final Class fieldType = field.getType(); - if (fieldType == int.class) { - try { - final String name = field.getName(); - final int value = field.getInt(null); - final String namespacedName; - if (null == namespace) { - namespacedName = name; - } else { - namespacedName = namespace + ":" + name; - } - - namesToIds.put(namespacedName, value); - } catch (ArrayIndexOutOfBoundsException e) { - // https://github.com/mixpanel/mixpanel-android/issues/241 - MPLog.e(LOGTAG, "Can't read built-in id name from " + platformIdClass.getName(), e); - } - } - } + private final String mResourcePackageName; + } + + protected ResourceReader(Context context) { + mContext = context; + mIdNameToId = new HashMap(); + mIdToIdName = new SparseArray(); + } + + @Override + public boolean knownIdName(String name) { + return mIdNameToId.containsKey(name); + } + + @Override + public int idFromName(String name) { + return mIdNameToId.get(name); + } + + @Override + public String nameForId(int id) { + return mIdToIdName.get(id); + } + + private static void readClassIds( + Class platformIdClass, String namespace, Map namesToIds) { + try { + final Field[] fields = platformIdClass.getFields(); + for (int i = 0; i < fields.length; i++) { + final Field field = fields[i]; + final int modifiers = field.getModifiers(); + if (Modifier.isStatic(modifiers)) { + final Class fieldType = field.getType(); + if (fieldType == int.class) { + try { + final String name = field.getName(); + final int value = field.getInt(null); + final String namespacedName; + if (null == namespace) { + namespacedName = name; + } else { + namespacedName = namespace + ":" + name; + } + + namesToIds.put(namespacedName, value); + } catch (ArrayIndexOutOfBoundsException e) { + // https://github.com/mixpanel/mixpanel-android/issues/241 + MPLog.e(LOGTAG, "Can't read built-in id name from " + platformIdClass.getName(), e); } - } catch (IllegalAccessException e) { - MPLog.e(LOGTAG, "Can't read built-in id names from " + platformIdClass.getName(), e); + } } + } + } catch (IllegalAccessException e) { + MPLog.e(LOGTAG, "Can't read built-in id names from " + platformIdClass.getName(), e); + } + } + + protected abstract Class getSystemClass(); + + protected abstract String getLocalClassName(Context context); + + protected void initialize() { + mIdNameToId.clear(); + mIdToIdName.clear(); + + final Class sysIdClass = getSystemClass(); + readClassIds(sysIdClass, "android", mIdNameToId); + + final String localClassName = getLocalClassName(mContext); + try { + final Class rIdClass = Class.forName(localClassName); + readClassIds(rIdClass, null, mIdNameToId); + } catch (ClassNotFoundException e) { + MPLog.w( + LOGTAG, + "Can't load names for Android view ids from '" + + localClassName + + "', ids by name will not be available in the events editor."); + MPLog.i( + LOGTAG, + "You may be missing a Resources class for your package due to your proguard" + + " configuration, or you may be using an applicationId in your build that isn't the" + + " same as the package declared in your AndroidManifest.xml file.\n" + + "If you're using proguard, you can fix this issue by adding the following to your" + + " proguard configuration:\n\n" + + "-keep class **.R$* {\n" + + " ;\n" + + "}\n\n" + + "If you're not using proguard, or if your proguard configuration already contains" + + " the directive above, you can add the following to your AndroidManifest.xml file" + + " to explicitly point the Mixpanel library to the appropriate library for your" + + " resources class:\n\n" + + "\n\n" + + "where YOUR_PACKAGE_NAME is the same string you use for the \"package\" attribute" + + " in your tag."); } - protected abstract Class getSystemClass(); - protected abstract String getLocalClassName(Context context); - - protected void initialize() { - mIdNameToId.clear(); - mIdToIdName.clear(); - - final Class sysIdClass = getSystemClass(); - readClassIds(sysIdClass, "android", mIdNameToId); - - final String localClassName = getLocalClassName(mContext); - try { - final Class rIdClass = Class.forName(localClassName); - readClassIds(rIdClass, null, mIdNameToId); - } catch (ClassNotFoundException e) { - MPLog.w(LOGTAG, "Can't load names for Android view ids from '" + localClassName + "', ids by name will not be available in the events editor."); - MPLog.i(LOGTAG, - "You may be missing a Resources class for your package due to your proguard configuration, " + - "or you may be using an applicationId in your build that isn't the same as the package declared in your AndroidManifest.xml file.\n" + - "If you're using proguard, you can fix this issue by adding the following to your proguard configuration:\n\n" + - "-keep class **.R$* {\n" + - " ;\n" + - "}\n\n" + - "If you're not using proguard, or if your proguard configuration already contains the directive above, " + - "you can add the following to your AndroidManifest.xml file to explicitly point the Mixpanel library to " + - "the appropriate library for your resources class:\n\n" + - "\n\n" + - "where YOUR_PACKAGE_NAME is the same string you use for the \"package\" attribute in your tag." - ); - } - - for (Map.Entry idMapping : mIdNameToId.entrySet()) { - mIdToIdName.put(idMapping.getValue(), idMapping.getKey()); - } + for (Map.Entry idMapping : mIdNameToId.entrySet()) { + mIdToIdName.put(idMapping.getValue(), idMapping.getKey()); } + } - private final Context mContext; - private final Map mIdNameToId; - private final SparseArray mIdToIdName; + private final Context mContext; + private final Map mIdNameToId; + private final SparseArray mIdToIdName; - @SuppressWarnings("unused") - private static final String LOGTAG = "MixpanelAPI.RsrcReader"; + @SuppressWarnings("unused") + private static final String LOGTAG = "MixpanelAPI.RsrcReader"; } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/SessionMetadata.java b/src/main/java/com/mixpanel/android/mpmetrics/SessionMetadata.java index 949fbceac..81bc4c5ee 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/SessionMetadata.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/SessionMetadata.java @@ -1,55 +1,53 @@ package com.mixpanel.android.mpmetrics; -import com.mixpanel.android.util.MPLog; +import static com.mixpanel.android.mpmetrics.ConfigurationChecker.LOGTAG; +import com.mixpanel.android.util.MPLog; +import java.security.SecureRandom; import org.json.JSONException; import org.json.JSONObject; -import java.security.SecureRandom; - -import static com.mixpanel.android.mpmetrics.ConfigurationChecker.LOGTAG; - /* package */ class SessionMetadata { - private long mEventsCounter, mPeopleCounter, mSessionStartEpoch; - private String mSessionID; - private final SecureRandom mRandom; - - /* package */ SessionMetadata() { - initSession(); - mRandom = new SecureRandom(); - } - - protected void initSession() { - mEventsCounter = 0L; - mPeopleCounter = 0L; - mSessionID = Long.toHexString(new SecureRandom().nextLong()); - mSessionStartEpoch = System.currentTimeMillis() / 1000; - } - - public JSONObject getMetadataForEvent() { - return getNewMetadata(true); - } - - public JSONObject getMetadataForPeople() { - return getNewMetadata(false); + private long mEventsCounter, mPeopleCounter, mSessionStartEpoch; + private String mSessionID; + private final SecureRandom mRandom; + + /* package */ SessionMetadata() { + initSession(); + mRandom = new SecureRandom(); + } + + protected void initSession() { + mEventsCounter = 0L; + mPeopleCounter = 0L; + mSessionID = Long.toHexString(new SecureRandom().nextLong()); + mSessionStartEpoch = System.currentTimeMillis() / 1000; + } + + public JSONObject getMetadataForEvent() { + return getNewMetadata(true); + } + + public JSONObject getMetadataForPeople() { + return getNewMetadata(false); + } + + private JSONObject getNewMetadata(boolean isEvent) { + JSONObject metadataJson = new JSONObject(); + try { + metadataJson.put("$mp_event_id", Long.toHexString(mRandom.nextLong())); + metadataJson.put("$mp_session_id", mSessionID); + metadataJson.put("$mp_session_seq_id", isEvent ? mEventsCounter : mPeopleCounter); + metadataJson.put("$mp_session_start_sec", mSessionStartEpoch); + if (isEvent) { + mEventsCounter++; + } else { + mPeopleCounter++; + } + } catch (JSONException e) { + MPLog.e(LOGTAG, "Cannot create session metadata JSON object", e); } - private JSONObject getNewMetadata(boolean isEvent) { - JSONObject metadataJson = new JSONObject(); - try { - metadataJson.put("$mp_event_id", Long.toHexString(mRandom.nextLong())); - metadataJson.put("$mp_session_id", mSessionID); - metadataJson.put("$mp_session_seq_id", isEvent ? mEventsCounter : mPeopleCounter); - metadataJson.put("$mp_session_start_sec", mSessionStartEpoch); - if (isEvent) { - mEventsCounter++; - } else { - mPeopleCounter++; - } - } catch (JSONException e) { - MPLog.e(LOGTAG, "Cannot create session metadata JSON object", e); - } - - return metadataJson; - } + return metadataJson; + } } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/SessionReplayBroadcastReceiver.java b/src/main/java/com/mixpanel/android/mpmetrics/SessionReplayBroadcastReceiver.java index 5a9538a1d..1960451f2 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/SessionReplayBroadcastReceiver.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/SessionReplayBroadcastReceiver.java @@ -4,46 +4,45 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; - import com.mixpanel.android.util.MPConstants.SessionReplay; import com.mixpanel.android.util.MPLog; - import java.io.Serializable; import java.util.HashMap; public class SessionReplayBroadcastReceiver extends BroadcastReceiver { - private static final String LOGTAG = "SessionReplayBroadcastReceiver"; - private final MixpanelAPI sdkInstance; + private static final String LOGTAG = "SessionReplayBroadcastReceiver"; + private final MixpanelAPI sdkInstance; - public static final IntentFilter INTENT_FILTER = new IntentFilter(); - static { - INTENT_FILTER.addAction(SessionReplay.REGISTER_ACTION); - INTENT_FILTER.addAction(SessionReplay.UNREGISTER_ACTION); - } + public static final IntentFilter INTENT_FILTER = new IntentFilter(); - public SessionReplayBroadcastReceiver(MixpanelAPI instance) { - this.sdkInstance = instance; - } + static { + INTENT_FILTER.addAction(SessionReplay.REGISTER_ACTION); + INTENT_FILTER.addAction(SessionReplay.UNREGISTER_ACTION); + } + + public SessionReplayBroadcastReceiver(MixpanelAPI instance) { + this.sdkInstance = instance; + } - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - if (SessionReplay.REGISTER_ACTION.equals(action)) { - HashMap data = null; - Serializable serializableData = intent.getSerializableExtra("data"); - if (serializableData instanceof HashMap) { - try { - data = (HashMap) serializableData; - } catch (ClassCastException e) { - MPLog.e(LOGTAG, "Failed to cast broadcast extras data to HashMap", e); - MPLog.d(LOGTAG, "Broadcast extras data: " + serializableData); - } - } - if (data != null && data.containsKey(SessionReplay.REPLAY_ID_KEY)) { - sdkInstance.registerSuperPropertiesMap(data); - } - } else if (SessionReplay.UNREGISTER_ACTION.equals(action)) { - sdkInstance.unregisterSuperProperty(SessionReplay.REPLAY_ID_KEY); + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (SessionReplay.REGISTER_ACTION.equals(action)) { + HashMap data = null; + Serializable serializableData = intent.getSerializableExtra("data"); + if (serializableData instanceof HashMap) { + try { + data = (HashMap) serializableData; + } catch (ClassCastException e) { + MPLog.e(LOGTAG, "Failed to cast broadcast extras data to HashMap", e); + MPLog.d(LOGTAG, "Broadcast extras data: " + serializableData); } + } + if (data != null && data.containsKey(SessionReplay.REPLAY_ID_KEY)) { + sdkInstance.registerSuperPropertiesMap(data); + } + } else if (SessionReplay.UNREGISTER_ACTION.equals(action)) { + sdkInstance.unregisterSuperProperty(SessionReplay.REPLAY_ID_KEY); } + } } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/SharedPreferencesLoader.java b/src/main/java/com/mixpanel/android/mpmetrics/SharedPreferencesLoader.java index 94fd72fef..7b49bbd23 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/SharedPreferencesLoader.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/SharedPreferencesLoader.java @@ -1,51 +1,53 @@ package com.mixpanel.android.mpmetrics; +import android.content.Context; +import android.content.SharedPreferences; import java.util.concurrent.Callable; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.FutureTask; -import android.content.Context; -import android.content.SharedPreferences; - /* package */ class SharedPreferencesLoader { - /* package */ interface OnPrefsLoadedListener { - void onPrefsLoaded(SharedPreferences prefs); - } - - public SharedPreferencesLoader() { - mExecutor = Executors.newSingleThreadExecutor(); + /* package */ interface OnPrefsLoadedListener { + void onPrefsLoaded(SharedPreferences prefs); + } + + public SharedPreferencesLoader() { + mExecutor = Executors.newSingleThreadExecutor(); + } + + public Future loadPreferences( + Context context, String name, OnPrefsLoadedListener listener) { + final LoadSharedPreferences loadSharedPrefs = + new LoadSharedPreferences(context, name, listener); + final FutureTask task = new FutureTask(loadSharedPrefs); + mExecutor.execute(task); + return task; + } + + private static class LoadSharedPreferences implements Callable { + public LoadSharedPreferences( + Context context, String prefsName, OnPrefsLoadedListener listener) { + mContext = context; + mPrefsName = prefsName; + mListener = listener; } - public Future loadPreferences(Context context, String name, OnPrefsLoadedListener listener) { - final LoadSharedPreferences loadSharedPrefs = new LoadSharedPreferences(context, name, listener); - final FutureTask task = new FutureTask(loadSharedPrefs); - mExecutor.execute(task); - return task; + @Override + public SharedPreferences call() { + final SharedPreferences ret = mContext.getSharedPreferences(mPrefsName, Context.MODE_PRIVATE); + if (null != mListener) { + mListener.onPrefsLoaded(ret); + } + return ret; } - private static class LoadSharedPreferences implements Callable { - public LoadSharedPreferences(Context context, String prefsName, OnPrefsLoadedListener listener) { - mContext = context; - mPrefsName = prefsName; - mListener = listener; - } - - @Override - public SharedPreferences call() { - final SharedPreferences ret = mContext.getSharedPreferences(mPrefsName, Context.MODE_PRIVATE); - if (null != mListener) { - mListener.onPrefsLoaded(ret); - } - return ret; - } - - private final Context mContext; - private final String mPrefsName; - private final OnPrefsLoadedListener mListener; - } + private final Context mContext; + private final String mPrefsName; + private final OnPrefsLoadedListener mListener; + } - private final Executor mExecutor; + private final Executor mExecutor; } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/SuperPropertyUpdate.java b/src/main/java/com/mixpanel/android/mpmetrics/SuperPropertyUpdate.java index a617dde89..3dab47292 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/SuperPropertyUpdate.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/SuperPropertyUpdate.java @@ -3,18 +3,17 @@ import org.json.JSONObject; /** - * Use SuperPropertyUpdate objects to make changes to super properties - * in place, in a thread-safe way. See {@link MixpanelAPI#updateSuperProperties(SuperPropertyUpdate)} - * for details. + * Use SuperPropertyUpdate objects to make changes to super properties in place, in a thread-safe + * way. See {@link MixpanelAPI#updateSuperProperties(SuperPropertyUpdate)} for details. */ public interface SuperPropertyUpdate { - /** - * update should take a JSONObject and return a JSON object. The returned - * object will replace the given object as all of the super properties stored - * for the current user. update should not return null. - * - * @param oldValues the existing super properties - * @return a new set of super properties that will be sent with every event. - */ - JSONObject update(JSONObject oldValues); + /** + * update should take a JSONObject and return a JSON object. The returned object will replace the + * given object as all of the super properties stored for the current user. update should not + * return null. + * + * @param oldValues the existing super properties + * @return a new set of super properties that will be sent with every event. + */ + JSONObject update(JSONObject oldValues); } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/SynchronizedReference.java b/src/main/java/com/mixpanel/android/mpmetrics/SynchronizedReference.java index 92c570ca8..b5199eabf 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/SynchronizedReference.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/SynchronizedReference.java @@ -1,27 +1,27 @@ package com.mixpanel.android.mpmetrics; /** - * We need this for stronger ordering guarantees than AtomicReference - * (and we don't need compareAndSet) + * We need this for stronger ordering guarantees than AtomicReference (and we don't need + * compareAndSet) */ /* package */ class SynchronizedReference { - public SynchronizedReference() { - mContents = null; - } + public SynchronizedReference() { + mContents = null; + } - public synchronized void set(T contents) { - mContents = contents; - } + public synchronized void set(T contents) { + mContents = contents; + } - public synchronized T getAndClear() { - final T ret = mContents; - mContents = null; - return ret; - } + public synchronized T getAndClear() { + final T ret = mContents; + mContents = null; + return ret; + } - public synchronized T get() { - return mContents; - } + public synchronized T get() { + return mContents; + } - private T mContents; + private T mContents; } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/SystemInformation.java b/src/main/java/com/mixpanel/android/mpmetrics/SystemInformation.java index c43fb45aa..530a610e9 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/SystemInformation.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/SystemInformation.java @@ -1,8 +1,5 @@ package com.mixpanel.android.mpmetrics; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; - import android.Manifest; import android.annotation.SuppressLint; import android.bluetooth.BluetoothAdapter; @@ -18,171 +15,204 @@ import android.telephony.TelephonyManager; import android.util.DisplayMetrics; import android.view.Display; - import com.mixpanel.android.util.MPLog; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; /** - * Abstracts away possibly non-present system information classes, - * and handles permission-dependent queries for default system information. + * Abstracts away possibly non-present system information classes, and handles permission-dependent + * queries for default system information. */ /* package */ class SystemInformation { - /* package */ static SystemInformation getInstance(Context context) { - synchronized (sInstanceLock) { - if (null == sInstance) { - final Context appContext = context.getApplicationContext(); - sInstance = new SystemInformation(appContext); - } - } - - return sInstance; + /* package */ static SystemInformation getInstance(Context context) { + synchronized (sInstanceLock) { + if (null == sInstance) { + final Context appContext = context.getApplicationContext(); + sInstance = new SystemInformation(appContext); + } } - private SystemInformation(Context context) { - mContext = context; - - PackageManager packageManager = mContext.getPackageManager(); - - String foundAppVersionName = null; - Integer foundAppVersionCode = null; - try { - PackageInfo packageInfo = packageManager.getPackageInfo(mContext.getPackageName(), 0); - foundAppVersionName = packageInfo.versionName; - foundAppVersionCode = packageInfo.versionCode; - } catch (NameNotFoundException e) { - MPLog.w(LOGTAG, "System information constructed with a context that apparently doesn't exist."); - } - - ApplicationInfo applicationInfo = context.getApplicationInfo(); - int appNameStringId = applicationInfo.labelRes; - - mAppVersionName = foundAppVersionName; - mAppVersionCode = foundAppVersionCode; - mAppName = appNameStringId == 0 ? applicationInfo.nonLocalizedLabel == null ? "Misc" : applicationInfo.nonLocalizedLabel.toString() : context.getString(appNameStringId); - - // We can't count on these features being available, since we need to - // run on old devices. Thus, the reflection fandango below... - Class packageManagerClass = packageManager.getClass(); - - Method hasSystemFeatureMethod = null; - try { - hasSystemFeatureMethod = packageManagerClass.getMethod("hasSystemFeature", String.class); - } catch (NoSuchMethodException e) { - // Nothing, this is an expected outcome - } - - Boolean foundNFC = null; - Boolean foundTelephony = null; - if (null != hasSystemFeatureMethod) { - try { - foundNFC = (Boolean) hasSystemFeatureMethod.invoke(packageManager, "android.hardware.nfc"); - foundTelephony = (Boolean) hasSystemFeatureMethod.invoke(packageManager, "android.hardware.telephony"); - } catch (InvocationTargetException e) { - MPLog.w(LOGTAG, "System version appeared to support PackageManager.hasSystemFeature, but we were unable to call it."); - } catch (IllegalAccessException e) { - MPLog.w(LOGTAG, "System version appeared to support PackageManager.hasSystemFeature, but we were unable to call it."); - } - } + return sInstance; + } - mHasNFC = foundNFC; - mHasTelephony = foundTelephony; - mDisplayMetrics = new DisplayMetrics(); + private SystemInformation(Context context) { + mContext = context; - DisplayManager displayManager = (DisplayManager) mContext.getSystemService(Context.DISPLAY_SERVICE); - Display display = (displayManager != null) ? displayManager.getDisplay(Display.DEFAULT_DISPLAY) : null; + PackageManager packageManager = mContext.getPackageManager(); - if (display != null) { - display.getMetrics(mDisplayMetrics); - } else { - DisplayMetrics defaultMetrics = mContext.getResources().getDisplayMetrics(); - mDisplayMetrics.setTo(defaultMetrics); - } + String foundAppVersionName = null; + Integer foundAppVersionCode = null; + try { + PackageInfo packageInfo = packageManager.getPackageInfo(mContext.getPackageName(), 0); + foundAppVersionName = packageInfo.versionName; + foundAppVersionCode = packageInfo.versionCode; + } catch (NameNotFoundException e) { + MPLog.w( + LOGTAG, "System information constructed with a context that apparently doesn't exist."); } - public String getAppVersionName() { return mAppVersionName; } - - public Integer getAppVersionCode() { return mAppVersionCode; } - - public String getAppName() { return mAppName; } - - public boolean hasNFC() { return mHasNFC; } - - public boolean hasTelephony() { return mHasTelephony; } - - public DisplayMetrics getDisplayMetrics() { return mDisplayMetrics; } - - // Note this is the *current*, not the canonical network, because it - // doesn't require special permissions to access. Unreliable for CDMA phones, - // - public String getCurrentNetworkOperator() { - String ret = null; - - TelephonyManager telephonyManager = (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE); - if (null != telephonyManager) { - ret = telephonyManager.getNetworkOperatorName(); - } + ApplicationInfo applicationInfo = context.getApplicationInfo(); + int appNameStringId = applicationInfo.labelRes; + + mAppVersionName = foundAppVersionName; + mAppVersionCode = foundAppVersionCode; + mAppName = + appNameStringId == 0 + ? applicationInfo.nonLocalizedLabel == null + ? "Misc" + : applicationInfo.nonLocalizedLabel.toString() + : context.getString(appNameStringId); + + // We can't count on these features being available, since we need to + // run on old devices. Thus, the reflection fandango below... + Class packageManagerClass = packageManager.getClass(); + + Method hasSystemFeatureMethod = null; + try { + hasSystemFeatureMethod = packageManagerClass.getMethod("hasSystemFeature", String.class); + } catch (NoSuchMethodException e) { + // Nothing, this is an expected outcome + } - return ret; + Boolean foundNFC = null; + Boolean foundTelephony = null; + if (null != hasSystemFeatureMethod) { + try { + foundNFC = (Boolean) hasSystemFeatureMethod.invoke(packageManager, "android.hardware.nfc"); + foundTelephony = + (Boolean) hasSystemFeatureMethod.invoke(packageManager, "android.hardware.telephony"); + } catch (InvocationTargetException e) { + MPLog.w( + LOGTAG, + "System version appeared to support PackageManager.hasSystemFeature, but we were unable" + + " to call it."); + } catch (IllegalAccessException e) { + MPLog.w( + LOGTAG, + "System version appeared to support PackageManager.hasSystemFeature, but we were unable" + + " to call it."); + } } - @SuppressLint("MissingPermission") - @SuppressWarnings("MissingPermission") - public Boolean isWifiConnected() { - Boolean ret = null; + mHasNFC = foundNFC; + mHasTelephony = foundTelephony; + mDisplayMetrics = new DisplayMetrics(); - if (PackageManager.PERMISSION_GRANTED == mContext.checkCallingOrSelfPermission(Manifest.permission.ACCESS_NETWORK_STATE)) { - ConnectivityManager connManager = (ConnectivityManager) this.mContext.getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo networkInfo = connManager.getActiveNetworkInfo(); - ret = (networkInfo != null && networkInfo.getType() == ConnectivityManager.TYPE_WIFI && networkInfo.isConnected()); - } + DisplayManager displayManager = + (DisplayManager) mContext.getSystemService(Context.DISPLAY_SERVICE); + Display display = + (displayManager != null) ? displayManager.getDisplay(Display.DEFAULT_DISPLAY) : null; - return ret; + if (display != null) { + display.getMetrics(mDisplayMetrics); + } else { + DisplayMetrics defaultMetrics = mContext.getResources().getDisplayMetrics(); + mDisplayMetrics.setTo(defaultMetrics); + } + } + + public String getAppVersionName() { + return mAppVersionName; + } + + public Integer getAppVersionCode() { + return mAppVersionCode; + } + + public String getAppName() { + return mAppName; + } + + public boolean hasNFC() { + return mHasNFC; + } + + public boolean hasTelephony() { + return mHasTelephony; + } + + public DisplayMetrics getDisplayMetrics() { + return mDisplayMetrics; + } + + // Note this is the *current*, not the canonical network, because it + // doesn't require special permissions to access. Unreliable for CDMA phones, + // + public String getCurrentNetworkOperator() { + String ret = null; + + TelephonyManager telephonyManager = + (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE); + if (null != telephonyManager) { + ret = telephonyManager.getNetworkOperatorName(); } - @SuppressLint("MissingPermission") - @SuppressWarnings("MissingPermission") - public Boolean isBluetoothEnabled() { - Boolean isBluetoothEnabled = null; - try { - PackageManager pm = mContext.getPackageManager(); - int hasBluetoothPermission = pm.checkPermission( - Manifest.permission.BLUETOOTH, - mContext.getPackageName()); - if (hasBluetoothPermission == PackageManager.PERMISSION_GRANTED) { - BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); - if (bluetoothAdapter != null) { - isBluetoothEnabled = bluetoothAdapter.isEnabled(); - } - } - } catch (Exception e) { - // something went wrong, don't crash, we can live without it - } - return isBluetoothEnabled; + return ret; + } + + @SuppressLint("MissingPermission") + @SuppressWarnings("MissingPermission") + public Boolean isWifiConnected() { + Boolean ret = null; + + if (PackageManager.PERMISSION_GRANTED + == mContext.checkCallingOrSelfPermission(Manifest.permission.ACCESS_NETWORK_STATE)) { + ConnectivityManager connManager = + (ConnectivityManager) this.mContext.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = connManager.getActiveNetworkInfo(); + ret = + (networkInfo != null + && networkInfo.getType() == ConnectivityManager.TYPE_WIFI + && networkInfo.isConnected()); } - public String getBluetoothVersion() { - String bluetoothVersion = "none"; - if(android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && - mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { - bluetoothVersion = "ble"; - } else if(mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) { - bluetoothVersion = "classic"; + return ret; + } + + @SuppressLint("MissingPermission") + @SuppressWarnings("MissingPermission") + public Boolean isBluetoothEnabled() { + Boolean isBluetoothEnabled = null; + try { + PackageManager pm = mContext.getPackageManager(); + int hasBluetoothPermission = + pm.checkPermission(Manifest.permission.BLUETOOTH, mContext.getPackageName()); + if (hasBluetoothPermission == PackageManager.PERMISSION_GRANTED) { + BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + if (bluetoothAdapter != null) { + isBluetoothEnabled = bluetoothAdapter.isEnabled(); } - return bluetoothVersion; + } + } catch (Exception e) { + // something went wrong, don't crash, we can live without it + } + return isBluetoothEnabled; + } + + public String getBluetoothVersion() { + String bluetoothVersion = "none"; + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 + && mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { + bluetoothVersion = "ble"; + } else if (mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) { + bluetoothVersion = "classic"; } + return bluetoothVersion; + } - private final Context mContext; + private final Context mContext; - // Unchanging facts - private final Boolean mHasNFC; - private final Boolean mHasTelephony; - private final DisplayMetrics mDisplayMetrics; - private final String mAppVersionName; - private final Integer mAppVersionCode; - private final String mAppName; + // Unchanging facts + private final Boolean mHasNFC; + private final Boolean mHasTelephony; + private final DisplayMetrics mDisplayMetrics; + private final String mAppVersionName; + private final Integer mAppVersionCode; + private final String mAppName; - private static SystemInformation sInstance; - private static final Object sInstanceLock = new Object(); + private static SystemInformation sInstance; + private static final Object sInstanceLock = new Object(); - private static final String LOGTAG = "MixpanelAPI.SysInfo"; + private static final String LOGTAG = "MixpanelAPI.SysInfo"; } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/package-info.java b/src/main/java/com/mixpanel/android/mpmetrics/package-info.java index a16f84af1..6e240a1c3 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/package-info.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/package-info.java @@ -1,22 +1,18 @@ /** - * This package contains the interface to Mixpanel that you can use from your - * Android apps. You can use Mixpanel to send events, update people analytics properties. + * This package contains the interface to Mixpanel that you can use from your Android apps. You can + * use Mixpanel to send events, update people analytics properties. * - * The primary interface to Mixpanel services is in {@link com.mixpanel.android.mpmetrics.MixpanelAPI}. - * At it's simplest, you can send events with - *

- * {@code
+ * 

The primary interface to Mixpanel services is in {@link + * com.mixpanel.android.mpmetrics.MixpanelAPI}. At it's simplest, you can send events with * + *

{@code
  * MixpanelAPI mixpanel = MixpanelAPI.getInstance(context, MIXPANEL_TOKEN);
  * mixpanel.track("Library integrated", null);
  *
- * }
- * 
- * - * In addition to this reference documentation, you can also see our overview - * and getting started documentation at - * https://mixpanel.com/help/reference/android + * }
* + * In addition to this reference documentation, you can also see our overview and getting started + * documentation at https://mixpanel.com/help/reference/android */ -package com.mixpanel.android.mpmetrics; \ No newline at end of file +package com.mixpanel.android.mpmetrics; diff --git a/src/main/java/com/mixpanel/android/util/Base64Coder.java b/src/main/java/com/mixpanel/android/util/Base64Coder.java index 45da57a64..9f0b0b77c 100644 --- a/src/main/java/com/mixpanel/android/util/Base64Coder.java +++ b/src/main/java/com/mixpanel/android/util/Base64Coder.java @@ -1,138 +1,155 @@ package com.mixpanel.android.util; -//Copyright 2003-2010 Christian d'Heureuse, Inventec Informatik AG, Zurich, Switzerland -//www.source-code.biz, www.inventec.ch/chdh +// Copyright 2003-2010 Christian d'Heureuse, Inventec Informatik AG, Zurich, Switzerland +// www.source-code.biz, www.inventec.ch/chdh // -//This module is multi-licensed and may be used under the terms -//of any of the following licenses: +// This module is multi-licensed and may be used under the terms +// of any of the following licenses: // -//EPL, Eclipse Public License, V1.0 or later, http://www.eclipse.org/legal -//LGPL, GNU Lesser General Public License, V2.1 or later, http://www.gnu.org/licenses/lgpl.html -//GPL, GNU General Public License, V2 or later, http://www.gnu.org/licenses/gpl.html -//AL, Apache License, V2.0 or later, http://www.apache.org/licenses -//BSD, BSD License, http://www.opensource.org/licenses/bsd-license.php +// EPL, Eclipse Public License, V1.0 or later, http://www.eclipse.org/legal +// LGPL, GNU Lesser General Public License, V2.1 or later, http://www.gnu.org/licenses/lgpl.html +// GPL, GNU General Public License, V2 or later, http://www.gnu.org/licenses/gpl.html +// AL, Apache License, V2.0 or later, http://www.apache.org/licenses +// BSD, BSD License, http://www.opensource.org/licenses/bsd-license.php // -//Please contact the author if you need another license. -//This module is provided "as is", without warranties of any kind. +// Please contact the author if you need another license. +// This module is provided "as is", without warranties of any kind. // // This file has been modified from it's original version by Mixpanel, Inc public class Base64Coder { - // Mapping table from 6-bit nibbles to Base64 characters. - private final static char[] map1 = new char[64]; - static { - int i=0; - for (char c='A'; c<='Z'; c++) map1[i++] = c; - for (char c='a'; c<='z'; c++) map1[i++] = c; - for (char c='0'; c<='9'; c++) map1[i++] = c; - map1[i++] = '+'; map1[i++] = '/'; } + // Mapping table from 6-bit nibbles to Base64 characters. + private static final char[] map1 = new char[64]; - // Mapping table from Base64 characters to 6-bit nibbles. - private final static byte[] map2 = new byte[128]; - static { - for (int i=0; iin. - * @return A character array with the Base64 encoded data. - */ - public static char[] encode (byte[] in, int iLen) { - int oDataLen = (iLen*4+2)/3; // output length without padding - int oLen = ((iLen+2)/3)*4; // output length including padding - char[] out = new char[oLen]; - int ip = 0; - int op = 0; - while (ip < iLen) { - int i0 = in[ip++] & 0xff; - int i1 = ip < iLen ? in[ip++] & 0xff : 0; - int i2 = ip < iLen ? in[ip++] & 0xff : 0; - int o0 = i0 >>> 2; - int o1 = ((i0 & 3) << 4) | (i1 >>> 4); - int o2 = ((i1 & 0xf) << 2) | (i2 >>> 6); - int o3 = i2 & 0x3F; - out[op++] = map1[o0]; - out[op++] = map1[o1]; - out[op] = op < oDataLen ? map1[o2] : '='; op++; - out[op] = op < oDataLen ? map1[o3] : '='; op++; } - return out; } + /** + * Encodes a string into Base64 format. No blanks or line breaks are inserted. + * + * @param s a String to be encoded. + * @return A String with the Base64 encoded data. + */ + public static String encodeString(String s) { + return new String(encode(s.getBytes())); + } - /** - * Decodes a string from Base64 format. - * @param s a Base64 String to be decoded. - * @return A String containing the decoded data. - * @throws IllegalArgumentException if the input is not valid Base64 encoded data. - */ - public static String decodeString (String s) { - return new String(decode(s)); } + /** + * Encodes a byte array into Base64 format. No blanks or line breaks are inserted. + * + * @param in an array containing the data bytes to be encoded. + * @return A character array with the Base64 encoded data. + */ + public static char[] encode(byte[] in) { + return encode(in, in.length); + } - /** - * Decodes a byte array from Base64 format. - * @param s a Base64 String to be decoded. - * @return An array containing the decoded data bytes. - * @throws IllegalArgumentException if the input is not valid Base64 encoded data. - */ - public static byte[] decode (String s) { - return decode(s.toCharArray()); } + /** + * Encodes a byte array into Base64 format. No blanks or line breaks are inserted. + * + * @param in an array containing the data bytes to be encoded. + * @param iLen number of bytes to process in in. + * @return A character array with the Base64 encoded data. + */ + public static char[] encode(byte[] in, int iLen) { + int oDataLen = (iLen * 4 + 2) / 3; // output length without padding + int oLen = ((iLen + 2) / 3) * 4; // output length including padding + char[] out = new char[oLen]; + int ip = 0; + int op = 0; + while (ip < iLen) { + int i0 = in[ip++] & 0xff; + int i1 = ip < iLen ? in[ip++] & 0xff : 0; + int i2 = ip < iLen ? in[ip++] & 0xff : 0; + int o0 = i0 >>> 2; + int o1 = ((i0 & 3) << 4) | (i1 >>> 4); + int o2 = ((i1 & 0xf) << 2) | (i2 >>> 6); + int o3 = i2 & 0x3F; + out[op++] = map1[o0]; + out[op++] = map1[o1]; + out[op] = op < oDataLen ? map1[o2] : '='; + op++; + out[op] = op < oDataLen ? map1[o3] : '='; + op++; + } + return out; + } - /** - * Decodes a byte array from Base64 format. - * No blanks or line breaks are allowed within the Base64 encoded data. - * @param in a character array containing the Base64 encoded data. - * @return An array containing the decoded data bytes. - * @throws IllegalArgumentException if the input is not valid Base64 encoded data. - */ - public static byte[] decode (char[] in) { - int iLen = in.length; - if (iLen%4 != 0) throw new IllegalArgumentException ("Length of Base64 encoded input string is not a multiple of 4."); - while (iLen > 0 && in[iLen-1] == '=') iLen--; - int oLen = (iLen*3) / 4; - byte[] out = new byte[oLen]; - int ip = 0; - int op = 0; - while (ip < iLen) { - int i0 = in[ip++]; - int i1 = in[ip++]; - int i2 = ip < iLen ? in[ip++] : 'A'; - int i3 = ip < iLen ? in[ip++] : 'A'; - if (i0 > 127 || i1 > 127 || i2 > 127 || i3 > 127) - throw new IllegalArgumentException ("Illegal character in Base64 encoded data."); - int b0 = map2[i0]; - int b1 = map2[i1]; - int b2 = map2[i2]; - int b3 = map2[i3]; - if (b0 < 0 || b1 < 0 || b2 < 0 || b3 < 0) - throw new IllegalArgumentException ("Illegal character in Base64 encoded data."); - int o0 = ( b0 <<2) | (b1>>>4); - int o1 = ((b1 & 0xf)<<4) | (b2>>>2); - int o2 = ((b2 & 3)<<6) | b3; - out[op++] = (byte)o0; - if (op 0 && in[iLen - 1] == '=') iLen--; + int oLen = (iLen * 3) / 4; + byte[] out = new byte[oLen]; + int ip = 0; + int op = 0; + while (ip < iLen) { + int i0 = in[ip++]; + int i1 = in[ip++]; + int i2 = ip < iLen ? in[ip++] : 'A'; + int i3 = ip < iLen ? in[ip++] : 'A'; + if (i0 > 127 || i1 > 127 || i2 > 127 || i3 > 127) + throw new IllegalArgumentException("Illegal character in Base64 encoded data."); + int b0 = map2[i0]; + int b1 = map2[i1]; + int b2 = map2[i2]; + int b3 = map2[i3]; + if (b0 < 0 || b1 < 0 || b2 < 0 || b3 < 0) + throw new IllegalArgumentException("Illegal character in Base64 encoded data."); + int o0 = (b0 << 2) | (b1 >>> 4); + int o1 = ((b1 & 0xf) << 4) | (b2 >>> 2); + int o2 = ((b2 & 3) << 6) | b3; + out[op++] = (byte) o0; + if (op < oLen) out[op++] = (byte) o1; + if (op < oLen) out[op++] = (byte) o2; + } + return out; + } +} // end class Base64Coder diff --git a/src/main/java/com/mixpanel/android/util/HttpService.java b/src/main/java/com/mixpanel/android/util/HttpService.java index 5567e5207..1f50b9566 100644 --- a/src/main/java/com/mixpanel/android/util/HttpService.java +++ b/src/main/java/com/mixpanel/android/util/HttpService.java @@ -7,10 +7,8 @@ import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.net.Uri; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; - import java.io.BufferedOutputStream; import java.io.ByteArrayOutputStream; import java.io.EOFException; @@ -25,338 +23,436 @@ import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.zip.GZIPOutputStream; - import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLSocketFactory; -/** - * An HTTP utility class for internal use in the Mixpanel library. Not thread-safe. - */ +/** An HTTP utility class for internal use in the Mixpanel library. Not thread-safe. */ public class HttpService implements RemoteService { - - private final boolean shouldGzipRequestPayload; - private final MixpanelNetworkErrorListener networkErrorListener; - - private static boolean sIsMixpanelBlocked; - private static final int MIN_UNAVAILABLE_HTTP_RESPONSE_CODE = HttpURLConnection.HTTP_INTERNAL_ERROR; - private static final int MAX_UNAVAILABLE_HTTP_RESPONSE_CODE = 599; - - public HttpService(boolean shouldGzipRequestPayload, MixpanelNetworkErrorListener networkErrorListener) { - this.shouldGzipRequestPayload = shouldGzipRequestPayload; - this.networkErrorListener = networkErrorListener; - } - - public HttpService() { - this(false, null); - } - @Override - public void checkIsMixpanelBlocked() { - Thread t = new Thread(new Runnable() { - public void run() { + private final boolean shouldGzipRequestPayload; + private final MixpanelNetworkErrorListener networkErrorListener; + + private static boolean sIsMixpanelBlocked; + private static final int MIN_UNAVAILABLE_HTTP_RESPONSE_CODE = + HttpURLConnection.HTTP_INTERNAL_ERROR; + private static final int MAX_UNAVAILABLE_HTTP_RESPONSE_CODE = 599; + + public HttpService( + boolean shouldGzipRequestPayload, MixpanelNetworkErrorListener networkErrorListener) { + this.shouldGzipRequestPayload = shouldGzipRequestPayload; + this.networkErrorListener = networkErrorListener; + } + + public HttpService() { + this(false, null); + } + + @Override + public void checkIsMixpanelBlocked() { + Thread t = + new Thread( + new Runnable() { + public void run() { try { - long startTimeNanos = System.nanoTime(); - String host = "api.mixpanel.com"; - InetAddress apiMixpanelInet = InetAddress.getByName(host); - sIsMixpanelBlocked = apiMixpanelInet.isLoopbackAddress() || - apiMixpanelInet.isAnyLocalAddress(); - if (sIsMixpanelBlocked) { - MPLog.v(LOGTAG, "AdBlocker is enabled. Won't be able to use Mixpanel services."); - onNetworkError(null, host, apiMixpanelInet.getHostAddress(), startTimeNanos, -1, -1, new IOException(host + " is blocked")); - } + long startTimeNanos = System.nanoTime(); + String host = "api.mixpanel.com"; + InetAddress apiMixpanelInet = InetAddress.getByName(host); + sIsMixpanelBlocked = + apiMixpanelInet.isLoopbackAddress() || apiMixpanelInet.isAnyLocalAddress(); + if (sIsMixpanelBlocked) { + MPLog.v( + LOGTAG, "AdBlocker is enabled. Won't be able to use Mixpanel services."); + onNetworkError( + null, + host, + apiMixpanelInet.getHostAddress(), + startTimeNanos, + -1, + -1, + new IOException(host + " is blocked")); + } } catch (Exception e) { } - } - }); - - t.start(); + } + }); + + t.start(); + } + + @SuppressLint("MissingPermission") + @SuppressWarnings("MissingPermission") + @Override + public boolean isOnline(Context context, OfflineMode offlineMode) { + if (sIsMixpanelBlocked) return false; + if (onOfflineMode(offlineMode)) return false; + + boolean isOnline; + try { + final ConnectivityManager cm = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + final NetworkInfo netInfo = cm.getActiveNetworkInfo(); + if (netInfo == null) { + isOnline = true; + MPLog.v( + LOGTAG, + "A default network has not been set so we cannot be certain whether we are offline"); + } else { + isOnline = netInfo.isConnectedOrConnecting(); + MPLog.v( + LOGTAG, "ConnectivityManager says we " + (isOnline ? "are" : "are not") + " online"); + } + } catch (final SecurityException e) { + isOnline = true; + MPLog.v(LOGTAG, "Don't have permission to check connectivity, will assume we are online"); } - - @SuppressLint("MissingPermission") - @SuppressWarnings("MissingPermission") - @Override - public boolean isOnline(Context context, OfflineMode offlineMode) { - if (sIsMixpanelBlocked) return false; - if (onOfflineMode(offlineMode)) return false; - - boolean isOnline; - try { - final ConnectivityManager cm = - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - final NetworkInfo netInfo = cm.getActiveNetworkInfo(); - if (netInfo == null) { - isOnline = true; - MPLog.v(LOGTAG, "A default network has not been set so we cannot be certain whether we are offline"); - } else { - isOnline = netInfo.isConnectedOrConnecting(); - MPLog.v(LOGTAG, "ConnectivityManager says we " + (isOnline ? "are" : "are not") + " online"); - } - } catch (final SecurityException e) { - isOnline = true; - MPLog.v(LOGTAG, "Don't have permission to check connectivity, will assume we are online"); - } - return isOnline; + return isOnline; + } + + private boolean onOfflineMode(OfflineMode offlineMode) { + boolean onOfflineMode; + + try { + onOfflineMode = offlineMode != null && offlineMode.isOffline(); + } catch (Exception e) { + onOfflineMode = false; + MPLog.v( + LOGTAG, "Client State should not throw exception, will assume is not on offline mode", e); } - private boolean onOfflineMode(OfflineMode offlineMode) { - boolean onOfflineMode; - - try { - onOfflineMode = offlineMode != null && offlineMode.isOffline(); + return onOfflineMode; + } + + /** + * Performs an HTTP POST request. Handles either URL-encoded parameters OR a raw byte request + * body. Includes support for custom headers and network error listening. + */ + @Override + public byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Use if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) + throws ServiceUnavailableException, IOException { + + MPLog.v( + LOGTAG, + "Attempting request to " + + endpointUrl + + (requestBodyBytes == null ? " (URL params)" : " (Raw Body)")); + byte[] response = null; + int retries = 0; + boolean succeeded = false; + + while (retries < 3 && !succeeded) { + InputStream in = null; + OutputStream out = null; // Raw output stream + HttpURLConnection connection = null; + + // Variables for error listener reporting + String targetIpAddress = null; + long startTimeNanos = System.nanoTime(); + long uncompressedBodySize = -1; + long compressedBodySize = -1; // Only set if gzip applied to params + + try { + // --- Connection Setup --- + final URL url = new URL(endpointUrl); + try { // Get IP Address for error reporting, but don't fail request if DNS fails here + InetAddress inetAddress = InetAddress.getByName(url.getHost()); + targetIpAddress = inetAddress.getHostAddress(); } catch (Exception e) { - onOfflineMode = false; - MPLog.v(LOGTAG, "Client State should not throw exception, will assume is not on offline mode", e); + MPLog.v(LOGTAG, "Could not resolve IP address for " + url.getHost(), e); + targetIpAddress = "N/A"; // Default if lookup fails } - return onOfflineMode; - } - - /** - * Performs an HTTP POST request. Handles either URL-encoded parameters OR a raw byte request body. - * Includes support for custom headers and network error listening. - */ - @Override - public byte[] performRequest( - @NonNull String endpointUrl, - @Nullable ProxyServerInteractor interactor, - @Nullable Map params, // Use if requestBodyBytes is null - @Nullable Map headers, - @Nullable byte[] requestBodyBytes, // If provided, send this as raw body - @Nullable SSLSocketFactory socketFactory - ) throws ServiceUnavailableException, IOException { - - MPLog.v(LOGTAG, "Attempting request to " + endpointUrl + (requestBodyBytes == null ? " (URL params)" : " (Raw Body)")); - byte[] response = null; - int retries = 0; - boolean succeeded = false; - - while (retries < 3 && !succeeded) { - InputStream in = null; - OutputStream out = null; // Raw output stream - HttpURLConnection connection = null; - - // Variables for error listener reporting - String targetIpAddress = null; - long startTimeNanos = System.nanoTime(); - long uncompressedBodySize = -1; - long compressedBodySize = -1; // Only set if gzip applied to params - - try { - // --- Connection Setup --- - final URL url = new URL(endpointUrl); - try { // Get IP Address for error reporting, but don't fail request if DNS fails here - InetAddress inetAddress = InetAddress.getByName(url.getHost()); - targetIpAddress = inetAddress.getHostAddress(); - } catch (Exception e) { - MPLog.v(LOGTAG, "Could not resolve IP address for " + url.getHost(), e); - targetIpAddress = "N/A"; // Default if lookup fails - } - - connection = (HttpURLConnection) url.openConnection(); - if (null != socketFactory && connection instanceof HttpsURLConnection) { - ((HttpsURLConnection) connection).setSSLSocketFactory(socketFactory); - } - connection.setConnectTimeout(2000); - connection.setReadTimeout(30000); - connection.setRequestMethod("POST"); - connection.setDoOutput(true); - - // --- Default Content-Type (can be overridden by headers map) --- - String contentType = (requestBodyBytes != null) - ? "application/json; charset=utf-8" // Default for raw body - : "application/x-www-form-urlencoded; charset=utf-8"; // Default for params - - // --- Apply Custom Headers (and determine final Content-Type) --- - if (headers != null) { - for (Map.Entry entry : headers.entrySet()) { - connection.setRequestProperty(entry.getKey(), entry.getValue()); - if (entry.getKey().equalsIgnoreCase("Content-Type")) { - contentType = entry.getValue(); // Use explicit content type - } - } - } - connection.setRequestProperty("Content-Type", contentType); - - // Apply proxy headers AFTER custom headers - if (interactor != null && isProxyRequest(endpointUrl)) { /* ... Apply proxy headers ... */ - Map proxyHeaders = interactor.getProxyRequestHeaders(); - if (proxyHeaders != null) { - for (Map.Entry entry : proxyHeaders.entrySet()) { connection.setRequestProperty(entry.getKey(), entry.getValue()); } - } - } - - connection.setConnectTimeout(15000); - connection.setReadTimeout(60000); - - // --- Prepare and Write Body --- - byte[] bytesToWrite; - if (requestBodyBytes != null) { - // --- Use Raw Body --- - bytesToWrite = requestBodyBytes; - uncompressedBodySize = bytesToWrite.length; - connection.setFixedLengthStreamingMode(uncompressedBodySize); - MPLog.v(LOGTAG, "Sending raw body of size: " + uncompressedBodySize); - } else if (params != null) { - // --- Use URL Encoded Params --- - Uri.Builder builder = new Uri.Builder(); - for (Map.Entry param : params.entrySet()) { - builder.appendQueryParameter(param.getKey(), param.getValue().toString()); - } - String query = builder.build().getEncodedQuery(); - byte[] queryBytes = Objects.requireNonNull(query).getBytes(StandardCharsets.UTF_8); - uncompressedBodySize = queryBytes.length; - MPLog.v(LOGTAG, "Sending URL params (raw size): " + uncompressedBodySize); - - if (shouldGzipRequestPayload) { - // Apply GZIP specifically to the URL-encoded params - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (GZIPOutputStream gzipOut = new GZIPOutputStream(baos)) { - gzipOut.write(queryBytes); - } // try-with-resources ensures close - bytesToWrite = baos.toByteArray(); - compressedBodySize = bytesToWrite.length; - connection.setRequestProperty(CONTENT_ENCODING_HEADER, GZIP_CONTENT_TYPE_HEADER); - connection.setFixedLengthStreamingMode(compressedBodySize); - MPLog.v(LOGTAG, "Gzipping params, compressed size: " + compressedBodySize); - } else { - bytesToWrite = queryBytes; - connection.setFixedLengthStreamingMode(uncompressedBodySize); - } - } else { - // No body and no params - bytesToWrite = new byte[0]; - uncompressedBodySize = 0; - connection.setFixedLengthStreamingMode(0); - MPLog.v(LOGTAG, "Sending POST request with empty body."); - } - - // Write the prepared bytes - out = new BufferedOutputStream(connection.getOutputStream()); - out.write(bytesToWrite); - out.flush(); - out.close(); // Close output stream before getting response - out = null; - - // --- Process Response --- - int responseCode = connection.getResponseCode(); - String responseMessage = connection.getResponseMessage(); // Get message for logging/errors - MPLog.v(LOGTAG, "Response Code: " + responseCode); - if (interactor != null && isProxyRequest(endpointUrl)) { - interactor.onProxyResponse(endpointUrl, responseCode); - } - - if (responseCode >= 200 && responseCode < 300) { // Success - in = connection.getInputStream(); - response = slurp(in); - succeeded = true; - } else if (responseCode >= MIN_UNAVAILABLE_HTTP_RESPONSE_CODE && responseCode <= MAX_UNAVAILABLE_HTTP_RESPONSE_CODE) { // Server Error 5xx - // Report error via listener before throwing - onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, - new ServiceUnavailableException("Service Unavailable: " + responseCode, connection.getHeaderField("Retry-After"))); - // Now throw the exception - throw new ServiceUnavailableException("Service Unavailable: " + responseCode, connection.getHeaderField("Retry-After")); - } else { // Other errors (4xx etc.) - MPLog.w(LOGTAG, "HTTP error " + responseCode + " (" + responseMessage + ") for URL: " + endpointUrl); - String errorBody = null; - try { in = connection.getErrorStream(); if (in != null) { byte[] errorBytes = slurp(in); errorBody = new String(errorBytes, StandardCharsets.UTF_8); MPLog.w(LOGTAG, "Error Body: " + errorBody); } - } catch (Exception e) { MPLog.w(LOGTAG, "Could not read error stream.", e); } - // Report error via listener - onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, - new IOException("HTTP error response: " + responseCode + " " + responseMessage + (errorBody != null ? " - Body: " + errorBody : ""))); - response = null; // Indicate failure with null response - succeeded = true; // Mark as succeeded to stop retry loop for definitive HTTP errors - } - - } catch (final EOFException e) { - // Report error BEFORE retry attempt - onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, e); - MPLog.d(LOGTAG, "EOFException, likely network issue. Retrying request to " + endpointUrl); - retries++; - } catch (final IOException e) { // Includes ServiceUnavailableException if thrown above - // Report error via listener - onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, e); - // Re-throw the original exception - throw e; - } catch (final Exception e) { // Catch any other unexpected exceptions - // Report error via listener - onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, e); - // Wrap and re-throw as IOException? Or handle differently? - // Let's wrap in IOException for consistency with method signature. - throw new IOException("Unexpected exception during network request", e); - } finally { - // Clean up resources - if (null != out) try { out.close(); } catch (final IOException e) { /* ignore */ } - if (null != in) try { in.close(); } catch (final IOException e) { /* ignore */ } - if (null != connection) connection.disconnect(); + connection = (HttpURLConnection) url.openConnection(); + if (null != socketFactory && connection instanceof HttpsURLConnection) { + ((HttpsURLConnection) connection).setSSLSocketFactory(socketFactory); + } + connection.setConnectTimeout(2000); + connection.setReadTimeout(30000); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + + // --- Default Content-Type (can be overridden by headers map) --- + String contentType = + (requestBodyBytes != null) + ? "application/json; charset=utf-8" // Default for raw body + : "application/x-www-form-urlencoded; charset=utf-8"; // Default for params + + // --- Apply Custom Headers (and determine final Content-Type) --- + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + if (entry.getKey().equalsIgnoreCase("Content-Type")) { + contentType = entry.getValue(); // Use explicit content type } - } // End while loop - - if (!succeeded) { - MPLog.e(LOGTAG, "Could not complete request to " + endpointUrl + " after " + retries + " retries."); - // Optionally report final failure via listener here if desired, though individual errors were already reported - throw new IOException("Request failed after multiple retries."); // Indicate final failure + } + } + connection.setRequestProperty("Content-Type", contentType); + + // Apply proxy headers AFTER custom headers + if (interactor != null && isProxyRequest(endpointUrl)) { + /* ... Apply proxy headers ... */ + Map proxyHeaders = interactor.getProxyRequestHeaders(); + if (proxyHeaders != null) { + for (Map.Entry entry : proxyHeaders.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + } + } } - return response; // Can be null if a non-retriable HTTP error occurred - } + connection.setConnectTimeout(15000); + connection.setReadTimeout(60000); + + // --- Prepare and Write Body --- + byte[] bytesToWrite; + if (requestBodyBytes != null) { + // --- Use Raw Body --- + bytesToWrite = requestBodyBytes; + uncompressedBodySize = bytesToWrite.length; + connection.setFixedLengthStreamingMode(uncompressedBodySize); + MPLog.v(LOGTAG, "Sending raw body of size: " + uncompressedBodySize); + } else if (params != null) { + // --- Use URL Encoded Params --- + Uri.Builder builder = new Uri.Builder(); + for (Map.Entry param : params.entrySet()) { + builder.appendQueryParameter(param.getKey(), param.getValue().toString()); + } + String query = builder.build().getEncodedQuery(); + byte[] queryBytes = Objects.requireNonNull(query).getBytes(StandardCharsets.UTF_8); + uncompressedBodySize = queryBytes.length; + MPLog.v(LOGTAG, "Sending URL params (raw size): " + uncompressedBodySize); + + if (shouldGzipRequestPayload) { + // Apply GZIP specifically to the URL-encoded params + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (GZIPOutputStream gzipOut = new GZIPOutputStream(baos)) { + gzipOut.write(queryBytes); + } // try-with-resources ensures close + bytesToWrite = baos.toByteArray(); + compressedBodySize = bytesToWrite.length; + connection.setRequestProperty(CONTENT_ENCODING_HEADER, GZIP_CONTENT_TYPE_HEADER); + connection.setFixedLengthStreamingMode(compressedBodySize); + MPLog.v(LOGTAG, "Gzipping params, compressed size: " + compressedBodySize); + } else { + bytesToWrite = queryBytes; + connection.setFixedLengthStreamingMode(uncompressedBodySize); + } + } else { + // No body and no params + bytesToWrite = new byte[0]; + uncompressedBodySize = 0; + connection.setFixedLengthStreamingMode(0); + MPLog.v(LOGTAG, "Sending POST request with empty body."); + } + // Write the prepared bytes + out = new BufferedOutputStream(connection.getOutputStream()); + out.write(bytesToWrite); + out.flush(); + out.close(); // Close output stream before getting response + out = null; + + // --- Process Response --- + int responseCode = connection.getResponseCode(); + String responseMessage = connection.getResponseMessage(); // Get message for logging/errors + MPLog.v(LOGTAG, "Response Code: " + responseCode); + if (interactor != null && isProxyRequest(endpointUrl)) { + interactor.onProxyResponse(endpointUrl, responseCode); + } - private void onNetworkError(HttpURLConnection connection, String endpointUrl, String targetIpAddress, long startTimeNanos, long uncompressedBodySize, long compressedBodySize, Exception e) { - if (this.networkErrorListener != null) { - long endTimeNanos = System.nanoTime(); - long durationNanos = Math.max(0, endTimeNanos - startTimeNanos); - long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); - int responseCode = -1; - String responseMessage = ""; - if (connection != null) { - try { - responseCode = connection.getResponseCode(); - responseMessage = connection.getResponseMessage(); - } catch (Exception respExc) { - MPLog.w(LOGTAG, "Could not retrieve response code/message after error", respExc); - } - } - String ip = (targetIpAddress == null) ? "N/A" : targetIpAddress; - long finalUncompressedSize = Math.max(-1, uncompressedBodySize); - long finalCompressedSize = Math.max(-1, compressedBodySize); - try { - this.networkErrorListener.onNetworkError(endpointUrl, ip, durationMillis, finalUncompressedSize, finalCompressedSize, responseCode, responseMessage, e); - } catch(Exception listenerException) { - MPLog.e(LOGTAG, "Network error listener threw an exception", listenerException); + if (responseCode >= 200 && responseCode < 300) { // Success + in = connection.getInputStream(); + response = slurp(in); + succeeded = true; + } else if (responseCode >= MIN_UNAVAILABLE_HTTP_RESPONSE_CODE + && responseCode <= MAX_UNAVAILABLE_HTTP_RESPONSE_CODE) { // Server Error 5xx + // Report error via listener before throwing + onNetworkError( + connection, + endpointUrl, + targetIpAddress, + startTimeNanos, + uncompressedBodySize, + compressedBodySize, + new ServiceUnavailableException( + "Service Unavailable: " + responseCode, + connection.getHeaderField("Retry-After"))); + // Now throw the exception + throw new ServiceUnavailableException( + "Service Unavailable: " + responseCode, connection.getHeaderField("Retry-After")); + } else { // Other errors (4xx etc.) + MPLog.w( + LOGTAG, + "HTTP error " + responseCode + " (" + responseMessage + ") for URL: " + endpointUrl); + String errorBody = null; + try { + in = connection.getErrorStream(); + if (in != null) { + byte[] errorBytes = slurp(in); + errorBody = new String(errorBytes, StandardCharsets.UTF_8); + MPLog.w(LOGTAG, "Error Body: " + errorBody); } + } catch (Exception e) { + MPLog.w(LOGTAG, "Could not read error stream.", e); + } + // Report error via listener + onNetworkError( + connection, + endpointUrl, + targetIpAddress, + startTimeNanos, + uncompressedBodySize, + compressedBodySize, + new IOException( + "HTTP error response: " + + responseCode + + " " + + responseMessage + + (errorBody != null ? " - Body: " + errorBody : ""))); + response = null; // Indicate failure with null response + succeeded = true; // Mark as succeeded to stop retry loop for definitive HTTP errors } + + } catch (final EOFException e) { + // Report error BEFORE retry attempt + onNetworkError( + connection, + endpointUrl, + targetIpAddress, + startTimeNanos, + uncompressedBodySize, + compressedBodySize, + e); + MPLog.d(LOGTAG, "EOFException, likely network issue. Retrying request to " + endpointUrl); + retries++; + } catch (final IOException e) { // Includes ServiceUnavailableException if thrown above + // Report error via listener + onNetworkError( + connection, + endpointUrl, + targetIpAddress, + startTimeNanos, + uncompressedBodySize, + compressedBodySize, + e); + // Re-throw the original exception + throw e; + } catch (final Exception e) { // Catch any other unexpected exceptions + // Report error via listener + onNetworkError( + connection, + endpointUrl, + targetIpAddress, + startTimeNanos, + uncompressedBodySize, + compressedBodySize, + e); + // Wrap and re-throw as IOException? Or handle differently? + // Let's wrap in IOException for consistency with method signature. + throw new IOException("Unexpected exception during network request", e); + } finally { + // Clean up resources + if (null != out) + try { + out.close(); + } catch (final IOException e) { + /* ignore */ + } + if (null != in) + try { + in.close(); + } catch (final IOException e) { + /* ignore */ + } + if (null != connection) connection.disconnect(); + } + } // End while loop + + if (!succeeded) { + MPLog.e( + LOGTAG, + "Could not complete request to " + endpointUrl + " after " + retries + " retries."); + // Optionally report final failure via listener here if desired, though individual errors were + // already reported + throw new IOException("Request failed after multiple retries."); // Indicate final failure } - private OutputStream getBufferedOutputStream(OutputStream out) throws IOException { - if(shouldGzipRequestPayload) { - return new GZIPOutputStream(new BufferedOutputStream(out), HTTP_OUTPUT_STREAM_BUFFER_SIZE); - } else { - return new BufferedOutputStream(out); + return response; // Can be null if a non-retriable HTTP error occurred + } + + private void onNetworkError( + HttpURLConnection connection, + String endpointUrl, + String targetIpAddress, + long startTimeNanos, + long uncompressedBodySize, + long compressedBodySize, + Exception e) { + if (this.networkErrorListener != null) { + long endTimeNanos = System.nanoTime(); + long durationNanos = Math.max(0, endTimeNanos - startTimeNanos); + long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); + int responseCode = -1; + String responseMessage = ""; + if (connection != null) { + try { + responseCode = connection.getResponseCode(); + responseMessage = connection.getResponseMessage(); + } catch (Exception respExc) { + MPLog.w(LOGTAG, "Could not retrieve response code/message after error", respExc); } + } + String ip = (targetIpAddress == null) ? "N/A" : targetIpAddress; + long finalUncompressedSize = Math.max(-1, uncompressedBodySize); + long finalCompressedSize = Math.max(-1, compressedBodySize); + try { + this.networkErrorListener.onNetworkError( + endpointUrl, + ip, + durationMillis, + finalUncompressedSize, + finalCompressedSize, + responseCode, + responseMessage, + e); + } catch (Exception listenerException) { + MPLog.e(LOGTAG, "Network error listener threw an exception", listenerException); + } } + } - private static boolean isProxyRequest(String endpointUrl) { - return !endpointUrl.toLowerCase().contains(MIXPANEL_API.toLowerCase()); + private OutputStream getBufferedOutputStream(OutputStream out) throws IOException { + if (shouldGzipRequestPayload) { + return new GZIPOutputStream(new BufferedOutputStream(out), HTTP_OUTPUT_STREAM_BUFFER_SIZE); + } else { + return new BufferedOutputStream(out); } + } - private static byte[] slurp(final InputStream inputStream) - throws IOException { - final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + private static boolean isProxyRequest(String endpointUrl) { + return !endpointUrl.toLowerCase().contains(MIXPANEL_API.toLowerCase()); + } - int nRead; - byte[] data = new byte[8192]; + private static byte[] slurp(final InputStream inputStream) throws IOException { + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - while ((nRead = inputStream.read(data, 0, data.length)) != -1) { - buffer.write(data, 0, nRead); - } + int nRead; + byte[] data = new byte[8192]; - buffer.flush(); - return buffer.toByteArray(); + while ((nRead = inputStream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); } - private static final String LOGTAG = "MixpanelAPI.Message"; - private static final int HTTP_OUTPUT_STREAM_BUFFER_SIZE = 8192; - private static final String CONTENT_ENCODING_HEADER = "Content-Encoding"; - private static final String GZIP_CONTENT_TYPE_HEADER = "gzip"; -} + buffer.flush(); + return buffer.toByteArray(); + } + private static final String LOGTAG = "MixpanelAPI.Message"; + private static final int HTTP_OUTPUT_STREAM_BUFFER_SIZE = 8192; + private static final String CONTENT_ENCODING_HEADER = "Content-Encoding"; + private static final String GZIP_CONTENT_TYPE_HEADER = "gzip"; +} diff --git a/src/main/java/com/mixpanel/android/util/JsonUtils.java b/src/main/java/com/mixpanel/android/util/JsonUtils.java index 1ea098145..c4d2f0f6d 100644 --- a/src/main/java/com/mixpanel/android/util/JsonUtils.java +++ b/src/main/java/com/mixpanel/android/util/JsonUtils.java @@ -2,169 +2,177 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; - import com.mixpanel.android.mpmetrics.MixpanelFlagVariant; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; /** - * Utility class for JSON operations, particularly for handling arbitrary value types - * encountered in feature flags. + * Utility class for JSON operations, particularly for handling arbitrary value types encountered in + * feature flags. */ public class JsonUtils { - private static final String LOGTAG = "MixpanelAPI.JsonUtils"; // Re-use Mixpanel log tag convention - - /** - * Parses a JSON value obtained from org.json (like JSONObject.get() or JSONArray.get()) - * into a standard Java Object (String, Boolean, Number, {@code List}, {@code Map}, or null). - * Handles JSONObject.NULL correctly. - * - * @param jsonValue The object retrieved from org.json library. - * @return The corresponding standard Java object, or null if the input was JSONObject.NULL. - * @throws JSONException if the input is an unsupported type or if nested parsing fails. - */ - @Nullable - public static Object parseJsonValue(@Nullable Object jsonValue) throws JSONException { - if (jsonValue == null || jsonValue == JSONObject.NULL) { - return null; - } - - if (jsonValue instanceof Boolean || - jsonValue instanceof String || - jsonValue instanceof Integer || - jsonValue instanceof Long || - jsonValue instanceof Double || - jsonValue instanceof Float) { - // Primitives (including Numbers) are returned directly - return jsonValue; - } - // Handle numbers that might not be boxed primitives? (Shouldn't happen with org.json?) - if (jsonValue instanceof Number) { - return jsonValue; - } - - - if (jsonValue instanceof JSONObject) { - return jsonObjectToMap((JSONObject) jsonValue); - } - - if (jsonValue instanceof JSONArray) { - return jsonArrayToList((JSONArray) jsonValue); - } - - // If we got here, the type is unexpected - MPLog.w(LOGTAG, "Could not parse JSON value of type: " + jsonValue.getClass().getSimpleName()); - throw new JSONException("Unsupported JSON type encountered: " + jsonValue.getClass().getSimpleName()); + private static final String LOGTAG = + "MixpanelAPI.JsonUtils"; // Re-use Mixpanel log tag convention + + /** + * Parses a JSON value obtained from org.json (like JSONObject.get() or JSONArray.get()) into a + * standard Java Object (String, Boolean, Number, {@code List}, {@code Map}, or null). Handles JSONObject.NULL correctly. + * + * @param jsonValue The object retrieved from org.json library. + * @return The corresponding standard Java object, or null if the input was JSONObject.NULL. + * @throws JSONException if the input is an unsupported type or if nested parsing fails. + */ + @Nullable + public static Object parseJsonValue(@Nullable Object jsonValue) throws JSONException { + if (jsonValue == null || jsonValue == JSONObject.NULL) { + return null; } - /** - * Converts a JSONObject to a Map, recursively parsing nested values. - * - * @param jsonObject The JSONObject to convert. - * @return A Map representing the JSONObject. - * @throws JSONException if parsing fails. - */ - @NonNull - private static Map jsonObjectToMap(@NonNull JSONObject jsonObject) throws JSONException { - Map map = new HashMap<>(); - Iterator keys = jsonObject.keys(); - while (keys.hasNext()) { - String key = keys.next(); - Object value = jsonObject.get(key); - map.put(key, parseJsonValue(value)); // Recursively parse nested values - } - return map; + if (jsonValue instanceof Boolean + || jsonValue instanceof String + || jsonValue instanceof Integer + || jsonValue instanceof Long + || jsonValue instanceof Double + || jsonValue instanceof Float) { + // Primitives (including Numbers) are returned directly + return jsonValue; + } + // Handle numbers that might not be boxed primitives? (Shouldn't happen with org.json?) + if (jsonValue instanceof Number) { + return jsonValue; } - /** - * Converts a JSONArray to a List, recursively parsing nested values. - * - * @param jsonArray The JSONArray to convert. - * @return A List representing the JSONArray. - * @throws JSONException if parsing fails. - */ - @NonNull - private static List jsonArrayToList(@NonNull JSONArray jsonArray) throws JSONException { - List list = new ArrayList<>(jsonArray.length()); - for (int i = 0; i < jsonArray.length(); i++) { - Object value = jsonArray.get(i); - list.add(parseJsonValue(value)); // Recursively parse nested values - } - return list; + if (jsonValue instanceof JSONObject) { + return jsonObjectToMap((JSONObject) jsonValue); } + if (jsonValue instanceof JSONArray) { + return jsonArrayToList((JSONArray) jsonValue); + } - /** - * Parses the "flags" object from a /flags API response JSONObject. - * - * @param responseJson The root JSONObject from the API response. - * @return A Map where keys are feature flag names (String) and values are FeatureFlagData objects. - * Returns an empty map if parsing fails or the "flags" key is missing/invalid. - */ - @NonNull - public static Map parseFlagsResponse(@Nullable JSONObject responseJson) { - Map flagsMap = new HashMap<>(); - if (responseJson == null) { - MPLog.e(LOGTAG, "Cannot parse null flags response"); - return flagsMap; - } + // If we got here, the type is unexpected + MPLog.w(LOGTAG, "Could not parse JSON value of type: " + jsonValue.getClass().getSimpleName()); + throw new JSONException( + "Unsupported JSON type encountered: " + jsonValue.getClass().getSimpleName()); + } + + /** + * Converts a JSONObject to a Map, recursively parsing nested values. + * + * @param jsonObject The JSONObject to convert. + * @return A Map representing the JSONObject. + * @throws JSONException if parsing fails. + */ + @NonNull + private static Map jsonObjectToMap(@NonNull JSONObject jsonObject) + throws JSONException { + Map map = new HashMap<>(); + Iterator keys = jsonObject.keys(); + while (keys.hasNext()) { + String key = keys.next(); + Object value = jsonObject.get(key); + map.put(key, parseJsonValue(value)); // Recursively parse nested values + } + return map; + } + + /** + * Converts a JSONArray to a List, recursively parsing nested values. + * + * @param jsonArray The JSONArray to convert. + * @return A List representing the JSONArray. + * @throws JSONException if parsing fails. + */ + @NonNull + private static List jsonArrayToList(@NonNull JSONArray jsonArray) throws JSONException { + List list = new ArrayList<>(jsonArray.length()); + for (int i = 0; i < jsonArray.length(); i++) { + Object value = jsonArray.get(i); + list.add(parseJsonValue(value)); // Recursively parse nested values + } + return list; + } + + /** + * Parses the "flags" object from a /flags API response JSONObject. + * + * @param responseJson The root JSONObject from the API response. + * @return A Map where keys are feature flag names (String) and values are FeatureFlagData + * objects. Returns an empty map if parsing fails or the "flags" key is missing/invalid. + */ + @NonNull + public static Map parseFlagsResponse( + @Nullable JSONObject responseJson) { + Map flagsMap = new HashMap<>(); + if (responseJson == null) { + MPLog.e(LOGTAG, "Cannot parse null flags response"); + return flagsMap; + } - JSONObject flagsObject = null; + JSONObject flagsObject = null; + try { + if (responseJson.has(MPConstants.Flags.FLAGS_KEY) + && !responseJson.isNull(MPConstants.Flags.FLAGS_KEY)) { + flagsObject = responseJson.getJSONObject(MPConstants.Flags.FLAGS_KEY); + } else { + MPLog.w(LOGTAG, "Flags response JSON does not contain 'flags' key or it's null."); + return flagsMap; // No flags found + } + + Iterator keys = flagsObject.keys(); + while (keys.hasNext()) { + String featureName = keys.next(); try { - if (responseJson.has(MPConstants.Flags.FLAGS_KEY) && !responseJson.isNull(MPConstants.Flags.FLAGS_KEY)) { - flagsObject = responseJson.getJSONObject(MPConstants.Flags.FLAGS_KEY); - } else { - MPLog.w(LOGTAG, "Flags response JSON does not contain 'flags' key or it's null."); - return flagsMap; // No flags found - } - - Iterator keys = flagsObject.keys(); - while (keys.hasNext()) { - String featureName = keys.next(); - try { - if (flagsObject.isNull(featureName)) { - MPLog.w(LOGTAG, "Flag definition is null for key: " + featureName); - continue; // Skip null flag definitions - } - JSONObject flagDefinition = flagsObject.getJSONObject(featureName); - - String variantKey = null; - if (flagDefinition.has(MPConstants.Flags.VARIANT_KEY) && !flagDefinition.isNull(MPConstants.Flags.VARIANT_KEY)) { - variantKey = flagDefinition.getString(MPConstants.Flags.VARIANT_KEY); - } else { - MPLog.w(LOGTAG, "Flag definition missing 'variant_key' for key: " + featureName); - continue; // Skip flags without a variant key - } - - Object variantValue = null; - if (flagDefinition.has(MPConstants.Flags.VARIANT_VALUE)) { // Check presence before getting - Object rawValue = flagDefinition.get(MPConstants.Flags.VARIANT_VALUE); // Get raw value (could be JSONObject.NULL) - variantValue = parseJsonValue(rawValue); // Parse it properly - } else { - MPLog.w(LOGTAG, "Flag definition missing 'variant_value' for key: " + featureName + ". Assuming null value."); - } - - MixpanelFlagVariant flagData = new MixpanelFlagVariant(variantKey, variantValue); - flagsMap.put(featureName, flagData); - - } catch (JSONException e) { - MPLog.e(LOGTAG, "Error parsing individual flag definition for key: " + featureName, e); - // Continue parsing other flags - } - } + if (flagsObject.isNull(featureName)) { + MPLog.w(LOGTAG, "Flag definition is null for key: " + featureName); + continue; // Skip null flag definitions + } + JSONObject flagDefinition = flagsObject.getJSONObject(featureName); + + String variantKey = null; + if (flagDefinition.has(MPConstants.Flags.VARIANT_KEY) + && !flagDefinition.isNull(MPConstants.Flags.VARIANT_KEY)) { + variantKey = flagDefinition.getString(MPConstants.Flags.VARIANT_KEY); + } else { + MPLog.w(LOGTAG, "Flag definition missing 'variant_key' for key: " + featureName); + continue; // Skip flags without a variant key + } + + Object variantValue = null; + if (flagDefinition.has( + MPConstants.Flags.VARIANT_VALUE)) { // Check presence before getting + Object rawValue = + flagDefinition.get( + MPConstants.Flags.VARIANT_VALUE); // Get raw value (could be JSONObject.NULL) + variantValue = parseJsonValue(rawValue); // Parse it properly + } else { + MPLog.w( + LOGTAG, + "Flag definition missing 'variant_value' for key: " + + featureName + + ". Assuming null value."); + } + + MixpanelFlagVariant flagData = new MixpanelFlagVariant(variantKey, variantValue); + flagsMap.put(featureName, flagData); + } catch (JSONException e) { - MPLog.e(LOGTAG, "Error parsing outer 'flags' object in response", e); + MPLog.e(LOGTAG, "Error parsing individual flag definition for key: " + featureName, e); + // Continue parsing other flags } - - return flagsMap; + } + } catch (JSONException e) { + MPLog.e(LOGTAG, "Error parsing outer 'flags' object in response", e); } -} \ No newline at end of file + + return flagsMap; + } +} diff --git a/src/main/java/com/mixpanel/android/util/LegacyVersionUtils.java b/src/main/java/com/mixpanel/android/util/LegacyVersionUtils.java index d0f1578da..ecff8efd1 100644 --- a/src/main/java/com/mixpanel/android/util/LegacyVersionUtils.java +++ b/src/main/java/com/mixpanel/android/util/LegacyVersionUtils.java @@ -2,33 +2,35 @@ import java.io.File; - /** - * A utility class for handling migration from legacy versions, internal use only in the Mixpanel library. Not thread-safe. + * A utility class for handling migration from legacy versions, internal use only in the Mixpanel + * library. Not thread-safe. */ public class LegacyVersionUtils { - /** - * Remove the residual image files produced from legacy SDK versions 5.x and older (from Messages and Experiments features) - * - * @param fileOrDirectory - */ - public static void removeLegacyResidualImageFiles(File fileOrDirectory) { - try { - if (fileOrDirectory.isDirectory()) { - File[] files = fileOrDirectory.listFiles(); - if (files != null) { - for (File child : files) { - removeLegacyResidualImageFiles(child); - } - } - } - if (fileOrDirectory.getName().contains(DEFAULT_DIRECTORY_PREFIX) || fileOrDirectory.getName().contains(FILE_PREFIX)) { - fileOrDirectory.delete(); - } + /** + * Remove the residual image files produced from legacy SDK versions 5.x and older (from Messages + * and Experiments features) + * + * @param fileOrDirectory + */ + public static void removeLegacyResidualImageFiles(File fileOrDirectory) { + try { + if (fileOrDirectory.isDirectory()) { + File[] files = fileOrDirectory.listFiles(); + if (files != null) { + for (File child : files) { + removeLegacyResidualImageFiles(child); + } } - catch(Exception e) {} + } + if (fileOrDirectory.getName().contains(DEFAULT_DIRECTORY_PREFIX) + || fileOrDirectory.getName().contains(FILE_PREFIX)) { + fileOrDirectory.delete(); + } + } catch (Exception e) { } + } - private static final String FILE_PREFIX = "MP_IMG_"; - private static final String DEFAULT_DIRECTORY_PREFIX = "MixpanelAPI.Images."; + private static final String FILE_PREFIX = "MP_IMG_"; + private static final String DEFAULT_DIRECTORY_PREFIX = "MixpanelAPI.Images."; } diff --git a/src/main/java/com/mixpanel/android/util/MPConstants.java b/src/main/java/com/mixpanel/android/util/MPConstants.java index dbe336464..44d7dc65b 100644 --- a/src/main/java/com/mixpanel/android/util/MPConstants.java +++ b/src/main/java/com/mixpanel/android/util/MPConstants.java @@ -1,25 +1,24 @@ package com.mixpanel.android.util; -/** - * Mixpanel Constants - */ - +/** Mixpanel Constants */ public class MPConstants { - public static class SessionReplay { - public static final String REGISTER_ACTION = "com.mixpanel.properties.register"; - public static final String UNREGISTER_ACTION = "com.mixpanel.properties.unregister"; - public static final String REPLAY_ID_KEY = "$mp_replay_id"; - } - public static class URL { - public static final String MIXPANEL_API = "https://api.mixpanel.com"; - public static final String EVENT = "/track/"; - public static final String PEOPLE = "/engage/"; - public static final String GROUPS = "/groups/"; - public static final String FLAGS = "/flags/"; - } - public static class Flags { - public static final String FLAGS_KEY = "flags"; - public static final String VARIANT_KEY = "variant_key"; - public static final String VARIANT_VALUE = "variant_value"; - } + public static class SessionReplay { + public static final String REGISTER_ACTION = "com.mixpanel.properties.register"; + public static final String UNREGISTER_ACTION = "com.mixpanel.properties.unregister"; + public static final String REPLAY_ID_KEY = "$mp_replay_id"; + } + + public static class URL { + public static final String MIXPANEL_API = "https://api.mixpanel.com"; + public static final String EVENT = "/track/"; + public static final String PEOPLE = "/engage/"; + public static final String GROUPS = "/groups/"; + public static final String FLAGS = "/flags/"; + } + + public static class Flags { + public static final String FLAGS_KEY = "flags"; + public static final String VARIANT_KEY = "variant_key"; + public static final String VARIANT_VALUE = "variant_value"; + } } diff --git a/src/main/java/com/mixpanel/android/util/MPLog.java b/src/main/java/com/mixpanel/android/util/MPLog.java index 1cfe1546e..605382894 100644 --- a/src/main/java/com/mixpanel/android/util/MPLog.java +++ b/src/main/java/com/mixpanel/android/util/MPLog.java @@ -4,84 +4,84 @@ public class MPLog { - public static final int VERBOSE = 2; - public static final int DEBUG = 3; - public static final int INFO = 4; - public static final int WARN = 5; - public static final int ERROR = 6; - public static final int NONE = Integer.MAX_VALUE; - - private static int sMinLevel = WARN; - - public static void setLevel(int minLevel) { - sMinLevel = minLevel; - } - - public static int getLevel() { - return sMinLevel; + public static final int VERBOSE = 2; + public static final int DEBUG = 3; + public static final int INFO = 4; + public static final int WARN = 5; + public static final int ERROR = 6; + public static final int NONE = Integer.MAX_VALUE; + + private static int sMinLevel = WARN; + + public static void setLevel(int minLevel) { + sMinLevel = minLevel; + } + + public static int getLevel() { + return sMinLevel; + } + + public static void v(String tag, String message) { + if (shouldLog(VERBOSE)) { + Log.v(tag, message); } + } - public static void v(String tag, String message) { - if (shouldLog(VERBOSE)) { - Log.v(tag, message); - } + public static void v(String tag, String message, Throwable throwable) { + if (shouldLog(VERBOSE)) { + Log.v(tag, message, throwable); } + } - public static void v(String tag, String message, Throwable throwable) { - if (shouldLog(VERBOSE)) { - Log.v(tag, message, throwable); - } + public static void d(String tag, String message) { + if (shouldLog(DEBUG)) { + Log.d(tag, message); } + } - public static void d(String tag, String message) { - if (shouldLog(DEBUG)) { - Log.d(tag, message); - } + public static void d(String tag, String message, Throwable throwable) { + if (shouldLog(DEBUG)) { + Log.d(tag, message, throwable); } + } - public static void d(String tag, String message, Throwable throwable) { - if (shouldLog(DEBUG)) { - Log.d(tag, message, throwable); - } + public static void i(String tag, String message) { + if (shouldLog(INFO)) { + Log.i(tag, message); } + } - public static void i(String tag, String message) { - if (shouldLog(INFO)) { - Log.i(tag, message); - } + public static void i(String tag, String message, Throwable throwable) { + if (shouldLog(INFO)) { + Log.i(tag, message, throwable); } + } - public static void i(String tag, String message, Throwable throwable) { - if (shouldLog(INFO)) { - Log.i(tag, message, throwable); - } + public static void w(String tag, String message) { + if (shouldLog(WARN)) { + Log.w(tag, message); } + } - public static void w(String tag, String message) { - if (shouldLog(WARN)) { - Log.w(tag, message); - } + public static void w(String tag, String message, Throwable throwable) { + if (shouldLog(WARN)) { + Log.w(tag, message, throwable); } + } - public static void w(String tag, String message, Throwable throwable) { - if (shouldLog(WARN)) { - Log.w(tag, message, throwable); - } + public static void e(String tag, String message) { + if (shouldLog(ERROR)) { + Log.e(tag, message); } + } - public static void e(String tag, String message) { - if (shouldLog(ERROR)) { - Log.e(tag, message); - } + public static void e(String tag, String message, Throwable throwable) { + if (shouldLog(ERROR)) { + Log.e(tag, message, throwable); } + } - public static void e(String tag, String message, Throwable throwable) { - if (shouldLog(ERROR)) { - Log.e(tag, message, throwable); - } - } - - private static boolean shouldLog(int level) { - return sMinLevel <= level; - } + private static boolean shouldLog(int level) { + return sMinLevel <= level; + } } diff --git a/src/main/java/com/mixpanel/android/util/MixpanelNetworkErrorListener.java b/src/main/java/com/mixpanel/android/util/MixpanelNetworkErrorListener.java index 2d5dee0d7..6e8d021e7 100644 --- a/src/main/java/com/mixpanel/android/util/MixpanelNetworkErrorListener.java +++ b/src/main/java/com/mixpanel/android/util/MixpanelNetworkErrorListener.java @@ -1,23 +1,33 @@ package com.mixpanel.android.util; public interface MixpanelNetworkErrorListener { - /** - * Called when a network request within the Mixpanel SDK fails. - * This method may be called on a background thread. - * - * @param endpointUrl The URL that failed. - * @param ipAddress The IP address resolved from the endpointUrl's hostname for this attempt (may be "N/A" if DNS lookup failed). - * @param durationMillis The approximate duration in milliseconds from the start of this specific connection attempt until the exception was thrown. - * @param uncompressedBodySize The size in bytes of the request body *before* any compression. - * Will be -1 if no body was sent. - * @param compressedBodySize The size in bytes of the request body *after* Gzip compression. - * Will be -1 if no body was sent or if compression was not enabled - * (in which case uncompressed size applies). - * @param responseCode The HTTP response code returned by the server, if available. - * Defaults to -1 if no response code could be retrieved (e.g., connection error). - * @param responseMessage The HTTP response message returned by the server, if available. - * Defaults to empty string if no response message could be retrieved. - * @param exception The exception that occurred (e.g., IOException, EOFException, etc.). - */ - void onNetworkError(String endpointUrl, String ipAddress, long durationMillis, long uncompressedBodySize, long compressedBodySize, int responseCode, String responseMessage, Exception exception); + /** + * Called when a network request within the Mixpanel SDK fails. This method may be called on a + * background thread. + * + * @param endpointUrl The URL that failed. + * @param ipAddress The IP address resolved from the endpointUrl's hostname for this attempt (may + * be "N/A" if DNS lookup failed). + * @param durationMillis The approximate duration in milliseconds from the start of this specific + * connection attempt until the exception was thrown. + * @param uncompressedBodySize The size in bytes of the request body *before* any compression. + * Will be -1 if no body was sent. + * @param compressedBodySize The size in bytes of the request body *after* Gzip compression. Will + * be -1 if no body was sent or if compression was not enabled (in which case uncompressed + * size applies). + * @param responseCode The HTTP response code returned by the server, if available. Defaults to -1 + * if no response code could be retrieved (e.g., connection error). + * @param responseMessage The HTTP response message returned by the server, if available. Defaults + * to empty string if no response message could be retrieved. + * @param exception The exception that occurred (e.g., IOException, EOFException, etc.). + */ + void onNetworkError( + String endpointUrl, + String ipAddress, + long durationMillis, + long uncompressedBodySize, + long compressedBodySize, + int responseCode, + String responseMessage, + Exception exception); } diff --git a/src/main/java/com/mixpanel/android/util/OfflineMode.java b/src/main/java/com/mixpanel/android/util/OfflineMode.java index cd1d04e2f..1071f8420 100644 --- a/src/main/java/com/mixpanel/android/util/OfflineMode.java +++ b/src/main/java/com/mixpanel/android/util/OfflineMode.java @@ -1,16 +1,13 @@ package com.mixpanel.android.util; -/** - * Implement this to allow Mixpanel behave in-sync with your current custom offline logic - */ +/** Implement this to allow Mixpanel behave in-sync with your current custom offline logic */ public interface OfflineMode { - /** - * Returns true if offline-mode is active on the client. When true Mixpanel will not start - * new connections, but current active connections will not be interrupted. - * - * @return true if offline mode is active, false otherwise - */ - boolean isOffline(); - + /** + * Returns true if offline-mode is active on the client. When true Mixpanel will not start new + * connections, but current active connections will not be interrupted. + * + * @return true if offline mode is active, false otherwise + */ + boolean isOffline(); } diff --git a/src/main/java/com/mixpanel/android/util/ProxyServerInteractor.java b/src/main/java/com/mixpanel/android/util/ProxyServerInteractor.java index 98eb0e810..04d9a2ffd 100644 --- a/src/main/java/com/mixpanel/android/util/ProxyServerInteractor.java +++ b/src/main/java/com/mixpanel/android/util/ProxyServerInteractor.java @@ -3,7 +3,7 @@ import java.util.Map; public interface ProxyServerInteractor { - Map getProxyRequestHeaders(); + Map getProxyRequestHeaders(); - void onProxyResponse(String apiPath, int responseCode); + void onProxyResponse(String apiPath, int responseCode); } diff --git a/src/main/java/com/mixpanel/android/util/RemoteService.java b/src/main/java/com/mixpanel/android/util/RemoteService.java index 13758936d..33a0abe49 100644 --- a/src/main/java/com/mixpanel/android/util/RemoteService.java +++ b/src/main/java/com/mixpanel/android/util/RemoteService.java @@ -1,61 +1,60 @@ package com.mixpanel.android.util; import android.content.Context; - - import androidx.annotation.NonNull; import androidx.annotation.Nullable; - import java.io.IOException; import java.util.Map; - import javax.net.ssl.SSLSocketFactory; - public interface RemoteService { - boolean isOnline(Context context, OfflineMode offlineMode); - - void checkIsMixpanelBlocked(); - - /** - * Performs an HTTP POST request. Handles either URL-encoded parameters OR a raw byte request body. - * - * @param endpointUrl The target URL. - * @param interactor Optional proxy interactor. - * @param params URL parameters to be URL-encoded and sent (used if requestBodyBytes is null). - * @param headers Optional map of custom headers (e.g., Authorization, Content-Type). - * @param requestBodyBytes Optional raw byte array for the request body. If non-null, this is sent directly, - * and the 'params' map is ignored for the body content. Ensure Content-Type header is set. - * @param socketFactory Optional custom SSLSocketFactory. - * @return The response body as a byte array, or null if the request failed with a non-retriable HTTP error code. - * @throws ServiceUnavailableException If the server returned a 5xx error with a Retry-After header. - * @throws IOException For network errors or non-5xx HTTP errors where reading failed. - */ - byte[] performRequest( - @NonNull String endpointUrl, - @Nullable ProxyServerInteractor interactor, - @Nullable Map params, // Used only if requestBodyBytes is null - @Nullable Map headers, - @Nullable byte[] requestBodyBytes, // If provided, send this as raw body - @Nullable SSLSocketFactory socketFactory) - throws ServiceUnavailableException, IOException; - - class ServiceUnavailableException extends Exception { - public ServiceUnavailableException(String message, String strRetryAfter) { - super(message); - int retry; - try { - retry = Integer.parseInt(strRetryAfter); - } catch (NumberFormatException e) { - retry = 0; - } - mRetryAfter = retry; - } - - public int getRetryAfter() { - return mRetryAfter; - } + boolean isOnline(Context context, OfflineMode offlineMode); + + void checkIsMixpanelBlocked(); + + /** + * Performs an HTTP POST request. Handles either URL-encoded parameters OR a raw byte request + * body. + * + * @param endpointUrl The target URL. + * @param interactor Optional proxy interactor. + * @param params URL parameters to be URL-encoded and sent (used if requestBodyBytes is null). + * @param headers Optional map of custom headers (e.g., Authorization, Content-Type). + * @param requestBodyBytes Optional raw byte array for the request body. If non-null, this is sent + * directly, and the 'params' map is ignored for the body content. Ensure Content-Type header + * is set. + * @param socketFactory Optional custom SSLSocketFactory. + * @return The response body as a byte array, or null if the request failed with a non-retriable + * HTTP error code. + * @throws ServiceUnavailableException If the server returned a 5xx error with a Retry-After + * header. + * @throws IOException For network errors or non-5xx HTTP errors where reading failed. + */ + byte[] performRequest( + @NonNull String endpointUrl, + @Nullable ProxyServerInteractor interactor, + @Nullable Map params, // Used only if requestBodyBytes is null + @Nullable Map headers, + @Nullable byte[] requestBodyBytes, // If provided, send this as raw body + @Nullable SSLSocketFactory socketFactory) + throws ServiceUnavailableException, IOException; + + class ServiceUnavailableException extends Exception { + public ServiceUnavailableException(String message, String strRetryAfter) { + super(message); + int retry; + try { + retry = Integer.parseInt(strRetryAfter); + } catch (NumberFormatException e) { + retry = 0; + } + mRetryAfter = retry; + } - private final int mRetryAfter; + public int getRetryAfter() { + return mRetryAfter; } + + private final int mRetryAfter; + } }