diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/AnonymousUserManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/AnonymousUserManager.java index c82f3d1b1..23d17e741 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/AnonymousUserManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/AnonymousUserManager.java @@ -7,6 +7,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.core.app.NotificationManagerCompat; import com.google.gson.Gson; @@ -25,10 +26,25 @@ import java.util.List; import java.util.UUID; -public class AnonymousUserManager { +public class AnonymousUserManager implements IterableActivityMonitor.AppStateCallback { private static final String TAG = "AnonymousUserManager"; - private final IterableApi iterableApi = IterableApi.sharedInstance; + private IterableApi iterableApi = IterableApi.sharedInstance; + private final IterableActivityMonitor activityMonitor; + long lastCriteriaFetch = 0; + + AnonymousUserManager(IterableApi iterableApi) { + this(iterableApi, + IterableActivityMonitor.getInstance()); + } + + @VisibleForTesting + AnonymousUserManager(IterableApi iterableApi, + IterableActivityMonitor activityMonitor) { + this.iterableApi = iterableApi; + this.activityMonitor = activityMonitor; + this.activityMonitor.addCallback(this); + } void updateAnonSession() { IterableLogger.v(TAG, "updateAnonSession"); @@ -157,6 +173,8 @@ void trackAnonUpdateCart(@NonNull List items) { } void getCriteria() { + lastCriteriaFetch = System.currentTimeMillis(); + iterableApi.apiClient.getCriteriaList(data -> { if (data != null) { try { @@ -465,4 +483,27 @@ private String getPushStatus() { return ""; } } + + @Override + public void onSwitchToForeground() { + long currentTime = System.currentTimeMillis(); + + // fetching anonymous user criteria on foregrounding + if (!iterableApi.checkSDKInitialization() + && iterableApi._userIdAnon == null + && iterableApi.config.enableAnonActivation + && iterableApi.getVisitorUsageTracked() + && iterableApi.config.enableForegroundCriteriaFetch + && currentTime - lastCriteriaFetch >= IterableConstants.CRITERIA_FETCHING_COOLDOWN) { + + lastCriteriaFetch = currentTime; + this.getCriteria(); + IterableLogger.d(TAG, "Fetching anonymous user criteria - Foreground"); + } + } + + @Override + public void onSwitchToBackground() { + + } } \ No newline at end of file diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index d316fc1c6..8bb7b24ec 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -36,7 +36,7 @@ public class IterableApi { private String _apiKey; private String _email; private String _userId; - private String _userIdAnon; + String _userIdAnon; private String _authToken; private boolean _debugMode; private Bundle _payloadData; @@ -47,8 +47,8 @@ public class IterableApi { private IterableHelper.FailureHandler _setUserFailureCallbackHandler; IterableApiClient apiClient = new IterableApiClient(new IterableApiAuthProvider()); - private static final AnonymousUserManager anonymousUserManager = new AnonymousUserManager(); private static final AnonymousUserMerge anonymousUserMerge = new AnonymousUserMerge(); + private @Nullable AnonymousUserManager anonymousUserManager; private @Nullable IterableInAppManager inAppManager; private @Nullable IterableEmbeddedManager embeddedManager; private String inboxSessionId; @@ -441,7 +441,7 @@ private boolean isInitialized() { return _apiKey != null && (_email != null || _userId != null); } - private boolean checkSDKInitialization() { + boolean checkSDKInitialization() { if (!isInitialized()) { IterableLogger.w(TAG, "Iterable SDK must be initialized with an API key and user email/userId before calling SDK methods"); return false; @@ -666,12 +666,21 @@ public static void initialize(@NonNull Context context, @NonNull String apiKey, ); } + if (sharedInstance.anonymousUserManager == null) { + sharedInstance.anonymousUserManager = new AnonymousUserManager( + sharedInstance + ); + } + loadLastSavedConfiguration(context); IterablePushNotificationUtil.processPendingAction(context); - if (!sharedInstance.checkSDKInitialization() && sharedInstance._userIdAnon == null && sharedInstance.config.enableAnonActivation && sharedInstance.getVisitorUsageTracked()) { - anonymousUserManager.updateAnonSession(); - anonymousUserManager.getCriteria(); + if (!sharedInstance.checkSDKInitialization() + && sharedInstance._userIdAnon == null + && sharedInstance.config.enableAnonActivation + && sharedInstance.getVisitorUsageTracked()) { + sharedInstance.anonymousUserManager.updateAnonSession(); + sharedInstance.anonymousUserManager.getCriteria(); } if (DeviceInfoUtils.isFireTV(context.getPackageManager())) { diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index d3c49b3bf..e1165d945 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -60,7 +60,9 @@ public class IterableConfig { */ final IterableAuthHandler authHandler; - + /** + * Handler that can be used to retrieve the anonymous user id + */ final IterableAnonUserHandler iterableAnonUserHandler; /** @@ -92,8 +94,20 @@ public class IterableConfig { final boolean encryptionEnforced; + /** + * Enables anonymous user activation + */ final boolean enableAnonActivation; + /** + * Toggles fetching of anonymous user criteria on foregrounding when set to true + * By default, the SDK will fetch anonymous user criteria on foregrounding. + */ + final boolean enableForegroundCriteriaFetch; + + /** + * The number of anonymous events stored in local storage + */ final int eventThresholdLimit; /** @@ -130,6 +144,7 @@ private IterableConfig(Builder builder) { useInMemoryStorageForInApps = builder.useInMemoryStorageForInApps; encryptionEnforced = builder.encryptionEnforced; enableAnonActivation = builder.enableAnonActivation; + enableForegroundCriteriaFetch = builder.enableForegroundCriteriaFetch; enableEmbeddedMessaging = builder.enableEmbeddedMessaging; eventThresholdLimit = builder.eventThresholdLimit; identityResolution = builder.identityResolution; @@ -155,6 +170,7 @@ public static class Builder { private IterableDecryptionFailureHandler decryptionFailureHandler; private boolean encryptionEnforced = false; private boolean enableAnonActivation = false; + private boolean enableForegroundCriteriaFetch = true; private boolean enableEmbeddedMessaging = false; private int eventThresholdLimit = 100; private IterableIdentityResolution identityResolution = new IterableIdentityResolution(); @@ -310,7 +326,6 @@ public Builder setDataRegion(@NonNull IterableDataRegion dataRegion) { * Set whether the SDK should store in-apps only in memory, or in file storage * @param useInMemoryStorageForInApps `true` will have in-apps be only in memory */ - @NonNull public Builder setUseInMemoryStorageForInApps(boolean useInMemoryStorageForInApps) { this.useInMemoryStorageForInApps = useInMemoryStorageForInApps; @@ -327,6 +342,16 @@ public Builder setEnableAnonActivation(boolean enableAnonActivation) { return this; } + /** + * Set whether the SDK should disable criteria fetching on foregrounding. Set this to `false` + * if you want criteria to only be fetched on app launch. + * @param enableForegroundCriteriaFetch `true` will fetch criteria only on app launch. + */ + public Builder setEnableForegroundCriteriaFetch(boolean enableForegroundCriteriaFetch) { + this.enableForegroundCriteriaFetch = enableForegroundCriteriaFetch; + return this; + } + public Builder setEventThresholdLimit(int eventThresholdLimit) { this.eventThresholdLimit = eventThresholdLimit; return this; @@ -348,7 +373,6 @@ public Builder setEnableEmbeddedMessaging(boolean enableEmbeddedMessaging) { * @param identityResolution * @return */ - public Builder setIdentityResolution(IterableIdentityResolution identityResolution) { this.identityResolution = identityResolution; return this; diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java index 6da931df4..46ba4ae7f 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java @@ -336,7 +336,7 @@ public final class IterableConstants { public static final String PURCHASE_ITEM = "shoppingCartItems"; public static final String PURCHASE_ITEM_PREFIX = PURCHASE_ITEM + "."; public static final String MIN_MATCH = "minMatch"; - + public static final Integer CRITERIA_FETCHING_COOLDOWN = 120000; //Tracking types public static final String TRACK_EVENT = "customEvent"; diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiCriteriaFetchTests.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiCriteriaFetchTests.java new file mode 100644 index 000000000..2dcb67fe5 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiCriteriaFetchTests.java @@ -0,0 +1,194 @@ +package com.iterable.iterableapi; + +import static android.os.Looper.getMainLooper; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Activity; + +import com.iterable.iterableapi.unit.PathBasedQueueDispatcher; + +import junit.framework.Assert; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.robolectric.Robolectric; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +public class IterableApiCriteriaFetchTests extends BaseTest { + private MockWebServer server; + private PathBasedQueueDispatcher dispatcher; + + @Before + public void setUp() { + server = new MockWebServer(); + dispatcher = new PathBasedQueueDispatcher(); + server.setDispatcher(dispatcher); + + reInitIterableApi(); + IterableApi.overrideURLEndpointPath(server.url("").toString()); + + IterableConfig iterableConfig = new IterableConfig.Builder().setEnableAnonActivation(true).build(); + IterableApi.initialize(getContext(), "apiKey", iterableConfig); + IterableApi.getInstance().setVisitorUsageTracked(true); + } + + private void reInitIterableApi() { + IterableApi.sharedInstance = new IterableApi(); + } + + @After + public void tearDown() throws IOException { + server.shutdown(); + server = null; + IterableApi.getInstance().setUserId(null); + IterableApi.getInstance().setEmail(null); + + // Add these cleanup steps + IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); + IterableActivityMonitor.instance = new IterableActivityMonitor(); + + // Clear any pending handlers + shadowOf(getMainLooper()).idle(); + } + + private void addResponse(String endPoint) { + dispatcher.enqueueResponse("/" + endPoint, new MockResponse().setResponseCode(200).setBody("{}")); + } + + @Test + public void testForegroundCriteriaFetchWhenConditionsMet() throws Exception { + // Clear any pending requests + while (server.takeRequest(1, TimeUnit.SECONDS) != null) { } + + // Mock responses for expected endpoints + addResponse(IterableConstants.ENDPOINT_CRITERIA_LIST); + + // Initialize with anon activation and foreground fetch enabled + IterableConfig config = new IterableConfig.Builder() + .setEnableAnonActivation(true) + .setEnableForegroundCriteriaFetch(true) + .build(); + + IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); + IterableActivityMonitor.instance = new IterableActivityMonitor(); + + // Initialize API + IterableApi.initialize(getContext(), "apiKey", config); + IterableApi.getInstance().setVisitorUsageTracked(true); + + // Verify first criteria fetch when consent is given + RecordedRequest firstCriteriaRequest = server.takeRequest(1, TimeUnit.SECONDS); + Assert.assertNotNull("First criteria request should be made", firstCriteriaRequest); + assertTrue("First request URL should contain getCriteria endpoint", + firstCriteriaRequest.getPath().contains(IterableConstants.ENDPOINT_CRITERIA_LIST)); + + // Simulate app coming to foreground + Robolectric.buildActivity(Activity.class).create().start().resume(); + shadowOf(getMainLooper()).idle(); + + // Verify criteria fetch request was made + RecordedRequest secondCriteriaRequest = server.takeRequest(1, TimeUnit.SECONDS); + Assert.assertNotNull("Criteria request should be made on foreground", secondCriteriaRequest); + assertTrue("Request URL should contain getCriteria endpoint", + secondCriteriaRequest.getPath().contains(IterableConstants.ENDPOINT_CRITERIA_LIST)); + + // Clean up + IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); + IterableActivityMonitor.instance = new IterableActivityMonitor(); + } + + @Test + public void testCriteriaFetchNotCalledWhenDisabled() throws Exception { + // Initialize with foreground fetch disabled + IterableConfig config = new IterableConfig.Builder() + .setEnableAnonActivation(true) + .setEnableForegroundCriteriaFetch(false) + .build(); + + // Initialize API and set visitor tracking + IterableApi.initialize(getContext(), "apiKey", config); + IterableApi.getInstance().setVisitorUsageTracked(true); + + // Clear out any pending requests + while (server.takeRequest(1, TimeUnit.SECONDS) != null) { } + + // Simulate app coming to foreground + Robolectric.buildActivity(Activity.class).create().start().resume(); + shadowOf(getMainLooper()).idle(); + + // Should only get remote config request after foreground + RecordedRequest configRequest = server.takeRequest(1, TimeUnit.SECONDS); + Assert.assertNotNull("Should have remote config request", configRequest); + assertTrue("Should be a remote configuration request", + configRequest.getPath().contains(IterableConstants.ENDPOINT_GET_REMOTE_CONFIGURATION)); + + // No more requests + RecordedRequest extraRequest = server.takeRequest(1, TimeUnit.SECONDS); + Assert.assertNull("Should not have any additional requests", extraRequest); + + // Clean up + IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); + IterableActivityMonitor.instance = new IterableActivityMonitor(); + } + + @Test + public void testForegroundCriteriaFetchWithCooldown() throws Exception { + // Clear any pending requests + while (server.takeRequest(1, TimeUnit.SECONDS) != null) { } + + // Mock responses + addResponse(IterableConstants.ENDPOINT_CRITERIA_LIST); + addResponse(IterableConstants.ENDPOINT_GET_REMOTE_CONFIGURATION); + + // Initialize with required config + IterableConfig config = new IterableConfig.Builder() + .setEnableAnonActivation(true) + .setEnableForegroundCriteriaFetch(true) + .build(); + + IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); + IterableActivityMonitor.instance = new IterableActivityMonitor(); + + // Initialize API + IterableApi.initialize(getContext(), "apiKey", config); + IterableApi.getInstance().setVisitorUsageTracked(true); + + // Verify first criteria fetch when consent is given + RecordedRequest firstCriteriaRequest = server.takeRequest(1, TimeUnit.SECONDS); + Assert.assertNotNull("First criteria request should be made", firstCriteriaRequest); + assertTrue("First request URL should contain getCriteria endpoint", + firstCriteriaRequest.getPath().contains(IterableConstants.ENDPOINT_CRITERIA_LIST)); + + // First foreground + Robolectric.buildActivity(Activity.class).create().start().resume(); + shadowOf(getMainLooper()).idle(); + + // Verify second criteria fetch + RecordedRequest secondCriteriaRequest = server.takeRequest(1, TimeUnit.SECONDS); + Assert.assertNotNull("Second criteria request should be made", firstCriteriaRequest); + assertTrue("Second request URL should contain getCriteria endpoint", + secondCriteriaRequest.getPath().contains(IterableConstants.ENDPOINT_CRITERIA_LIST)); + + // Immediate second foreground + Robolectric.buildActivity(Activity.class).create().start().resume(); + shadowOf(getMainLooper()).idle(); + + // Verify no criteria requests during cooldown period + RecordedRequest cooldownRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertFalse("Second request URL should contain getCriteria endpoint", + cooldownRequest.getPath().contains(IterableConstants.ENDPOINT_CRITERIA_LIST)); + + // Clean up + IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); + IterableActivityMonitor.instance = new IterableActivityMonitor(); + } +}