diff --git a/build.gradle b/build.gradle index 1daf48b..6d8e20a 100755 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,8 @@ dependencies { androidTestImplementation 'com.android.support.test:runner:1.0.2' androidTestImplementation 'com.android.support.test:rules:1.0.2' + androidTestImplementation 'com.linkedin.dexmaker:dexmaker-mockito:2.25.0' + androidTestImplementation 'junit:junit:4.12' } diff --git a/src/androidTest/java/com/segment/analytics/android/integrations/appboy/AppboyAndroidTest.java b/src/androidTest/java/com/segment/analytics/android/integrations/appboy/AppboyAndroidTest.java index 06c974b..04ed943 100644 --- a/src/androidTest/java/com/segment/analytics/android/integrations/appboy/AppboyAndroidTest.java +++ b/src/androidTest/java/com/segment/analytics/android/integrations/appboy/AppboyAndroidTest.java @@ -33,7 +33,8 @@ public void testIdentifyCallsChangeUser() { Traits traits = createTraits(testUserId); IdentifyPayload identifyPayload = new IdentifyPayloadBuilder().traits(traits).build(); Logger logger = Logger.with(Analytics.LogLevel.DEBUG); - AppboyIntegration integration = new AppboyIntegration(Appboy.getInstance(getContext()), "foo", logger, true, new DefaultUserIdMapper()); + AppboyIntegration integration = new AppboyIntegration(Appboy.getInstance(getContext()), "foo", logger, true, + new PreferencesTraitsCache(getContext()), new DefaultUserIdMapper()); integration.identify(identifyPayload); assertEquals(testUserId, Appboy.getInstance(getContext()).getCurrentUser().getUserId()); diff --git a/src/androidTest/java/com/segment/analytics/android/integrations/appboy/AppboyIntegrationOptionsAndroidTest.java b/src/androidTest/java/com/segment/analytics/android/integrations/appboy/AppboyIntegrationOptionsAndroidTest.java index 4c30082..39294b1 100644 --- a/src/androidTest/java/com/segment/analytics/android/integrations/appboy/AppboyIntegrationOptionsAndroidTest.java +++ b/src/androidTest/java/com/segment/analytics/android/integrations/appboy/AppboyIntegrationOptionsAndroidTest.java @@ -2,25 +2,47 @@ import android.support.test.runner.AndroidJUnit4; import com.appboy.Appboy; +import com.appboy.AppboyUser; import com.appboy.configuration.AppboyConfig; import com.segment.analytics.Analytics; import com.segment.analytics.Traits; import com.segment.analytics.integrations.IdentifyPayload; import com.segment.analytics.integrations.Logger; import com.segment.analytics.test.IdentifyPayloadBuilder; +import org.junit.After; +import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; import static android.support.test.InstrumentationRegistry.getContext; import static com.segment.analytics.Utils.createTraits; -import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @RunWith(AndroidJUnit4.class) public class AppboyIntegrationOptionsAndroidTest { - private static final String ORIGINAL_USER_ID = "testUser"; + private static final String USER_ID = "testUser"; + private static final String OTHER_USER_ID = "testUser2"; private static final String TRANSFORMED_USER_ID = "transformedUser"; + private static final String TRAIT_EMAIL = "test@segment.com"; + private static final String TRAIT_EMAIL_UPDATED = "updated@segment.com"; + private static final String TRAIT_CITY = "city"; + private static final String TRAIT_COUNTRY = "country"; + private static final String CUSTOM_TRAIT_KEY = "custom_trait_key"; + private static final String CUSTOM_KEY_VALUE = "custom_key_value"; + + @Mock Appboy appboy; + @Mock AppboyUser appboyUser; + + private AppboyIntegration appboyIntegration; + private PreferencesTraitsCache traitsCache; @BeforeClass public static void beforeClass() { @@ -28,24 +50,124 @@ public static void beforeClass() { Appboy.configure(getContext(), appboyConfig); } + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + + when(appboy.getCurrentUser()).thenReturn(appboyUser); + + Logger logger = Logger.with(Analytics.LogLevel.DEBUG); + + traitsCache = new PreferencesTraitsCache(getContext()); + + appboyIntegration = new AppboyIntegration( + appboy, "foo", logger, true, + traitsCache, new ReplaceUserIdMapper()); + } + + @After + public void tearDown() throws Exception { + traitsCache.clear(); + } + @Test public void testUserIdMapperTransformsAppboyUserId() { - Traits traits = createTraits(ORIGINAL_USER_ID); - IdentifyPayload identifyPayload = new IdentifyPayloadBuilder().traits(traits).build(); + Traits traits = createTraits(USER_ID); - Logger logger = Logger.with(Analytics.LogLevel.DEBUG); + callIdentifyWithTraits(traits); + + verify(appboy).changeUser(TRANSFORMED_USER_ID); + } + + @Test + public void testShouldNotTriggerAppboyUpdateIfTraitDoesntChange() { + Traits traits = createTraits(USER_ID); + + Traits.Address address = new Traits.Address(); + address.putCity(TRAIT_CITY); + address.putCountry(TRAIT_COUNTRY); - AppboyIntegration integration = new AppboyIntegration(Appboy.getInstance(getContext()), "foo", logger, true, new ReplaceUserIdMapper()); + traits.putAddress(address); + traits.putEmail(TRAIT_EMAIL); + traits.put(CUSTOM_TRAIT_KEY, CUSTOM_KEY_VALUE); + + callIdentifyWithTraits(traits); + callIdentifyWithTraits(traits); + callIdentifyWithTraits(traits); + + verify(appboyUser, times(1)).setEmail(TRAIT_EMAIL); + verify(appboyUser, times(1)).setHomeCity(TRAIT_CITY); + verify(appboyUser, times(1)).setCountry(TRAIT_COUNTRY); + verify(appboyUser, times(1)).setCustomUserAttribute(CUSTOM_TRAIT_KEY, CUSTOM_KEY_VALUE); + } + + @Test + public void testShouldTriggerUpdateIfTraitChanges() { + Traits traits = createTraits(USER_ID); + traits.putEmail(TRAIT_EMAIL); + callIdentifyWithTraits(traits); + callIdentifyWithTraits(traits); - integration.identify(identifyPayload); + Traits traitsUpdate = createTraits(USER_ID); + traitsUpdate.putEmail(TRAIT_EMAIL_UPDATED); + callIdentifyWithTraits(traitsUpdate); + callIdentifyWithTraits(traitsUpdate); + + InOrder inOrder = Mockito.inOrder(appboyUser); + inOrder.verify(appboyUser, times(1)).setEmail(TRAIT_EMAIL); + inOrder.verify(appboyUser, times(1)).setEmail(TRAIT_EMAIL_UPDATED); + } + + @Test + public void testAvoidTriggeringRepeatedUserIdUpdates() { + Traits traits = createTraits(USER_ID); + traits.putEmail(TRAIT_EMAIL); + + callIdentifyWithTraits(traits); + callIdentifyWithTraits(traits); + + verify(appboyUser, times(1)).setEmail(TRAIT_EMAIL); + verify(appboy, times(1)).changeUser(TRANSFORMED_USER_ID); + } + + @Test + public void clearCacheIfUserIdChanges() { + Traits traits = createTraits(USER_ID); + traits.putEmail(TRAIT_EMAIL); + + Traits traitsUpdate = createTraits(OTHER_USER_ID); + traitsUpdate.putEmail(TRAIT_EMAIL); + + callIdentifyWithTraits(traits); + callIdentifyWithTraits(traitsUpdate); + + verify(appboyUser, times(2)).setEmail(TRAIT_EMAIL); + } + + @Test + public void clearCacheOnReset() { + Traits traits = createTraits(USER_ID); + traits.putEmail(TRAIT_EMAIL); + + callIdentifyWithTraits(traits); + + appboyIntegration.reset(); + + callIdentifyWithTraits(traits); + + verify(appboyUser, times(2)).setEmail(TRAIT_EMAIL); + } + + public void callIdentifyWithTraits(Traits traits) { + IdentifyPayload identifyPayload = new IdentifyPayloadBuilder().traits(traits).build(); - assertEquals(TRANSFORMED_USER_ID, Appboy.getInstance(getContext()).getCurrentUser().getUserId()); + appboyIntegration.identify(identifyPayload); } class ReplaceUserIdMapper implements UserIdMapper { @Override public String transformUserId(String segmentUserId) { - return segmentUserId.replaceFirst(ORIGINAL_USER_ID, TRANSFORMED_USER_ID); + return segmentUserId.replaceFirst(USER_ID, TRANSFORMED_USER_ID); } } } diff --git a/src/main/java/com/segment/analytics/android/integrations/appboy/AppboyIntegration.java b/src/main/java/com/segment/analytics/android/integrations/appboy/AppboyIntegration.java index e062953..6a89046 100755 --- a/src/main/java/com/segment/analytics/android/integrations/appboy/AppboyIntegration.java +++ b/src/main/java/com/segment/analytics/android/integrations/appboy/AppboyIntegration.java @@ -20,6 +20,7 @@ import com.segment.analytics.integrations.Logger; import com.segment.analytics.integrations.TrackPayload; +import java.util.Map; import org.json.JSONObject; import java.math.BigDecimal; @@ -77,10 +78,15 @@ public Integration create(ValueMap settings, Analytics analytics) { userIdMapper = new DefaultUserIdMapper(); } + TraitsCache traitsCache = null; + if (options.isTraitDiffingEnabled()) { + traitsCache = new PreferencesTraitsCache(analytics.getApplication()); + } + Appboy.configure(analytics.getApplication().getApplicationContext(), builder.build()); Appboy appboy = Appboy.getInstance(analytics.getApplication()); logger.verbose("Configured Appboy+Segment integration and initialized Appboy."); - return new AppboyIntegration(appboy, apiKey, logger, inAppMessageRegistrationEnabled, userIdMapper); + return new AppboyIntegration(appboy, apiKey, logger, inAppMessageRegistrationEnabled, traitsCache, userIdMapper); } @Override @@ -95,15 +101,18 @@ public String key() { private final Logger mLogger; private final boolean mAutomaticInAppMessageRegistrationEnabled; private final UserIdMapper mUserIdMapper; + private final TraitsCache mTraitsCache; public AppboyIntegration(Appboy appboy, String token, Logger logger, boolean automaticInAppMessageRegistrationEnabled, + TraitsCache traitsCache, UserIdMapper userIdMapper) { mAppboy = appboy; mToken = token; mLogger = logger; mAutomaticInAppMessageRegistrationEnabled = automaticInAppMessageRegistrationEnabled; mUserIdMapper = userIdMapper; + mTraitsCache = traitsCache; } public String getToken() { @@ -121,14 +130,32 @@ public void identify(IdentifyPayload identify) { String userId = identify.userId(); if (!StringUtils.isNullOrBlank(userId)) { - mAppboy.changeUser(mUserIdMapper.transformUserId(userId)); + + String cachedUserId = mTraitsCache.load().userId(); + + if (!userId.equals(cachedUserId)) { + mAppboy.changeUser(mUserIdMapper.transformUserId(userId)); + + if (mTraitsCache != null) { + mTraitsCache.clear(); + } + } } Traits traits = identify.traits(); + if (traits == null) { return; } + if (mTraitsCache != null) { + Traits lastEmittedTraits = mTraitsCache.load(); + + mTraitsCache.save(traits); + + traits = diffTraits(traits, lastEmittedTraits); + } + Date birthday = traits.birthday(); if (birthday != null) { Calendar birthdayCal = Calendar.getInstance(Locale.US); @@ -200,11 +227,27 @@ public void identify(IdentifyPayload identify) { mAppboy.getCurrentUser().setCustomUserAttribute(key, (String) value); } else { mLogger.info("Appboy can't map segment value for custom Appboy user " - + "attribute with key %s and value %s", key, value); + + "attribute with key %s and value %s", key, value); } } } + private Traits diffTraits(Traits traits, Traits lastEmittedTraits) { + if (lastEmittedTraits == null) return traits; + + Traits diffed = new Traits(); + + for (Map.Entry trait : traits.entrySet()) { + Object storedValue = lastEmittedTraits.get(trait.getKey()); + + if (storedValue == null || !trait.getValue().equals(storedValue)) { + diffed.put(trait.getKey(), trait.getValue()); + } + } + + return diffed; + } + @Override public void flush() { super.flush(); @@ -268,6 +311,15 @@ public void track(TrackPayload track) { } } + @Override + public void reset() { + super.reset(); + + if (mTraitsCache != null) { + mTraitsCache.clear(); + } + } + @Override public void onActivityStarted(Activity activity) { super.onActivityStarted(activity); diff --git a/src/main/java/com/segment/analytics/android/integrations/appboy/AppboyIntegrationOptions.java b/src/main/java/com/segment/analytics/android/integrations/appboy/AppboyIntegrationOptions.java index 1f7d9f3..aaa6b6e 100644 --- a/src/main/java/com/segment/analytics/android/integrations/appboy/AppboyIntegrationOptions.java +++ b/src/main/java/com/segment/analytics/android/integrations/appboy/AppboyIntegrationOptions.java @@ -3,6 +3,7 @@ public class AppboyIntegrationOptions { private UserIdMapper userIdMapper; + private boolean enableTraitDiffing; public static Builder builder() { return new Builder(); @@ -12,20 +13,32 @@ UserIdMapper getUserIdMapper() { return userIdMapper; } - private AppboyIntegrationOptions(UserIdMapper userIdMapper) { + public boolean isTraitDiffingEnabled() { + return enableTraitDiffing; + } + + private AppboyIntegrationOptions(UserIdMapper userIdMapper, boolean enableTraitDiffing) { this.userIdMapper = userIdMapper; + + this.enableTraitDiffing = enableTraitDiffing; } public static class Builder { private UserIdMapper userIdMapper; + private boolean traitDiffingEnabled; public Builder userIdMapper(UserIdMapper userIdMapper) { this.userIdMapper = userIdMapper; return this; } + public Builder enableTraitDiffing(boolean enable) { + this.traitDiffingEnabled = enable; + return this; + } + public AppboyIntegrationOptions build() { - return new AppboyIntegrationOptions(userIdMapper); + return new AppboyIntegrationOptions(userIdMapper, traitDiffingEnabled); } } } diff --git a/src/main/java/com/segment/analytics/android/integrations/appboy/PreferencesTraitsCache.java b/src/main/java/com/segment/analytics/android/integrations/appboy/PreferencesTraitsCache.java new file mode 100644 index 0000000..aae73bc --- /dev/null +++ b/src/main/java/com/segment/analytics/android/integrations/appboy/PreferencesTraitsCache.java @@ -0,0 +1,64 @@ +package com.segment.analytics.android.integrations.appboy; + +import android.content.Context; +import android.content.SharedPreferences; +import com.segment.analytics.Cartographer; +import com.segment.analytics.Traits; +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +import static android.content.Context.MODE_PRIVATE; +import static com.segment.analytics.internal.Utils.isNullOrEmpty; + +public class PreferencesTraitsCache implements TraitsCache { + + private static final String PREFS_FILENAME = "segment-braze-traits-cache"; + private static final String PREFS_KEY = "content"; + + private final Cartographer cartographer; + private final SharedPreferences preferences; + + public PreferencesTraitsCache(Context context) { + preferences = context.getSharedPreferences(PREFS_FILENAME, MODE_PRIVATE); + cartographer = new Cartographer.Builder() + .lenient(true) + .prettyPrint(false) + .build(); + } + + @Override + public void save(Traits traits) { + String json = cartographer.toJson(traits); + preferences.edit().putString(PREFS_KEY, json).apply(); + } + + @Override + public Traits load() { + String json = preferences.getString(PREFS_KEY, null); + + if (isNullOrEmpty(json)) return new Traits(); + + try { + Map map = cartographer.fromJson(json); + return buildTraits(map); + } catch (IOException ignored) { + return new Traits(); + } + } + + @Override + public void clear() { + preferences.edit().clear().apply(); + } + + private Traits buildTraits(Map map) { + Traits result = new Traits(); + + for (Map.Entry entry: map.entrySet()) { + result.put(entry.getKey(), entry.getValue()); + } + + return result; + } +} diff --git a/src/main/java/com/segment/analytics/android/integrations/appboy/TraitsCache.java b/src/main/java/com/segment/analytics/android/integrations/appboy/TraitsCache.java new file mode 100644 index 0000000..6a60cd1 --- /dev/null +++ b/src/main/java/com/segment/analytics/android/integrations/appboy/TraitsCache.java @@ -0,0 +1,11 @@ +package com.segment.analytics.android.integrations.appboy; + +import com.segment.analytics.Traits; + +interface TraitsCache { + void save(Traits traits); + + Traits load(); + + void clear(); +} diff --git a/src/test/java/com/segment/analytics/android/integrations/appboy/AppboyTest.java b/src/test/java/com/segment/analytics/android/integrations/appboy/AppboyTest.java index b98120b..e6fc698 100644 --- a/src/test/java/com/segment/analytics/android/integrations/appboy/AppboyTest.java +++ b/src/test/java/com/segment/analytics/android/integrations/appboy/AppboyTest.java @@ -73,7 +73,8 @@ public void setUp() { mLogger = Logger.with(Analytics.LogLevel.DEBUG); when(mAnalytics.logger("Appboy")).thenReturn(mLogger); when(mAnalytics.getApplication()).thenReturn(mContext); - mIntegration = new AppboyIntegration(mAppboy, "foo", mLogger, true, new DefaultUserIdMapper()); + mIntegration = new AppboyIntegration( + mAppboy, "foo", mLogger, true, new InMemoryTraitsCache(), new DefaultUserIdMapper()); } @Test @@ -214,7 +215,7 @@ public void testIdentifyGenderOnBadInputs() { traits.putGender("female_1"); identifyPayload = new IdentifyPayloadBuilder().traits(traits).build(); mIntegration.identify(identifyPayload); - verify(mAppboy, Mockito.times(2)).changeUser("userId"); + verify(mAppboy, Mockito.times(1)).changeUser("userId"); //verifyNoMoreAppboyUserInteractions(); //verifyNoMoreAppboyInteractions(); } diff --git a/src/test/java/com/segment/analytics/android/integrations/appboy/InMemoryTraitsCache.java b/src/test/java/com/segment/analytics/android/integrations/appboy/InMemoryTraitsCache.java new file mode 100644 index 0000000..1465913 --- /dev/null +++ b/src/test/java/com/segment/analytics/android/integrations/appboy/InMemoryTraitsCache.java @@ -0,0 +1,23 @@ +package com.segment.analytics.android.integrations.appboy; + +import com.segment.analytics.Traits; + +public class InMemoryTraitsCache implements TraitsCache { + + private Traits traits = new Traits(); + + @Override + public void save(Traits traits) { + this.traits = traits; + } + + @Override + public Traits load() { + return traits; + } + + @Override + public void clear() { + traits = new Traits(); + } +}