package com.mixpanel.android.mpmetrics; import android.content.Context; import android.content.SharedPreferences; import android.graphics.Bitmap; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.HandlerThread; import android.test.AndroidTestCase; import com.mixpanel.android.util.HttpService; import com.mixpanel.android.util.ImageStore; import com.mixpanel.android.util.RemoteService; import com.mixpanel.android.viewcrawler.UpdatesFromMixpanel; import org.json.JSONArray; import org.json.JSONObject; import java.io.ByteArrayOutputStream; import java.net.URLEncoder; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.regex.Pattern; import javax.net.ssl.SSLSocketFactory; public class DecideFunctionalTest extends AndroidTestCase { public void setUp() throws InterruptedException { final SharedPreferences referrerPreferences = getContext().getSharedPreferences("MIXPANEL_TEST_PREFERENCES", Context.MODE_PRIVATE); SharedPreferences.Editor editor = referrerPreferences.edit(); editor.clear(); editor.commit(); mMockPreferences = new Future<SharedPreferences>() { @Override public boolean cancel(final boolean mayInterruptIfRunning) { return false; } @Override public boolean isCancelled() { return false; } @Override public boolean isDone() { return false; } @Override public SharedPreferences get() throws InterruptedException, ExecutionException { return referrerPreferences; } @Override public SharedPreferences get(final long timeout, final TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { return referrerPreferences; } }; mExpectations = new Expectations(); mMockPoster = new HttpService() { @Override public byte[] performRequest(String endpointUrl, Map<String, Object> params, SSLSocketFactory socketFactory) { return mExpectations.setExpectationsRequest(endpointUrl, params); } }; mMockConfig = new MPConfig(new Bundle(), getContext()) { @Override public boolean getAutoShowMixpanelUpdates() { return false; } }; mMockMessages = new AnalyticsMessages(getContext()) { @Override protected RemoteService getPoster() { return mMockPoster; } @Override protected MPConfig getConfig(Context context) { return mMockConfig; } // this is to pass the mock poster to image store @Override protected Worker createWorker() { return new Worker() { @Override protected Handler restartWorkerThread() { final HandlerThread thread = new HandlerThread("com.mixpanel.android.AnalyticsWorker", Thread.MIN_PRIORITY); thread.start(); final Handler ret = new AnalyticsMessageHandler(thread.getLooper()) { @Override protected DecideChecker createDecideChecker() { return new DecideChecker(mContext, mConfig, new SystemInformation(mContext)) { @Override protected ImageStore createImageStore(final Context context) { return new ImageStore(context, "MixpanelAPI.Images.DecideChecker", mMockPoster); } }; } }; return ret; } }; } }; try { SystemInformation systemInformation = new SystemInformation(mContext); 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) {} } public void testDecideChecks() { // Should not make any requests on construction if the user has not been identified mExpectations.expect("ALWAYS WRONG", "ALWAYS WRONG"); MixpanelAPI api = new TestUtils.CleanMixpanelAPI(getContext(), mMockPreferences, "TEST TOKEN") { @Override AnalyticsMessages getAnalyticsMessages() { return mMockMessages; } @Override UpdatesFromMixpanel constructUpdatesFromMixpanel(final Context context, final String token) { return new MockUpdates(); } @Override DecideMessages constructDecideUpdates(String token, DecideMessages.OnNewResultsListener listener, UpdatesFromMixpanel binder) { return new MockMessages(token, listener, binder); } }; // Should make a request on identify mExpectations.expect( "https://decide.mixpanel.com/decide?version=1&lib=android&token=TEST+TOKEN&distinct_id=DECIDE+CHECKS+ID+1" + mAppProperties, "{" + "\"notifications\":[{\"id\": 119911, \"message_id\": 4321, \"type\": \"takeover\", \"body\": \"Hook me up, yo!\", \"body_color\": 4294901760, \"title\": null, \"title_color\": 4278255360, \"image_url\": \"http://mixpanel.com/Balok.jpg\", \"bg_color\": 3909091328, \"close_color\": 4294967295, \"extras\": {\"image_fade\": true},\"buttons\": [{\"text\": \"Button!\", \"text_color\": 4278190335, \"bg_color\": 4294967040, \"border_color\": 4278255615, \"cta_url\": \"hellomixpanel://deeplink/howareyou\"}, {\"text\": \"Button 2!\", \"text_color\": 4278190335, \"bg_color\": 4294967040, \"border_color\": 4278255615, \"cta_url\": \"hellomixpanel://deeplink/howareyou\"}]}]," + "\"event_bindings\": [{\"event_name\":\"EVENT NAME\",\"path\":[{\"index\":0,\"view_class\":\"com.android.internal.policy.impl.PhoneWindow.DecorView\"},{\"index\":0,\"view_class\":\"com.android.internal.widget.ActionBarOverlayLayout\"},{\"index\":0,\"view_class\":\"com.android.internal.widget.ActionBarContainer\"}],\"target_activity\":\"ACTIVITY\",\"event_type\":\"EVENT TYPE\"}]" + "}" ); api.getPeople().identify("DECIDE CHECKS ID 1"); mExpectations.checkExpectations(); // We should be done, and Updates should have our goodies waiting { final InAppNotification shouldExistNotification = api.getPeople().getNotificationIfAvailable(); assertEquals(shouldExistNotification.getId(), 119911); } assertNull(api.getPeople().getNotificationIfAvailable()); // We should run a new check on every flush (right before the flush) mExpectations.expect( "https://decide.mixpanel.com/decide?version=1&lib=android&token=TEST+TOKEN&distinct_id=DECIDE+CHECKS+ID+1" + mAppProperties, "{" + "\"notifications\":[{\"id\": 3333, \"message_id\": 4321, \"type\": \"takeover\", \"body\": \"Hook me up, yo!\", \"body_color\": 4294901760, \"title\": null, \"title_color\": 4278255360, \"image_url\": \"http://mixpanel.com/Balok.jpg\", \"bg_color\": 3909091328, \"close_color\": 4294967295, \"extras\": {\"image_fade\": true},\"buttons\": [{\"text\": \"Button!\", \"text_color\": 4278190335, \"bg_color\": 4294967040, \"border_color\": 4278255615, \"cta_url\": \"hellomixpanel://deeplink/howareyou\"}, {\"text\": \"Button 2!\", \"text_color\": 4278190335, \"bg_color\": 4294967040, \"border_color\": 4278255615, \"cta_url\": \"hellomixpanel://deeplink/howareyou\"}]}]," + "\"event_bindings\": [{\"event_name\":\"EVENT NAME\",\"path\":[{\"index\":0,\"view_class\":\"com.android.internal.policy.impl.PhoneWindow.DecorView\"},{\"index\":0,\"view_class\":\"com.android.internal.widget.ActionBarOverlayLayout\"},{\"index\":0,\"view_class\":\"com.android.internal.widget.ActionBarContainer\"}],\"target_activity\":\"ACTIVITY\",\"event_type\":\"EVENT TYPE\"}]" + "}" ); api.flush(); mExpectations.checkExpectations(); { final InAppNotification shouldExistNotification = api.getPeople().getNotificationIfAvailable(); assertEquals(shouldExistNotification.getId(), 3333); } assertNull(api.getPeople().getNotificationIfAvailable()); // We should check, but IGNORE repeated objects when we see them come through mExpectations.expect( "https://decide.mixpanel.com/decide?version=1&lib=android&token=TEST+TOKEN&distinct_id=DECIDE+CHECKS+ID+1" + mAppProperties, "{" + "\"notifications\":[{\"id\": 119911, \"message_id\": 4321, \"type\": \"takeover\", \"body\": \"Hook me up, yo!\", \"body_color\": 4294901760, \"title\": null, \"title_color\": 4278255360, \"image_url\": \"http://mixpanel.com/Balok.jpg\", \"bg_color\": 3909091328, \"close_color\": 4294967295, \"extras\": {\"image_fade\": true},\"buttons\": [{\"text\": \"Button!\", \"text_color\": 4278190335, \"bg_color\": 4294967040, \"border_color\": 4278255615, \"cta_url\": \"hellomixpanel://deeplink/howareyou\"}, {\"text\": \"Button 2!\", \"text_color\": 4278190335, \"bg_color\": 4294967040, \"border_color\": 4278255615, \"cta_url\": \"hellomixpanel://deeplink/howareyou\"}]}]," + "\"event_bindings\": [{\"event_name\":\"EVENT NAME\",\"path\":[{\"index\":0,\"view_class\":\"com.android.internal.policy.impl.PhoneWindow.DecorView\"},{\"index\":0,\"view_class\":\"com.android.internal.widget.ActionBarOverlayLayout\"},{\"index\":0,\"view_class\":\"com.android.internal.widget.ActionBarContainer\"}],\"target_activity\":\"ACTIVITY\",\"event_type\":\"EVENT TYPE\"}]" + "}" ); api.flush(); mExpectations.checkExpectations(); assertNull(api.getPeople().getNotificationIfAvailable()); // Seen never changes, even if we re-identify mExpectations.expect( "https://decide.mixpanel.com/decide?version=1&lib=android&token=TEST+TOKEN&distinct_id=DECIDE+CHECKS+ID+2" + mAppProperties, "{" + "\"notifications\":[{\"id\": 119911, \"message_id\": 4321, \"type\": \"takeover\", \"body\": \"Hook me up, yo!\", \"body_color\": 4294901760, \"title\": null, \"title_color\": 4278255360, \"image_url\": \"http://mixpanel.com/Balok.jpg\", \"bg_color\": 3909091328, \"close_color\": 4294967295, \"extras\": {\"image_fade\": true},\"buttons\": [{\"text\": \"Button!\", \"text_color\": 4278190335, \"bg_color\": 4294967040, \"border_color\": 4278255615, \"cta_url\": \"hellomixpanel://deeplink/howareyou\"}, {\"text\": \"Button 2!\", \"text_color\": 4278190335, \"bg_color\": 4294967040, \"border_color\": 4278255615, \"cta_url\": \"hellomixpanel://deeplink/howareyou\"}]}]," + "\"event_bindings\": [{\"event_name\":\"EVENT NAME\",\"path\":[{\"index\":0,\"view_class\":\"com.android.internal.policy.impl.PhoneWindow.DecorView\"},{\"index\":0,\"view_class\":\"com.android.internal.widget.ActionBarOverlayLayout\"},{\"index\":0,\"view_class\":\"com.android.internal.widget.ActionBarContainer\"}],\"target_activity\":\"ACTIVITY\",\"event_type\":\"EVENT TYPE\"}]" + "}" ); api.getPeople().identify("DECIDE CHECKS ID 2"); api.flush(); mExpectations.checkExpectations(); assertNull(api.getPeople().getNotificationIfAvailable()); } public void testDecideChecksOnConstruction() { final String useToken = "TEST IDENTIFIED ON CONSTRUCTION"; final String prefsName = "com.mixpanel.android.mpmetrics.MixpanelAPI_" + useToken; final SharedPreferences ret = getContext().getSharedPreferences(prefsName, Context.MODE_PRIVATE); final SharedPreferences.Editor editor = ret.edit(); editor.putString("people_distinct_id", "Present Before Construction"); editor.commit(); // We should run a check on construction if we are constructed with a people distinct id mExpectations.expect( "https://decide.mixpanel.com/decide?version=1&lib=android&token=TEST+IDENTIFIED+ON+CONSTRUCTION&distinct_id=Present+Before+Construction" + mAppProperties, "{" + "\"notifications\":[{\"id\": 3333, \"message_id\": 4321, \"type\": \"takeover\", \"body\": \"Hook me up, yo!\", \"body_color\": 4294901760, \"title\": null, \"title_color\": 4278255360, \"image_url\": \"http://mixpanel.com/Balok.jpg\", \"bg_color\": 3909091328, \"close_color\": 4294967295, \"extras\": {\"image_fade\": true},\"buttons\": [{\"text\": \"Button!\", \"text_color\": 4278190335, \"bg_color\": 4294967040, \"border_color\": 4278255615, \"cta_url\": \"hellomixpanel://deeplink/howareyou\"}, {\"text\": \"Button 2!\", \"text_color\": 4278190335, \"bg_color\": 4294967040, \"border_color\": 4278255615, \"cta_url\": \"hellomixpanel://deeplink/howareyou\"}]}]," + "\"event_bindings\": [{\"event_name\":\"EVENT NAME\",\"path\":[{\"index\":0,\"view_class\":\"com.android.internal.policy.impl.PhoneWindow.DecorView\"},{\"index\":0,\"view_class\":\"com.android.internal.widget.ActionBarOverlayLayout\"},{\"index\":0,\"view_class\":\"com.android.internal.widget.ActionBarContainer\"}],\"target_activity\":\"ACTIVITY\",\"event_type\":\"EVENT TYPE\"}]" + "}" ); MixpanelAPI api = new MixpanelAPI(getContext(), mMockPreferences, useToken) { @Override AnalyticsMessages getAnalyticsMessages() { return mMockMessages; } @Override DecideMessages constructDecideUpdates(String token, DecideMessages.OnNewResultsListener listener, UpdatesFromMixpanel binder) { return new MockMessages(token, listener, binder); } @Override boolean sendAppOpen() { return false; } }; mExpectations.checkExpectations(); final InAppNotification foundNotification = api.getPeople().getNotificationIfAvailable(); assertEquals(foundNotification.getId(), 3333); } private static class Expectations { public Expectations() { final ByteArrayOutputStream imageStream = new ByteArrayOutputStream(); final Bitmap.Config conf = Bitmap.Config.ARGB_8888; final Bitmap testBitmap = Bitmap.createBitmap(100, 100, conf); testBitmap.compress(Bitmap.CompressFormat.JPEG, 50, imageStream); imageBytes = imageStream.toByteArray(); } public synchronized void expect(String url, String response) { mExpectUrl = url; mResponse = response; badUrl = null; badParams = null; mResultsFound = false; resultsBad = false; } public void checkExpectations() { final long startWaiting = System.currentTimeMillis(); final long timeout = 1000; while (true) { try { synchronized (this) { if (mResultsFound) { if (resultsBad) { fail("Unexpected URL " + badUrl + " in MixpanelAPI (expected " + mExpectUrl + ")\n" + "Got params " + badParams); } break; } this.wait(timeout); } } catch (InterruptedException e) { ; // Next iteration } if (startWaiting + (2 * timeout) < System.currentTimeMillis()) { fail("Test timed out waiting on expectation " + this); break; } } } public synchronized byte[] setExpectationsRequest(final String endpointUrl, Map<String, Object> params) { if (endpointUrl.equals(mExpectUrl)) { return TestUtils.bytes(mResponse); } else if (Pattern.matches("^http://mixpanel.com/Balok.{0,3}\\.jpg$", endpointUrl)) { return imageBytes; } else { badUrl = endpointUrl; badParams = params; resultsBad = true; return "{}".getBytes(); } } public synchronized void resolve() { mResultsFound = true; this.notify(); } public synchronized String toString() { return "Expectations(" + mExpectUrl + ", " + mResponse + ", " + mResultsFound + ")"; } private String mExpectUrl = null; private String mResponse = null; private String badUrl = null; private Map<String, Object> badParams = null; private boolean mResultsFound = false; private boolean resultsBad = false; private byte[] imageBytes; } private class MockMessages extends DecideMessages { public MockMessages(final String token, final OnNewResultsListener listener, final UpdatesFromMixpanel binder) { super(token, listener, binder); } @Override public void reportResults(List<InAppNotification> newNotifications, JSONArray newBindings, JSONArray variants) { super.reportResults(newNotifications, newBindings, variants); mExpectations.resolve(); } } private class MockUpdates implements UpdatesFromMixpanel { @Override public void startUpdates() { ; } @Override public void setEventBindings(JSONArray bindings) { ; // TODO we need to test that (possibly empty, never null) bindings come through } @Override public void setVariants(JSONArray variants) { ; } @Override public Tweaks getTweaks() { return null; } @Override public void addOnMixpanelTweaksUpdatedListener(OnMixpanelTweaksUpdatedListener listener) { } @Override public void removeOnMixpanelTweaksUpdatedListener(OnMixpanelTweaksUpdatedListener listener) { } } private MPConfig mMockConfig; private Future<SharedPreferences> mMockPreferences; private Expectations mExpectations; private RemoteService mMockPoster; private AnalyticsMessages mMockMessages; private String mAppProperties; }