diff --git a/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/OptimizelyManagerTest.java b/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/OptimizelyManagerTest.java index 3052612e..1687761c 100644 --- a/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/OptimizelyManagerTest.java +++ b/android-sdk/src/androidTest/java/com/optimizely/ab/android/sdk/OptimizelyManagerTest.java @@ -21,6 +21,7 @@ import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.os.Build; +import android.util.Log; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SdkSuppress; @@ -48,6 +49,8 @@ import org.mockito.stubbing.Answer; import org.slf4j.Logger; +import java.util.Map; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -58,6 +61,7 @@ import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; import static junit.framework.Assert.fail; +import static org.junit.Assert.assertNotEquals; import static org.mockito.Matchers.any; import static org.mockito.Matchers.eq; import static org.mockito.Mockito.doAnswer; @@ -359,7 +363,7 @@ public void injectOptimizely() { UserProfileService userProfileService = mock(UserProfileService.class); OptimizelyStartListener startListener = mock(OptimizelyStartListener.class); - optimizelyManager.setOptimizelyStartListener(startListener); + optimizelyManager.setOptimizelyStartListener(startListener, true); optimizelyManager.injectOptimizely(context, userProfileService, minDatafile); try { executor.awaitTermination(5, TimeUnit.SECONDS); @@ -750,6 +754,72 @@ public void initializeSyncWithResourceDatafileNoCacheWithDefaultParams() { verify(manager).initialize(eq(context), eq(defaultDatafile), eq(true), eq(false)); } + @Test + public void initializeAsyncCallbackInBackgroundThread() throws InterruptedException { + OptimizelyManager optimizelyManager = OptimizelyManager.builder(testProjectId) + .build(InstrumentationRegistry.getInstrumentation().getTargetContext()); + + CountDownLatch latch = new CountDownLatch(1); + + // by default, async init returns in main thread. + // this parameter should be set to false to overrule it. + boolean returnInMainThread = false; + + optimizelyManager.initialize( + InstrumentationRegistry.getInstrumentation().getContext(), + null, + returnInMainThread, + (client) -> { + Log.d("Optly", "[TESTING] " + Thread.currentThread().getName()); + try { + assertNotEquals( + "OptimizelyStartListener should be called in a background thread", + "main", Thread.currentThread().getName() + ); + latch.countDown(); + } catch (AssertionError e) { + // we need catch and silence this assertion error, otherwise it will be caught in OptimizeManager, + // and give a wrong error message. The failure will be detected with the latch timeout below. + } + } + ); + + boolean completed = latch.await(1, TimeUnit.SECONDS); + if (!completed) { + fail("OptimizelyStartListener thread checking failed"); + } + } + + @Test + public void initializeAsyncCallbackInMainThread() throws InterruptedException { + OptimizelyManager optimizelyManager = OptimizelyManager.builder(testProjectId) + .build(InstrumentationRegistry.getInstrumentation().getTargetContext()); + + CountDownLatch latch = new CountDownLatch(1); + + optimizelyManager.initialize( + InstrumentationRegistry.getInstrumentation().getContext(), + null, + (client) -> { + Log.d("Optly", "[TESTING] " + Thread.currentThread().getName()); + try { + assertEquals( + "OptimizelyStartListener should be called in a background thread", + "main", Thread.currentThread().getName() + ); + latch.countDown(); + } catch (AssertionError e) { + // we need catch and silence this assertion error, otherwise it will be caught in OptimizeManager, + // and give a wrong error message. The failure will be detected with the latch timeout below. + } + } + ); + + boolean completed = latch.await(1, TimeUnit.SECONDS); + if (!completed) { + fail("OptimizelyStartListener thread checking failed"); + } + } // Utils diff --git a/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyManager.java b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyManager.java index 5776cbf6..286881c2 100644 --- a/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyManager.java +++ b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/OptimizelyManager.java @@ -97,6 +97,7 @@ public class OptimizelyManager { @Nullable private final String vuid; @Nullable private OptimizelyStartListener optimizelyStartListener; + private boolean returnInMainThreadFromAsyncInit = true; @Nullable private final List defaultDecideOptions; private String sdkVersion = null; @@ -175,8 +176,14 @@ OptimizelyStartListener getOptimizelyStartListener() { return optimizelyStartListener; } - void setOptimizelyStartListener(@Nullable OptimizelyStartListener optimizelyStartListener) { + void setOptimizelyStartListener(@Nullable OptimizelyStartListener optimizelyStartListener, boolean returnInMainThread) { this.optimizelyStartListener = optimizelyStartListener; + this.returnInMainThreadFromAsyncInit = returnInMainThread; + } + + void setOptimizelyStartListener(@Nullable OptimizelyStartListener optimizelyStartListener) { + boolean returnInMainThread = true; + setOptimizelyStartListener(optimizelyStartListener, returnInMainThread); } private void notifyStartListener() { @@ -398,11 +405,27 @@ public void initialize(@NonNull final Context context, @NonNull OptimizelyStartL * @see #initialize(Context, Integer, OptimizelyStartListener) */ @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) - public void initialize(@NonNull final Context context, @RawRes final Integer datafileRes, @NonNull OptimizelyStartListener optimizelyStartListener) { + public void initialize( + @NonNull final Context context, + @RawRes final Integer datafileRes, + @NonNull OptimizelyStartListener optimizelyStartListener) + { + // return in main thread after async completed (backward compatible) + boolean returnInMainThread = true; + initialize(context, datafileRes, returnInMainThread, optimizelyStartListener); + } + + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) + public void initialize( + @NonNull final Context context, + @RawRes final Integer datafileRes, + final boolean returnInMainThread, + @NonNull OptimizelyStartListener optimizelyStartListener) + { if (!isAndroidVersionSupported()) { return; } - setOptimizelyStartListener(optimizelyStartListener); + setOptimizelyStartListener(optimizelyStartListener, returnInMainThread); datafileHandler.downloadDatafile(context, datafileConfig, getDatafileLoadedListener(context,datafileRes)); } @@ -553,7 +576,7 @@ public void onStartComplete(UserProfileService userProfileService) { logger.info("No listener to send Optimizely to"); } } - }); + }, returnInMainThreadFromAsyncInit); } else { if (optimizelyStartListener != null) { diff --git a/test-app/src/main/java/com/optimizely/ab/android/test_app/Samples/APISamplesInJava.java b/test-app/src/main/java/com/optimizely/ab/android/test_app/Samples/APISamplesInJava.java index 3b7016dd..ccf09dfe 100644 --- a/test-app/src/main/java/com/optimizely/ab/android/test_app/Samples/APISamplesInJava.java +++ b/test-app/src/main/java/com/optimizely/ab/android/test_app/Samples/APISamplesInJava.java @@ -90,7 +90,8 @@ static public void samplesAll(Context context) { samplesForDoc_NotificatonListener(context); samplesForDoc_OlderVersions(context); samplesForDoc_ForcedDecision(context); - samplesForDoc_ODP(context); + samplesForDoc_ODP_async(context); + samplesForDoc_ODP_sync(context); } static public void samplesForDecide(Context context) { @@ -859,7 +860,7 @@ static public void samplesForDoc_ForcedDecision(Context context) { success = user.removeAllForcedDecisions(); } - static public void samplesForDoc_ODP(Context context) { + static public void samplesForDoc_ODP_async(Context context) { OptimizelyManager optimizelyManager = OptimizelyManager.builder().withSDKKey("VivZyCGPHY369D4z8T9yG").build(context); optimizelyManager.initialize(context, null, (OptimizelyClient client) -> { OptimizelyUserContext userContext = client.createUserContext("user_123"); @@ -871,4 +872,19 @@ static public void samplesForDoc_ODP(Context context) { }); } + static public void samplesForDoc_ODP_sync(Context context) { + OptimizelyManager optimizelyManager = OptimizelyManager.builder().withSDKKey("VivZyCGPHY369D4z8T9yG").build(context); + + boolean returnInMainThread = false; + + optimizelyManager.initialize(context, null, returnInMainThread, (OptimizelyClient client) -> { + OptimizelyUserContext userContext = client.createUserContext("user_123"); + userContext.fetchQualifiedSegments(); + + Log.d("Optimizely", "[ODP] segments = " + userContext.getQualifiedSegments()); + OptimizelyDecision optDecision = userContext.decide("odp-flag-1"); + Log.d("Optimizely", "[ODP] decision = " + optDecision.toString()); + }); + } + } diff --git a/test-app/src/main/java/com/optimizely/ab/android/test_app/Samples/APISamplesInKotlin.kt b/test-app/src/main/java/com/optimizely/ab/android/test_app/Samples/APISamplesInKotlin.kt index 342bbef9..d2112caf 100644 --- a/test-app/src/main/java/com/optimizely/ab/android/test_app/Samples/APISamplesInKotlin.kt +++ b/test-app/src/main/java/com/optimizely/ab/android/test_app/Samples/APISamplesInKotlin.kt @@ -18,8 +18,6 @@ package com.optimizely.ab.android.test_app import android.content.Context import android.content.IntentFilter import android.net.wifi.WifiManager -import android.os.Parcel -import android.os.Parcelable import android.util.Log import com.optimizely.ab.OptimizelyDecisionContext import com.optimizely.ab.OptimizelyForcedDecision @@ -29,7 +27,6 @@ import com.optimizely.ab.android.event_handler.EventRescheduler import com.optimizely.ab.android.sdk.OptimizelyClient import com.optimizely.ab.android.sdk.OptimizelyManager import com.optimizely.ab.bucketing.UserProfileService -import com.optimizely.ab.config.Variation import com.optimizely.ab.config.parser.JsonParseException import com.optimizely.ab.error.ErrorHandler import com.optimizely.ab.error.RaiseExceptionErrorHandler @@ -40,12 +37,8 @@ import com.optimizely.ab.notification.DecisionNotification import com.optimizely.ab.notification.NotificationHandler import com.optimizely.ab.notification.TrackNotification import com.optimizely.ab.notification.UpdateConfigNotification -import com.optimizely.ab.optimizelyconfig.OptimizelyConfig import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption import com.optimizely.ab.optimizelydecision.OptimizelyDecision -import com.optimizely.ab.optimizelyjson.OptimizelyJSON -import org.slf4j.LoggerFactory -import java.lang.Exception import java.util.* import java.util.concurrent.TimeUnit @@ -76,7 +69,8 @@ object APISamplesInKotlin { samplesForDoc_NotificatonListener(context) samplesForDoc_OlderVersions(context) samplesForDoc_ForcedDecision(context) - samplesForDoc_ODP(context) + samplesForDoc_ODP_async(context) + samplesForDoc_ODP_sync(context) } fun samplesForDecide(context: Context) { @@ -829,7 +823,7 @@ object APISamplesInKotlin { success = user.removeAllForcedDecisions() } - fun samplesForDoc_ODP(context: Context?) { + fun samplesForDoc_ODP_async(context: Context?) { val optimizelyManager = OptimizelyManager.builder().withSDKKey("VivZyCGPHY369D4z8T9yG").build(context) optimizelyManager.initialize(context!!, null) { client: OptimizelyClient -> @@ -842,6 +836,22 @@ object APISamplesInKotlin { } } + fun samplesForDoc_ODP_sync(context: Context?) { + val optimizelyManager = + OptimizelyManager.builder().withSDKKey("VivZyCGPHY369D4z8T9yG").build(context) + + val returnInMainThread = false; + + optimizelyManager.initialize(context!!, null, returnInMainThread) { client: OptimizelyClient -> + val userContext = client.createUserContext("user_123") + userContext!!.fetchQualifiedSegments() + + Log.d("Optimizely", "[ODP] segments = " + userContext.qualifiedSegments) + val optDecision = userContext.decide("odp-flag-1") + Log.d("Optimizely", "[ODP] decision = $optDecision") + } + } + } diff --git a/test-app/src/main/java/com/optimizely/ab/android/test_app/SplashScreenActivity.kt b/test-app/src/main/java/com/optimizely/ab/android/test_app/SplashScreenActivity.kt index 3ee7f38b..36ea2e1a 100644 --- a/test-app/src/main/java/com/optimizely/ab/android/test_app/SplashScreenActivity.kt +++ b/test-app/src/main/java/com/optimizely/ab/android/test_app/SplashScreenActivity.kt @@ -25,6 +25,7 @@ import com.optimizely.ab.android.event_handler.EventRescheduler import com.optimizely.ab.android.sdk.OptimizelyClient import com.optimizely.ab.android.sdk.OptimizelyManager import com.optimizely.ab.android.shared.CountingIdlingResourceManager +import com.optimizely.ab.android.test_app.Samples.APISamplesInJava import com.optimizely.ab.notification.DecisionNotification import com.optimizely.ab.notification.TrackNotification import com.optimizely.ab.notification.UpdateConfigNotification @@ -131,4 +132,4 @@ class SplashScreenActivity : AppCompatActivity() { // The Idling Resource which will be null in production. private val countingIdlingResourceManager: CountingIdlingResourceManager? = null } -} \ No newline at end of file +} diff --git a/user-profile/src/androidTest/java/com/optimizely/ab/android/user_profile/DefaultUserProfileServiceTest.java b/user-profile/src/androidTest/java/com/optimizely/ab/android/user_profile/DefaultUserProfileServiceTest.java index f10968be..54448543 100644 --- a/user-profile/src/androidTest/java/com/optimizely/ab/android/user_profile/DefaultUserProfileServiceTest.java +++ b/user-profile/src/androidTest/java/com/optimizely/ab/android/user_profile/DefaultUserProfileServiceTest.java @@ -29,6 +29,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -39,6 +40,8 @@ import static junit.framework.Assert.assertTrue; import static junit.framework.Assert.fail; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; /** * Tests for {@link DefaultUserProfileService} @@ -101,6 +104,20 @@ public void teardown() { cache.delete(diskCache.getFileName()); } + @Test + public void startInBackground() throws InterruptedException { + DefaultUserProfileService ups = spy(DefaultUserProfileService.class); + + CountDownLatch latch = new CountDownLatch(1); + ups.startInBackground((u) -> { + latch.countDown(); + }); + + latch.await(3, TimeUnit.SECONDS); + + verify(ups).start(); + } + @Test public void saveAndStartAndLookup() { userProfileService.save(userProfileMap1); diff --git a/user-profile/src/main/java/com/optimizely/ab/android/user_profile/DefaultUserProfileService.java b/user-profile/src/main/java/com/optimizely/ab/android/user_profile/DefaultUserProfileService.java index 70df153d..d5f3aa33 100644 --- a/user-profile/src/main/java/com/optimizely/ab/android/user_profile/DefaultUserProfileService.java +++ b/user-profile/src/main/java/com/optimizely/ab/android/user_profile/DefaultUserProfileService.java @@ -20,6 +20,9 @@ import android.content.Context; import android.os.AsyncTask; import android.annotation.TargetApi; +import android.os.Handler; +import android.os.Looper; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -32,6 +35,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** @@ -44,8 +48,10 @@ */ public class DefaultUserProfileService implements UserProfileService { - @NonNull private final UserProfileCache userProfileCache; - @NonNull private final Logger logger; + @NonNull + private final UserProfileCache userProfileCache; + @NonNull + private final Logger logger; DefaultUserProfileService(@NonNull UserProfileCache userProfileCache, @NonNull Logger logger) { this.userProfileCache = userProfileCache; @@ -62,20 +68,20 @@ public class DefaultUserProfileService implements UserProfileService { */ public static UserProfileService newInstance(@NonNull String projectId, @NonNull Context context) { UserProfileCache userProfileCache = new UserProfileCache( - new UserProfileCache.DiskCache( - new Cache( - context, - LoggerFactory.getLogger(Cache.class) - ), - Executors.newSingleThreadExecutor(), - LoggerFactory.getLogger(UserProfileCache.DiskCache.class), - projectId + new UserProfileCache.DiskCache( + new Cache( + context, + LoggerFactory.getLogger(Cache.class) ), - LoggerFactory.getLogger(UserProfileCache.class), - new ConcurrentHashMap>()); + Executors.newSingleThreadExecutor(), + LoggerFactory.getLogger(UserProfileCache.DiskCache.class), + projectId + ), + LoggerFactory.getLogger(UserProfileCache.class), + new ConcurrentHashMap>()); return new DefaultUserProfileService(userProfileCache, - LoggerFactory.getLogger(DefaultUserProfileService.class)); + LoggerFactory.getLogger(DefaultUserProfileService.class)); } public interface StartCallback { @@ -83,30 +89,35 @@ public interface StartCallback { } public void startInBackground(final StartCallback callback) { - final DefaultUserProfileService userProfileService = this; + startInBackground(callback, true); + } - AsyncTask initUserProfileTask = new AsyncTask() { - @Override - protected UserProfileService doInBackground(Void[] params) { - userProfileService.start(); - return userProfileService; - } + public void startInBackground(final StartCallback callback, boolean returnOnMainThread) { + final DefaultUserProfileService userProfileService = this; + Handler mainHandler = new Handler(Looper.getMainLooper()); + + Runnable initUserProfileTask = new Runnable() { @Override - protected void onPostExecute(UserProfileService userProfileService) { + public void run() { + userProfileService.start(); + if (callback != null) { - callback.onStartComplete(userProfileService); + if (returnOnMainThread) { + mainHandler.post(new Runnable() { + @Override + public void run() { + callback.onStartComplete(userProfileService); + } + }); + } else { + callback.onStartComplete(userProfileService); + } } } }; - try { - initUserProfileTask.executeOnExecutor(Executors.newSingleThreadExecutor()); - } - catch (Exception e) { - logger.error("Error loading user profile service from AndroidUserProfileServiceDefault"); - callback.onStartComplete(null); - } - + ExecutorService executor = Executors.newSingleThreadExecutor(); + executor.submit(initUserProfileTask); } /** @@ -146,15 +157,15 @@ public void remove(String userId) { public void removeInvalidExperiments(Set validExperiments) { try { userProfileCache.removeInvalidExperiments(validExperiments); - } - catch (Exception e) { + } catch (Exception e) { logger.error("Error calling userProfileCache to remove invalid experiments", e); } } + /** * Remove a decision from a user profile. * - * @param userId the user ID of the decision to remove + * @param userId the user ID of the decision to remove * @param experimentId the experiment ID of the decision to remove */ public void remove(String userId, String experimentId) {