diff --git a/CHANGELOG.md b/CHANGELOG.md index 05390111..a715a1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to the LaunchDarkly Android SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [2.7.0] - 2019-04-02 +### Added +- The new configuration option `setEvaluationReasons(true)` causes LaunchDarkly to report information about how each feature flag value was determined; you can access this information with the new client methods `boolVariationDetail`, `stringVariationDetail`, etc. The new methods return an object that contains both the flag value and a "reason" object which will tell you, for instance, if the user was individually targeted for the flag or was matched by one of the flag's rules, or if the flag returned the default value due to an error. For more information, see the SDK Reference Guide on [evaluation reasons](https://docs.launchdarkly.com/docs/evaluation-reasons). +- The new client method `getVersion()` returns the version string of the SDK. +### Fixed +- Bug causing `boolVariation`, `intVariation`, and `floatVariation` to always return `null` if `fallback` argument was `null`. +- Potential issue where environment versions for flag updates could compare incorrectly due to floating point coercion. +- Summary events for unknown flags (flags evaluated without any stored value, variation, or version) now include the returned value as intended. +- Inaccurate events caused by data for flag version and variation being unsynchronized with flag value. +- Bug causing some events to be dropped from summary counts due to data race in sending and updating summary events. +- Potential `ClassCastException` crash on some devices due to old version of OkHttp. +- Improved documentation comments throughout. +- Crash on migration when no primary mobile key is specified. +### Removed +- CircleCI V1 config file ## [2.6.0] - 2019-01-22 ### Added - Support for connecting to multiple environments through LDClient interface. diff --git a/README.md b/README.md index 6c053def..d6bcb547 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Check out the included example app, or follow things here: 1. Declare this dependency: ``` - compile 'com.launchdarkly:launchdarkly-android-client:2.6.0' + compile 'com.launchdarkly:launchdarkly-android-client:2.7.0' ``` 1. In your application configure and initialize the client: diff --git a/circle.yml b/circle.yml deleted file mode 100644 index d418e298..00000000 --- a/circle.yml +++ /dev/null @@ -1,36 +0,0 @@ -# Disable emulator audio -machine: - environment: - GRADLE_OPTS: '-Dorg.gradle.jvmargs="-Xmx2048m -XX:+HeapDumpOnOutOfMemoryError"' - QEMU_AUDIO_DRV: none - version: oraclejdk8 - -dependencies: - pre: - - unset ANDROID_NDK_HOME - # Android SDK Build-tools - - if [ ! -d "/usr/local/android-sdk-linux/build-tools/26.0.2" ]; then echo y | android update sdk --no-ui --all --filter "build-tools-26.0.2"; fi - # Android SDK Platform 26 - - if [ ! -d "/usr/local/android-sdk-linux/platforms/android-26" ]; then echo y | android update sdk --no-ui --all --filter "android-26"; fi - # brings in appcompat - - if [ ! -d "/usr/local/android-sdk-linux/extras/android/m2repository" ]; then echo y | android update sdk --no-ui --all --filter "extra-android-m2repository"; fi - - mkdir -p /usr/local/android-sdk-linux/licenses - - aws s3 cp s3://launchdarkly-pastebin/ci/android/licenses/android-sdk-license /usr/local/android-sdk-linux/licenses/android-sdk-license - - aws s3 cp s3://launchdarkly-pastebin/ci/android/licenses/intel-android-extra-license /usr/local/android-sdk-linux/licenses/intel-android-extra-license - cache_directories: - - /usr/local/android-sdk-linux/platforms/android-26 - - /usr/local/android-sdk-linux/build-tools/26.0.2 - - /usr/local/android-sdk-linux/extras/android/m2repository - -test: - override: - - unset ANDROID_NDK_HOME - - emulator -avd circleci-android24 -no-audio -no-window: - background: true - parallel: true - - circle-android wait-for-boot - - ./gradlew :launchdarkly-android-client:assembleDebug --console=plain -PdisablePreDex - - ./gradlew :launchdarkly-android-client:test --console=plain -PdisablePreDex - - ./gradlew :launchdarkly-android-client:connectedAndroidTest --console=plain -PdisablePreDex - - cp -r launchdarkly-android-client/build/reports/* $CIRCLE_TEST_REPORTS - - ./gradlew packageRelease --console=plain -PdisablePreDex diff --git a/launchdarkly-android-client/build.gradle b/launchdarkly-android-client/build.gradle index fd07ddfb..0950172b 100644 --- a/launchdarkly-android-client/build.gradle +++ b/launchdarkly-android-client/build.gradle @@ -7,7 +7,7 @@ apply plugin: 'io.codearte.nexus-staging' allprojects { group = 'com.launchdarkly' - version = '2.6.0' + version = '2.7.0' sourceCompatibility = 1.7 targetCompatibility = 1.7 } @@ -29,7 +29,17 @@ android { versionName version testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" consumerProguardFiles 'consumer-proguard-rules.pro' + + // The following argument makes the Android Test Orchestrator run its + // "pm clear" command after each test invocation. This command ensures + // that the app's state is completely cleared between tests. + testInstrumentationRunnerArguments clearPackageData: 'true' + } + + testOptions { + execution 'ANDROID_TEST_ORCHESTRATOR' } + lintOptions { // TODO: fix things and set this to true abortOnError false @@ -57,10 +67,10 @@ android { ext { supportVersion = "26.0.1" - okhttpVersion = "3.6.0" + okhttpVersion = "3.9.1" eventsourceVersion = "1.8.0" gsonVersion = "2.8.2" - testRunnerVersion = "0.5" + testRunnerVersion = "1.0.2" } dependencies { @@ -80,6 +90,7 @@ dependencies { implementation "com.android.support:support-annotations:$supportVersion" androidTestImplementation "com.android.support.test:runner:$testRunnerVersion" androidTestImplementation "com.android.support.test:rules:$testRunnerVersion" + androidTestUtil "com.android.support.test:orchestrator:$testRunnerVersion" androidTestImplementation 'org.hamcrest:hamcrest-library:1.3' androidTestImplementation 'org.easymock:easymock:3.4' androidTestImplementation 'junit:junit:4.12' diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/DeleteFlagResponseTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/DeleteFlagResponseTest.java new file mode 100644 index 00000000..880a549d --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/DeleteFlagResponseTest.java @@ -0,0 +1,54 @@ +package com.launchdarkly.android; + +import android.support.test.runner.AndroidJUnit4; + +import com.google.gson.Gson; +import com.launchdarkly.android.flagstore.Flag; +import com.launchdarkly.android.flagstore.FlagBuilder; +import com.launchdarkly.android.gson.GsonCache; +import com.launchdarkly.android.response.DeleteFlagResponse; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@RunWith(AndroidJUnit4.class) +public class DeleteFlagResponseTest { + + private static final Gson gson = GsonCache.getGson(); + + @Test + public void deleteFlagResponseKeyIsDeserialized() { + final String jsonStr = "{\"key\": \"flag\"}"; + final DeleteFlagResponse delete = gson.fromJson(jsonStr, DeleteFlagResponse.class); + assertEquals("flag", delete.flagToUpdate()); + } + + @Test + public void testUpdateFlag() { + // Create delete flag responses from json to verify version is deserialized + final String jsonNoVersion = "{\"key\": \"flag\"}"; + final String jsonLowVersion = "{\"key\": \"flag\", \"version\": 50}"; + final String jsonHighVersion = "{\"key\": \"flag\", \"version\": 100}"; + final DeleteFlagResponse deleteNoVersion = gson.fromJson(jsonNoVersion, DeleteFlagResponse.class); + final DeleteFlagResponse deleteLowVersion = gson.fromJson(jsonLowVersion, DeleteFlagResponse.class); + final DeleteFlagResponse deleteHighVersion = gson.fromJson(jsonHighVersion, DeleteFlagResponse.class); + final Flag flagNoVersion = new FlagBuilder("flag").build(); + final Flag flagLowVersion = new FlagBuilder("flag").version(50).build(); + final Flag flagHighVersion = new FlagBuilder("flag").version(100).build(); + + assertNull(deleteNoVersion.updateFlag(null)); + assertNull(deleteNoVersion.updateFlag(flagNoVersion)); + assertNull(deleteNoVersion.updateFlag(flagLowVersion)); + assertNull(deleteNoVersion.updateFlag(flagHighVersion)); + assertNull(deleteLowVersion.updateFlag(null)); + assertNull(deleteLowVersion.updateFlag(flagNoVersion)); + assertEquals(flagLowVersion, deleteLowVersion.updateFlag(flagLowVersion)); + assertEquals(flagHighVersion, deleteLowVersion.updateFlag(flagHighVersion)); + assertNull(deleteHighVersion.updateFlag(null)); + assertNull(deleteHighVersion.updateFlag(flagNoVersion)); + assertNull(deleteHighVersion.updateFlag(flagLowVersion)); + } +} diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/EvaluationReasonTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/EvaluationReasonTest.java new file mode 100644 index 00000000..977f1919 --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/EvaluationReasonTest.java @@ -0,0 +1,66 @@ +package com.launchdarkly.android; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class EvaluationReasonTest { + private static final Gson gson = new LDConfig.Builder().build().getFilteredEventGson(); + + @Test + public void testOffReasonSerialization() { + EvaluationReason reason = EvaluationReason.off(); + String json = "{\"kind\":\"OFF\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("OFF", reason.toString()); + } + + @Test + public void testFallthroughSerialization() { + EvaluationReason reason = EvaluationReason.fallthrough(); + String json = "{\"kind\":\"FALLTHROUGH\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("FALLTHROUGH", reason.toString()); + } + + @Test + public void testTargetMatchSerialization() { + EvaluationReason reason = EvaluationReason.targetMatch(); + String json = "{\"kind\":\"TARGET_MATCH\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("TARGET_MATCH", reason.toString()); + } + + @Test + public void testRuleMatchSerialization() { + EvaluationReason reason = EvaluationReason.ruleMatch(1, "id"); + String json = "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("RULE_MATCH(1,id)", reason.toString()); + } + + @Test + public void testPrerequisiteFailedSerialization() { + EvaluationReason reason = EvaluationReason.prerequisiteFailed("key"); + String json = "{\"kind\":\"PREREQUISITE_FAILED\",\"prerequisiteKey\":\"key\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("PREREQUISITE_FAILED(key)", reason.toString()); + } + + @Test + public void testErrorSerialization() { + EvaluationReason reason = EvaluationReason.error(EvaluationReason.ErrorKind.EXCEPTION); + String json = "{\"kind\":\"ERROR\",\"errorKind\":\"EXCEPTION\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("ERROR(EXCEPTION)", reason.toString()); + } + + private void assertJsonEqual(String expectedString, String actualString) { + JsonElement expected = gson.fromJson(expectedString, JsonElement.class); + JsonElement actual = gson.fromJson(actualString, JsonElement.class); + assertEquals(expected, actual); + } +} diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/EventTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/EventTest.java index 663bd0c1..db1df4d8 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/EventTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/EventTest.java @@ -192,7 +192,7 @@ public void testUserObjectRemovedFromFeatureEvent() { LDUser user = builder.build(); - final FeatureRequestEvent event = new FeatureRequestEvent("key1", user.getKeyAsString(), JsonNull.INSTANCE, JsonNull.INSTANCE, -1, -1); + final FeatureRequestEvent event = new FeatureRequestEvent("key1", user.getKeyAsString(), JsonNull.INSTANCE, JsonNull.INSTANCE, -1, -1, null); Assert.assertNull(event.user); Assert.assertEquals(user.getKeyAsString(), event.userKey); @@ -205,7 +205,7 @@ public void testFullUserObjectIncludedInFeatureEvent() { LDUser user = builder.build(); - final FeatureRequestEvent event = new FeatureRequestEvent("key1", user, JsonNull.INSTANCE, JsonNull.INSTANCE, -1, -1); + final FeatureRequestEvent event = new FeatureRequestEvent("key1", user, JsonNull.INSTANCE, JsonNull.INSTANCE, -1, -1, null); Assert.assertEquals(user, event.user); Assert.assertNull(event.userKey); @@ -244,16 +244,39 @@ public void testOptionalFieldsAreExcludedAppropriately() { LDUser user = builder.build(); - final FeatureRequestEvent hasVersionEvent = new FeatureRequestEvent("key1", user, JsonNull.INSTANCE, JsonNull.INSTANCE, 5, -1); - final FeatureRequestEvent hasVariationEvent = new FeatureRequestEvent("key1", user, JsonNull.INSTANCE, JsonNull.INSTANCE, -1, 20); + final EvaluationReason reason = EvaluationReason.fallthrough(); + + final FeatureRequestEvent hasVersionEvent = new FeatureRequestEvent("key1", user, JsonNull.INSTANCE, JsonNull.INSTANCE, 5, null, null); + final FeatureRequestEvent hasVariationEvent = new FeatureRequestEvent("key1", user, JsonNull.INSTANCE, JsonNull.INSTANCE, -1, 20, null); + final FeatureRequestEvent hasReasonEvent = new FeatureRequestEvent("key1", user, JsonNull.INSTANCE, JsonNull.INSTANCE, 5, 20, reason); Assert.assertEquals(5, hasVersionEvent.version, 0.0f); Assert.assertNull(hasVersionEvent.variation); + Assert.assertNull(hasVersionEvent.reason); Assert.assertEquals(20, hasVariationEvent.variation, 0); Assert.assertNull(hasVariationEvent.version); + Assert.assertNull(hasVariationEvent.reason); + Assert.assertEquals(5, hasReasonEvent.version, 0); + Assert.assertEquals(20, hasReasonEvent.variation, 0); + Assert.assertEquals(reason, hasReasonEvent.reason); } + @Test + public void reasonIsSerialized() { + LDUser.Builder builder = new LDUser.Builder("1") + .email("email@server.net"); + LDUser user = builder.build(); + final EvaluationReason reason = EvaluationReason.fallthrough(); + + final FeatureRequestEvent hasReasonEvent = new FeatureRequestEvent("key1", user, JsonNull.INSTANCE, JsonNull.INSTANCE, 5, 20, reason); + LDConfig config = new LDConfig.Builder() + .build(); + JsonElement jsonElement = config.getFilteredEventGson().toJsonTree(hasReasonEvent); + + JsonElement expected = config.getFilteredEventGson().fromJson("{\"kind\":\"FALLTHROUGH\"}", JsonElement.class); + Assert.assertEquals(expected, jsonElement.getAsJsonObject().get("reason")); + } } diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/LDClientTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/LDClientTest.java index 73837a1c..2fe273b4 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/LDClientTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/LDClientTest.java @@ -12,6 +12,7 @@ import org.junit.Test; import org.junit.runner.RunWith; +import java.io.IOException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -46,7 +47,7 @@ public void setUp() { @UiThreadTest // Not testing UI things, but we need to simulate the UI so the Foreground class is happy. @Test - public void TestOfflineClientReturnsFallbacks() { + public void testOfflineClientReturnsFallbacks() { ldClient = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, ldUser, 1); ldClient.clearSummaryEventSharedPreferences(); @@ -68,7 +69,7 @@ public void TestOfflineClientReturnsFallbacks() { @UiThreadTest // Not testing UI things, but we need to simulate the UI so the Foreground class is happy. @Test - public void GivenFallbacksAreNullAndTestOfflineClientReturnsFallbacks() { + public void givenFallbacksAreNullAndTestOfflineClientReturnsFallbacks() { ldClient = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, ldUser, 1); ldClient.clearSummaryEventSharedPreferences(); @@ -86,10 +87,11 @@ public void GivenFallbacksAreNullAndTestOfflineClientReturnsFallbacks() { @UiThreadTest @Test - public void TestInitMissingApplication() { + public void testInitMissingApplication() { ExecutionException actualFutureException = null; LaunchDarklyException actualProvidedException = null; + //noinspection ConstantConditions ldClientFuture = LDClient.init(null, ldConfig, ldUser); try { @@ -108,10 +110,11 @@ public void TestInitMissingApplication() { @UiThreadTest @Test - public void TestInitMissingConfig() { + public void testInitMissingConfig() { ExecutionException actualFutureException = null; LaunchDarklyException actualProvidedException = null; + //noinspection ConstantConditions ldClientFuture = LDClient.init(activityTestRule.getActivity().getApplication(), null, ldUser); try { @@ -130,10 +133,11 @@ public void TestInitMissingConfig() { @UiThreadTest @Test - public void TestInitMissingUser() { + public void testInitMissingUser() { ExecutionException actualFutureException = null; LaunchDarklyException actualProvidedException = null; + //noinspection ConstantConditions ldClientFuture = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, null); try { @@ -149,4 +153,12 @@ public void TestInitMissingUser() { assertThat(actualProvidedException, instanceOf(LaunchDarklyException.class)); assertTrue("No future task to run", ldClientFuture.isDone()); } + + @UiThreadTest + @Test + public void testDoubleClose() throws IOException { + ldClient = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, ldUser, 1); + ldClient.close(); + ldClient.close(); + } } diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/LDConfigTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/LDConfigTest.java index 679f6586..14a1ef8c 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/LDConfigTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/LDConfigTest.java @@ -15,7 +15,7 @@ public class LDConfigTest { @Test - public void TestBuilderDefaults() { + public void testBuilderDefaults() { LDConfig config = new LDConfig.Builder().build(); assertTrue(config.isStream()); assertFalse(config.isOffline()); @@ -34,11 +34,12 @@ public void TestBuilderDefaults() { assertEquals(null, config.getMobileKey()); assertFalse(config.inlineUsersInEvents()); + assertFalse(config.isEvaluationReasons()); } @Test - public void TestBuilderStreamDisabled() { + public void testBuilderStreamDisabled() { LDConfig config = new LDConfig.Builder() .setStream(false) .build(); @@ -51,7 +52,7 @@ public void TestBuilderStreamDisabled() { } @Test - public void TestBuilderStreamDisabledCustomIntervals() { + public void testBuilderStreamDisabledCustomIntervals() { LDConfig config = new LDConfig.Builder() .setStream(false) .setPollingIntervalMillis(LDConfig.DEFAULT_POLLING_INTERVAL_MILLIS + 1) @@ -66,7 +67,7 @@ public void TestBuilderStreamDisabledCustomIntervals() { } @Test - public void TestBuilderStreamDisabledBackgroundUpdatingDisabled() { + public void testBuilderStreamDisabledBackgroundUpdatingDisabled() { LDConfig config = new LDConfig.Builder() .setStream(false) .setDisableBackgroundUpdating(true) @@ -80,7 +81,7 @@ public void TestBuilderStreamDisabledBackgroundUpdatingDisabled() { } @Test - public void TestBuilderStreamDisabledPollingIntervalBelowMinimum() { + public void testBuilderStreamDisabledPollingIntervalBelowMinimum() { LDConfig config = new LDConfig.Builder() .setStream(false) .setPollingIntervalMillis(LDConfig.MIN_POLLING_INTERVAL_MILLIS - 1) @@ -95,7 +96,7 @@ public void TestBuilderStreamDisabledPollingIntervalBelowMinimum() { } @Test - public void TestBuilderStreamDisabledBackgroundPollingIntervalBelowMinimum() { + public void testBuilderStreamDisabledBackgroundPollingIntervalBelowMinimum() { LDConfig config = new LDConfig.Builder() .setStream(false) .setBackgroundPollingIntervalMillis(LDConfig.MIN_BACKGROUND_POLLING_INTERVAL_MILLIS - 1) @@ -110,7 +111,7 @@ public void TestBuilderStreamDisabledBackgroundPollingIntervalBelowMinimum() { } @Test - public void TestBuilderUseReportDefaultGet() { + public void testBuilderUseReportDefaultGet() { LDConfig config = new LDConfig.Builder() .build(); @@ -118,7 +119,7 @@ public void TestBuilderUseReportDefaultGet() { } @Test - public void TestBuilderUseReporSetToGet() { + public void testBuilderUseReporSetToGet() { LDConfig config = new LDConfig.Builder() .setUseReport(false) .build(); @@ -127,7 +128,7 @@ public void TestBuilderUseReporSetToGet() { } @Test - public void TestBuilderUseReportSetToReport() { + public void testBuilderUseReportSetToReport() { LDConfig config = new LDConfig.Builder() .setUseReport(true) .build(); @@ -136,7 +137,7 @@ public void TestBuilderUseReportSetToReport() { } @Test - public void TestBuilderAllAttributesPrivate() { + public void testBuilderAllAttributesPrivate() { LDConfig config = new LDConfig.Builder() .build(); @@ -150,7 +151,7 @@ public void TestBuilderAllAttributesPrivate() { } @Test - public void TestBuilderPrivateAttributesList() { + public void testBuilderPrivateAttributesList() { LDConfig config = new LDConfig.Builder() .build(); @@ -168,4 +169,10 @@ public void TestBuilderPrivateAttributesList() { assertEquals(config.getPrivateAttributeNames().size(), 2); } + @Test + public void testBuilderEvaluationReasons() { + LDConfig config = new LDConfig.Builder().setEvaluationReasons(true).build(); + + assertTrue(config.isEvaluationReasons()); + } } \ No newline at end of file diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/MigrationTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/MigrationTest.java new file mode 100644 index 00000000..ac0d07fe --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/MigrationTest.java @@ -0,0 +1,129 @@ +package com.launchdarkly.android; + +import android.app.Application; +import android.content.Context; +import android.content.SharedPreferences; +import android.support.test.rule.ActivityTestRule; + +import static com.launchdarkly.android.Migration.getUserKeysPre_2_6; +import static com.launchdarkly.android.Migration.getUserKeys_2_6; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.google.common.collect.Multimap; +import com.launchdarkly.android.test.TestActivity; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.io.File; +import java.util.ArrayList; + +public class MigrationTest { + + private static final String FAKE_MOB_KEY = "mob-fakemob6-key9-fake-mob0-keyfakemob22"; + + @Rule + public final ActivityTestRule activityTestRule = + new ActivityTestRule<>(TestActivity.class, false, true); + + private Application getApplication() { + return activityTestRule.getActivity().getApplication(); + } + + @Before + public void setUp() { + File directory = new File(activityTestRule.getActivity().getApplication().getFilesDir().getParent() + "/shared_prefs/"); + File[] files = directory.listFiles(); + if (files == null) { + return; + } + for (File file : files) { + if (file.isFile()) { + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + } + } + + private void set_version_2_6() { + SharedPreferences migrations = getApplication().getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "migrations", Context.MODE_PRIVATE); + migrations.edit() + .putString("v2.6.0", "v2.6.0") + .apply(); + } + + @Test + public void setsCurrentVersionInMigrationsPrefs() { + LDConfig ldConfig = new LDConfig.Builder().setMobileKey("fake_mob_key").build(); + // perform migration from fresh env + Migration.migrateWhenNeeded(getApplication(), ldConfig); + assertTrue(getApplication().getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "migrations", Context.MODE_PRIVATE).contains("v2.7.0")); + } + + @Test + public void maintainsExistingSharedPrefsFresh() { + // Create existing shared prefs + SharedPreferences existing = getApplication().getSharedPreferences("arbitrary", Context.MODE_PRIVATE); + existing.edit() + .putString("test", "string") + .commit(); + //noinspection UnusedAssignment + existing = null; + LDConfig ldConfig = new LDConfig.Builder().setMobileKey("fake_mob_key").build(); + // perform migration from fresh env + Migration.migrateWhenNeeded(getApplication(), ldConfig); + setUp(); + // Check existing shared prefs still exist + existing = getApplication().getSharedPreferences("arbitrary", Context.MODE_PRIVATE); + assertEquals("string", existing.getString("test", null)); + } + + @Test + public void maintainsExistingSharedPrefs_2_6() { + set_version_2_6(); + maintainsExistingSharedPrefsFresh(); + } + + @Test + public void migrationNoMobileKeysFresh() { + LDConfig ldConfig = new LDConfig.Builder().setMobileKey("fake_mob_key").build(); + Migration.migrateWhenNeeded(getApplication(), ldConfig); + } + + @Test + public void migrationNoMobileKeys_2_6() { + set_version_2_6(); + migrationNoMobileKeysFresh(); + } + + @Test + public void getsCorrectUserKeysPre_2_6() { + LDUser user1 = new LDUser.Builder("user1").build(); + LDUser user2 = new LDUser.Builder("user2").build(); + // Create shared prefs files + getApplication().getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + user1.getSharedPrefsKey(), Context.MODE_PRIVATE).edit().commit(); + getApplication().getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + user2.getSharedPrefsKey(), Context.MODE_PRIVATE).edit().commit(); + LDConfig ldConfig = new LDConfig.Builder().setMobileKey(FAKE_MOB_KEY).build(); + ArrayList userKeys = getUserKeysPre_2_6(getApplication(), ldConfig); + assertTrue(userKeys.contains(user1.getSharedPrefsKey())); + assertTrue(userKeys.contains(user2.getSharedPrefsKey())); + assertEquals(2, userKeys.size()); + } + + @Test + public void getsCorrectUserKeys_2_6() { + LDUser user1 = new LDUser.Builder("user1").build(); + LDUser user2 = new LDUser.Builder("user2").build(); + // Create shared prefs files + getApplication().getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + FAKE_MOB_KEY + user1.getSharedPrefsKey() + "-user", Context.MODE_PRIVATE).edit().commit(); + getApplication().getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + FAKE_MOB_KEY + user2.getSharedPrefsKey() + "-user", Context.MODE_PRIVATE).edit().commit(); + Multimap userKeys = getUserKeys_2_6(getApplication()); + assertTrue(userKeys.containsKey(FAKE_MOB_KEY)); + assertTrue(userKeys.get(FAKE_MOB_KEY).contains(user1.getSharedPrefsKey())); + assertTrue(userKeys.get(FAKE_MOB_KEY).contains(user2.getSharedPrefsKey())); + assertEquals(2, userKeys.get(FAKE_MOB_KEY).size()); + assertEquals(1, userKeys.keySet().size()); + } +} diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/MultiEnvironmentLDClientTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/MultiEnvironmentLDClientTest.java index 5c58efa6..d8b2fcde 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/MultiEnvironmentLDClientTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/MultiEnvironmentLDClientTest.java @@ -26,131 +26,134 @@ @RunWith(AndroidJUnit4.class) public class MultiEnvironmentLDClientTest { - @Rule - public final ActivityTestRule activityTestRule = - new ActivityTestRule<>(TestActivity.class, false, true); - - private LDClient ldClient; - private Future ldClientFuture; - private LDConfig ldConfig; - private LDUser ldUser; - - @Before - public void setUp() { - Map secondaryKeys = new HashMap<>(); - secondaryKeys.put("test", "test"); - secondaryKeys.put("test1", "test1"); - - ldConfig = new LDConfig.Builder() - .setOffline(true) - .setSecondaryMobileKeys(secondaryKeys) - .build(); - - ldUser = new LDUser.Builder("userKey").build(); - } - - @UiThreadTest - @Test - public void TestOfflineClientReturnsFallbacks() { - ldClient = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, ldUser, 1); - ldClient.clearSummaryEventSharedPreferences(); - - assertTrue(ldClient.isInitialized()); - assertTrue(ldClient.isOffline()); - - assertTrue(ldClient.boolVariation("boolFlag", true)); - assertEquals(1.0F, ldClient.floatVariation("floatFlag", 1.0F)); - assertEquals(Integer.valueOf(1), ldClient.intVariation("intFlag", 1)); - assertEquals("fallback", ldClient.stringVariation("stringFlag", "fallback")); - - JsonObject expectedJson = new JsonObject(); - expectedJson.addProperty("field", "value"); - assertEquals(expectedJson, ldClient.jsonVariation("jsonFlag", expectedJson)); - - ldClient.clearSummaryEventSharedPreferences(); - } - - @UiThreadTest - @Test - public void GivenFallbacksAreNullAndTestOfflineClientReturnsFallbacks() { - ldClient = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, ldUser, 1); - ldClient.clearSummaryEventSharedPreferences(); - - assertTrue(ldClient.isInitialized()); - assertTrue(ldClient.isOffline()); - assertNull(ldClient.jsonVariation("jsonFlag", null)); - - assertNull(ldClient.boolVariation("boolFlag", null)); - assertNull(ldClient.floatVariation("floatFlag", null)); - assertNull(ldClient.intVariation("intFlag", null)); - assertNull(ldClient.stringVariation("stringFlag", null)); - - ldClient.clearSummaryEventSharedPreferences(); - } - - @UiThreadTest - @Test - public void TestInitMissingApplication() { - ExecutionException actualFutureException = null; - LaunchDarklyException actualProvidedException = null; - - ldClientFuture = LDClient.init(null, ldConfig, ldUser); - - try { - ldClientFuture.get(); - } catch (InterruptedException e) { - fail(); - } catch (ExecutionException e) { - actualFutureException = e; - actualProvidedException = (LaunchDarklyException) e.getCause(); + @Rule + public final ActivityTestRule activityTestRule = + new ActivityTestRule<>(TestActivity.class, false, true); + + private LDClient ldClient; + private Future ldClientFuture; + private LDConfig ldConfig; + private LDUser ldUser; + + @Before + public void setUp() { + Map secondaryKeys = new HashMap<>(); + secondaryKeys.put("test", "test"); + secondaryKeys.put("test1", "test1"); + + ldConfig = new LDConfig.Builder() + .setOffline(true) + .setSecondaryMobileKeys(secondaryKeys) + .build(); + + ldUser = new LDUser.Builder("userKey").build(); } - assertThat(actualFutureException, instanceOf(ExecutionException.class)); - assertThat(actualProvidedException, instanceOf(LaunchDarklyException.class)); - assertTrue("No future task to run", ldClientFuture.isDone()); - } - - @UiThreadTest - @Test - public void TestInitMissingConfig() { - ExecutionException actualFutureException = null; - LaunchDarklyException actualProvidedException = null; - - ldClientFuture = LDClient.init(activityTestRule.getActivity().getApplication(), null, ldUser); - - try { - ldClientFuture.get(); - } catch (InterruptedException e) { - fail(); - } catch (ExecutionException e) { - actualFutureException = e; - actualProvidedException = (LaunchDarklyException) e.getCause(); + @UiThreadTest + @Test + public void testOfflineClientReturnsFallbacks() { + ldClient = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, ldUser, 1); + ldClient.clearSummaryEventSharedPreferences(); + + assertTrue(ldClient.isInitialized()); + assertTrue(ldClient.isOffline()); + + assertTrue(ldClient.boolVariation("boolFlag", true)); + assertEquals(1.0F, ldClient.floatVariation("floatFlag", 1.0F)); + assertEquals(Integer.valueOf(1), ldClient.intVariation("intFlag", 1)); + assertEquals("fallback", ldClient.stringVariation("stringFlag", "fallback")); + + JsonObject expectedJson = new JsonObject(); + expectedJson.addProperty("field", "value"); + assertEquals(expectedJson, ldClient.jsonVariation("jsonFlag", expectedJson)); + + ldClient.clearSummaryEventSharedPreferences(); + } + + @UiThreadTest + @Test + public void givenFallbacksAreNullAndTestOfflineClientReturnsFallbacks() { + ldClient = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, ldUser, 1); + ldClient.clearSummaryEventSharedPreferences(); + + assertTrue(ldClient.isInitialized()); + assertTrue(ldClient.isOffline()); + assertNull(ldClient.jsonVariation("jsonFlag", null)); + + assertNull(ldClient.boolVariation("boolFlag", null)); + assertNull(ldClient.floatVariation("floatFlag", null)); + assertNull(ldClient.intVariation("intFlag", null)); + assertNull(ldClient.stringVariation("stringFlag", null)); + + ldClient.clearSummaryEventSharedPreferences(); } - assertThat(actualFutureException, instanceOf(ExecutionException.class)); - assertThat(actualProvidedException, instanceOf(LaunchDarklyException.class)); - assertTrue("No future task to run", ldClientFuture.isDone()); - } - - @UiThreadTest - @Test - public void TestInitMissingUser() { - ExecutionException actualFutureException = null; - LaunchDarklyException actualProvidedException = null; - - ldClientFuture = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, null); - - try { - ldClientFuture.get(); - } catch (InterruptedException e) { - fail(); - } catch (ExecutionException e) { - actualFutureException = e; - actualProvidedException = (LaunchDarklyException) e.getCause(); + @UiThreadTest + @Test + public void testInitMissingApplication() { + ExecutionException actualFutureException = null; + LaunchDarklyException actualProvidedException = null; + + //noinspection ConstantConditions + ldClientFuture = LDClient.init(null, ldConfig, ldUser); + + try { + ldClientFuture.get(); + } catch (InterruptedException e) { + fail(); + } catch (ExecutionException e) { + actualFutureException = e; + actualProvidedException = (LaunchDarklyException) e.getCause(); + } + + assertThat(actualFutureException, instanceOf(ExecutionException.class)); + assertThat(actualProvidedException, instanceOf(LaunchDarklyException.class)); + assertTrue("No future task to run", ldClientFuture.isDone()); } - assertThat(actualFutureException, instanceOf(ExecutionException.class)); - assertThat(actualProvidedException, instanceOf(LaunchDarklyException.class)); - assertTrue("No future task to run", ldClientFuture.isDone()); - } + @UiThreadTest + @Test + public void testInitMissingConfig() { + ExecutionException actualFutureException = null; + LaunchDarklyException actualProvidedException = null; + + //noinspection ConstantConditions + ldClientFuture = LDClient.init(activityTestRule.getActivity().getApplication(), null, ldUser); + + try { + ldClientFuture.get(); + } catch (InterruptedException e) { + fail(); + } catch (ExecutionException e) { + actualFutureException = e; + actualProvidedException = (LaunchDarklyException) e.getCause(); + } + + assertThat(actualFutureException, instanceOf(ExecutionException.class)); + assertThat(actualProvidedException, instanceOf(LaunchDarklyException.class)); + assertTrue("No future task to run", ldClientFuture.isDone()); + } + + @UiThreadTest + @Test + public void testInitMissingUser() { + ExecutionException actualFutureException = null; + LaunchDarklyException actualProvidedException = null; + + //noinspection ConstantConditions + ldClientFuture = LDClient.init(activityTestRule.getActivity().getApplication(), ldConfig, null); + + try { + ldClientFuture.get(); + } catch (InterruptedException e) { + fail(); + } catch (ExecutionException e) { + actualFutureException = e; + actualProvidedException = (LaunchDarklyException) e.getCause(); + } + + assertThat(actualFutureException, instanceOf(ExecutionException.class)); + assertThat(actualProvidedException, instanceOf(LaunchDarklyException.class)); + assertTrue("No future task to run", ldClientFuture.isDone()); + } } diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/ThrottlerTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/ThrottlerTest.java index 5bb81852..7c2f6df4 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/ThrottlerTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/ThrottlerTest.java @@ -46,7 +46,7 @@ public void run() { @UiThreadTest @Test - public void TestFirstRunIsInstant() { + public void testFirstRunIsInstant() { throttler.attemptRun(); boolean result = this.hasRun.getAndSet(false); assertTrue(result); @@ -62,7 +62,7 @@ public void inspectJitter() { @UiThreadTest @Test - public void TestRespectsMaxRetryTime() { + public void testRespectsMaxRetryTime() { assertEquals(throttler.calculateJitterVal(300), MAX_RETRY_TIME_MS); } } diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserHasherTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserHasherTest.java index ac75f0f8..aa4243ba 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserHasherTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserHasherTest.java @@ -12,7 +12,7 @@ public class UserHasherTest { @Test - public void TestUserHasherReturnsUniqueResults(){ + public void testUserHasherReturnsUniqueResults(){ UserHasher userHasher1 = new UserHasher(); String input1 = "{'key':'userKey1'}"; @@ -22,7 +22,7 @@ public void TestUserHasherReturnsUniqueResults(){ } @Test - public void TestDifferentUserHashersReturnSameResults(){ + public void testDifferentUserHashersReturnSameResults(){ UserHasher userHasher1 = new UserHasher(); UserHasher userHasher2 = new UserHasher(); UserHasher userHasher3 = new UserHasher(); diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserManagerTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserManagerTest.java index 9ef38427..29c096b5 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserManagerTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserManagerTest.java @@ -1,14 +1,13 @@ package com.launchdarkly.android; -import android.content.SharedPreferences; import android.support.test.rule.ActivityTestRule; import android.support.test.runner.AndroidJUnit4; -import android.util.Pair; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.gson.JsonObject; -import com.launchdarkly.android.response.FlagResponseSharedPreferences; +import com.launchdarkly.android.flagstore.Flag; +import com.launchdarkly.android.flagstore.FlagStore; import com.launchdarkly.android.test.TestActivity; import org.easymock.EasyMockRule; @@ -29,6 +28,7 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.reset; @@ -55,45 +55,63 @@ public void before() { } @Test - public void TestFailedFetchThrowsException() throws InterruptedException { + public void testFailedFetchThrowsException() throws InterruptedException { setUserAndFailToFetchFlags("userKey"); } + private void addSimpleFlag(JsonObject jsonObject, String flagKey, String value) { + JsonObject flagBody = new JsonObject(); + flagBody.addProperty("value", value); + jsonObject.add(flagKey, flagBody); + } + + private void addSimpleFlag(JsonObject jsonObject, String flagKey, boolean value) { + JsonObject flagBody = new JsonObject(); + flagBody.addProperty("value", value); + jsonObject.add(flagKey, flagBody); + } + + private void addSimpleFlag(JsonObject jsonObject, String flagKey, Number value) { + JsonObject flagBody = new JsonObject(); + flagBody.addProperty("value", value); + jsonObject.add(flagKey, flagBody); + } + @Test - public void TestBasicRetrieval() throws ExecutionException, InterruptedException { + public void testBasicRetrieval() throws ExecutionException, InterruptedException { String expectedStringFlagValue = "string1"; JsonObject jsonObject = new JsonObject(); - jsonObject.addProperty("boolFlag1", true); - jsonObject.addProperty("stringFlag1", expectedStringFlagValue); + addSimpleFlag(jsonObject, "boolFlag1", true); + addSimpleFlag(jsonObject, "stringFlag1", expectedStringFlagValue); Future future = setUser("userKey", jsonObject); future.get(); - SharedPreferences sharedPrefs = userManager.getCurrentUserSharedPrefs(); - assertEquals(2, sharedPrefs.getAll().size()); - assertEquals(true, sharedPrefs.getBoolean("boolFlag1", false)); - assertEquals(expectedStringFlagValue, sharedPrefs.getString("stringFlag1", "")); + FlagStore flagStore = userManager.getCurrentUserFlagStore(); + assertEquals(2, flagStore.getAllFlags().size()); + assertEquals(true, flagStore.getFlag("boolFlag1").getValue().getAsBoolean()); + assertEquals(expectedStringFlagValue, flagStore.getFlag("stringFlag1").getValue().getAsString()); } @Test - public void TestNewUserUpdatesFlags() { + public void testNewUserUpdatesFlags() { JsonObject flags = new JsonObject(); String flagKey = "stringFlag"; - flags.addProperty(flagKey, "user1"); + addSimpleFlag(flags, flagKey, "user1"); setUser("user1", flags); assertFlagValue(flagKey, "user1"); - flags.addProperty(flagKey, "user2"); + addSimpleFlag(flags, flagKey, "user2"); setUser("user2", flags); assertFlagValue(flagKey, "user2"); } @Test - public void TestCanStoreExactly5Users() throws InterruptedException { + public void testCanStoreExactly5Users() throws InterruptedException { JsonObject flags = new JsonObject(); String flagKey = "stringFlag"; @@ -102,14 +120,14 @@ public void TestCanStoreExactly5Users() throws InterruptedException { List users = Arrays.asList(user1, "user2", "user3", "user4", user5, "user6"); for (String user : users) { - flags.addProperty(flagKey, user); + addSimpleFlag(flags, flagKey, user); setUser(user, flags); assertFlagValue(flagKey, user); } //we now have 5 users in SharedPreferences. The very first one we added shouldn't be saved anymore. setUserAndFailToFetchFlags(user1); - assertFlagValue(flagKey, null); + assertNull(userManager.getCurrentUserFlagStore().getFlag(flagKey)); // user5 should still be saved: setUserAndFailToFetchFlags(user5); @@ -117,7 +135,7 @@ public void TestCanStoreExactly5Users() throws InterruptedException { } @Test - public void TestRegisterUnregisterListener() { + public void testRegisterUnregisterListener() { FeatureFlagChangeListener listener = new FeatureFlagChangeListener() { @Override public void onFeatureFlagChange(String flagKey) { @@ -125,7 +143,7 @@ public void onFeatureFlagChange(String flagKey) { }; userManager.registerListener("key", listener); - Collection> listeners = userManager.getListenersByKey("key"); + Collection listeners = userManager.getListenersByKey("key"); assertNotNull(listeners); assertFalse(listeners.isEmpty()); @@ -136,7 +154,7 @@ public void onFeatureFlagChange(String flagKey) { } @Test - public void TestUnregisterListenerWithDuplicates() { + public void testUnregisterListenerWithDuplicates() { FeatureFlagChangeListener listener = new FeatureFlagChangeListener() { @Override public void onFeatureFlagChange(String flagKey) { @@ -147,43 +165,41 @@ public void onFeatureFlagChange(String flagKey) { userManager.registerListener("key", listener); userManager.unregisterListener("key", listener); - Collection> listeners = userManager.getListenersByKey("key"); + Collection listeners = userManager.getListenersByKey("key"); assertNotNull(listeners); assertTrue(listeners.isEmpty()); } @Test - public void TestDeleteFlag() throws ExecutionException, InterruptedException { - userManager.clearFlagResponseSharedPreferences(); - + public void testDeleteFlag() throws ExecutionException, InterruptedException { String expectedStringFlagValue = "string1"; JsonObject jsonObject = new JsonObject(); - jsonObject.addProperty("boolFlag1", true); - jsonObject.addProperty("stringFlag1", expectedStringFlagValue); + addSimpleFlag(jsonObject, "boolFlag1", true); + addSimpleFlag(jsonObject, "stringFlag1", expectedStringFlagValue); - Future future = setUser("userKey", jsonObject); + Future future = setUserClear("userKey", jsonObject); future.get(); - SharedPreferences sharedPrefs = userManager.getCurrentUserSharedPrefs(); - assertEquals(2, sharedPrefs.getAll().size()); - assertEquals(true, sharedPrefs.getBoolean("boolFlag1", false)); - assertEquals(expectedStringFlagValue, sharedPrefs.getString("stringFlag1", "")); + FlagStore flagStore = userManager.getCurrentUserFlagStore(); + assertEquals(2, flagStore.getAllFlags().size()); + assertEquals(true, flagStore.getFlag("boolFlag1").getValue().getAsBoolean()); + assertEquals(expectedStringFlagValue, flagStore.getFlag("stringFlag1").getValue().getAsString()); userManager.deleteCurrentUserFlag("{\"key\":\"stringFlag1\",\"version\":16}").get(); - assertEquals("", sharedPrefs.getString("stringFlag1", "")); - assertEquals(true, sharedPrefs.getBoolean("boolFlag1", false)); + assertNull(flagStore.getFlag("stringFlag1")); + assertEquals(true, flagStore.getFlag("boolFlag1").getValue().getAsBoolean()); userManager.deleteCurrentUserFlag("{\"key\":\"nonExistentFlag\",\"version\":16,\"value\":false}").get(); } @Test - public void TestDeleteForInvalidResponse() throws ExecutionException, InterruptedException { + public void testDeleteForInvalidResponse() throws ExecutionException, InterruptedException { String expectedStringFlagValue = "string1"; JsonObject jsonObject = new JsonObject(); - jsonObject.addProperty("boolFlag1", true); - jsonObject.addProperty("stringFlag1", expectedStringFlagValue); + addSimpleFlag(jsonObject, "boolFlag1", true); + addSimpleFlag(jsonObject, "stringFlag1", expectedStringFlagValue); Future future = setUser("userKey", jsonObject); future.get(); @@ -197,10 +213,8 @@ public void TestDeleteForInvalidResponse() throws ExecutionException, Interrupte } @Test - public void TestDeleteWithVersion() throws ExecutionException, InterruptedException { - userManager.clearFlagResponseSharedPreferences(); - - Future future = setUser("userKey", new JsonObject()); + public void testDeleteWithVersion() throws ExecutionException, InterruptedException { + Future future = setUserClear("userKey", new JsonObject()); future.get(); String json = "{\n" + @@ -214,109 +228,108 @@ public void TestDeleteWithVersion() throws ExecutionException, InterruptedExcept userManager.putCurrentUserFlags(json).get(); userManager.deleteCurrentUserFlag("{\"key\":\"stringFlag1\",\"version\":16}").get(); - SharedPreferences sharedPrefs = userManager.getCurrentUserSharedPrefs(); - assertEquals("string1", sharedPrefs.getString("stringFlag1", "")); + FlagStore flagStore = userManager.getCurrentUserFlagStore(); + assertEquals("string1", flagStore.getFlag("stringFlag1").getValue().getAsString()); userManager.deleteCurrentUserFlag("{\"key\":\"stringFlag1\",\"version\":127}").get(); - assertEquals("", sharedPrefs.getString("stringFlag1", "")); + assertNull(flagStore.getFlag("stringFlag1")); userManager.deleteCurrentUserFlag("{\"key\":\"nonExistent\",\"version\":1}").get(); } @Test - public void TestPatchForAddAndReplaceFlags() throws ExecutionException, InterruptedException { - userManager.clearFlagResponseSharedPreferences(); - + public void testPatchForAddAndReplaceFlags() throws ExecutionException, InterruptedException { JsonObject jsonObject = new JsonObject(); - jsonObject.addProperty("boolFlag1", true); - jsonObject.addProperty("stringFlag1", "string1"); - jsonObject.addProperty("floatFlag1", 3.0f); + addSimpleFlag(jsonObject, "boolFlag1", true); + addSimpleFlag(jsonObject, "stringFlag1", "string1"); + addSimpleFlag(jsonObject, "floatFlag1", 3.0f); - Future future = setUser("userKey", jsonObject); + Future future = setUserClear("userKey", jsonObject); future.get(); userManager.patchCurrentUserFlags("{\"key\":\"new-flag\",\"version\":16,\"value\":false}").get(); - SharedPreferences sharedPrefs = userManager.getCurrentUserSharedPrefs(); - assertEquals(false, sharedPrefs.getBoolean("new-flag", true)); + FlagStore flagStore = userManager.getCurrentUserFlagStore(); + assertEquals(false, flagStore.getFlag("new-flag").getValue().getAsBoolean()); userManager.patchCurrentUserFlags("{\"key\":\"stringFlag1\",\"version\":16,\"value\":\"string2\"}").get(); - assertEquals("string2", sharedPrefs.getString("stringFlag1", "")); + assertEquals("string2", flagStore.getFlag("stringFlag1").getValue().getAsString()); userManager.patchCurrentUserFlags("{\"key\":\"boolFlag1\",\"version\":16,\"value\":false}").get(); - assertEquals(false, sharedPrefs.getBoolean("boolFlag1", false)); + assertEquals(false, flagStore.getFlag("boolFlag1").getValue().getAsBoolean()); - assertEquals(3.0f, sharedPrefs.getFloat("floatFlag1", Float.MIN_VALUE)); + assertEquals(3.0f, flagStore.getFlag("floatFlag1").getValue().getAsFloat()); userManager.patchCurrentUserFlags("{\"key\":\"floatFlag2\",\"version\":16,\"value\":8.0}").get(); - assertEquals(8.0f, sharedPrefs.getFloat("floatFlag2", Float.MIN_VALUE)); + assertEquals(8.0f, flagStore.getFlag("floatFlag2").getValue().getAsFloat()); } @Test - public void TestPatchSucceedsForMissingVersionInPatch() throws ExecutionException, InterruptedException { - userManager.clearFlagResponseSharedPreferences(); - - Future future = setUser("userKey", new JsonObject()); + public void testPatchSucceedsForMissingVersionInPatch() throws ExecutionException, InterruptedException { + Future future = setUserClear("userKey", new JsonObject()); future.get(); - SharedPreferences sharedPrefs = userManager.getCurrentUserSharedPrefs(); - FlagResponseSharedPreferences flagResponseSharedPreferences = userManager.getFlagResponseSharedPreferences(); + FlagStore flagStore = userManager.getCurrentUserFlagStore(); // version does not exist in shared preferences and patch. // --------------------------- //// case 1: value does not exist in shared preferences. userManager.patchCurrentUserFlags("{\"key\":\"flag1\",\"value\":\"value-from-patch\"}").get(); - assertEquals("value-from-patch", sharedPrefs.getString("flag1", "")); - assertEquals(-1, flagResponseSharedPreferences.getStoredVersion("flag1")); + Flag flag1 = flagStore.getFlag("flag1"); + assertEquals("value-from-patch", flag1.getValue().getAsString()); + assertNull(flag1.getVersion()); //// case 2: value exists in shared preferences without version. userManager.putCurrentUserFlags("{\"flag1\": {\"value\": \"value1\"}}"); userManager.patchCurrentUserFlags("{\"key\":\"flag1\",\"value\":\"value-from-patch\"}").get(); - assertEquals("value-from-patch", sharedPrefs.getString("flag1", "")); - assertEquals(-1, flagResponseSharedPreferences.getStoredVersion("flag1")); + flag1 = flagStore.getFlag("flag1"); + assertEquals("value-from-patch", flag1.getValue().getAsString()); + assertNull(flag1.getVersion()); // version does not exist in shared preferences but exists in patch. // --------------------------- //// case 1: value does not exist in shared preferences. userManager.patchCurrentUserFlags("{\"key\":\"flag1\",\"version\":558,\"flagVersion\":3,\"value\":\"value-from-patch\",\"variation\":1,\"trackEvents\":false}").get(); - assertEquals("value-from-patch", sharedPrefs.getString("flag1", "")); - assertEquals(558, flagResponseSharedPreferences.getStoredVersion("flag1")); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagVersion("flag1")); - assertEquals(3, flagResponseSharedPreferences.getVersionForEvents("flag1")); + flag1 = flagStore.getFlag("flag1"); + assertEquals("value-from-patch", flag1.getValue().getAsString()); + assertEquals(558, (int) flag1.getVersion()); + assertEquals(3, (int) flag1.getFlagVersion()); + assertEquals(3, flag1.getVersionForEvents()); //// case 2: value exists in shared preferences without version. userManager.putCurrentUserFlags("{\"flag1\": {\"value\": \"value1\"}}"); userManager.patchCurrentUserFlags("{\"key\":\"flag1\",\"version\":558,\"flagVersion\":3,\"value\":\"value-from-patch\",\"variation\":1,\"trackEvents\":false}").get(); - assertEquals("value-from-patch", sharedPrefs.getString("flag1", "")); - assertEquals(558, flagResponseSharedPreferences.getStoredVersion("flag1")); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagVersion("flag1")); - assertEquals(3, flagResponseSharedPreferences.getVersionForEvents("flag1")); + flag1 = flagStore.getFlag("flag1"); + assertEquals("value-from-patch", flag1.getValue().getAsString()); + assertEquals(558, (int) flag1.getVersion()); + assertEquals(3, (int) flag1.getFlagVersion()); + assertEquals(3, flag1.getVersionForEvents()); // version exists in shared preferences but does not exist in patch. // --------------------------- userManager.putCurrentUserFlags("{\"flag1\": {\"version\": 558, \"flagVersion\": 110,\"value\": \"value1\", \"variation\": 1, \"trackEvents\": false}}"); userManager.patchCurrentUserFlags("{\"key\":\"flag1\",\"value\":\"value-from-patch\"}").get(); - assertEquals("value-from-patch", sharedPrefs.getString("flag1", "")); - assertEquals(-1, flagResponseSharedPreferences.getStoredVersion("flag1")); - assertEquals(-1, flagResponseSharedPreferences.getStoredFlagVersion("flag1")); - assertEquals(-1, flagResponseSharedPreferences.getVersionForEvents("flag1")); + flag1 = flagStore.getFlag("flag1"); + assertEquals("value-from-patch", flag1.getValue().getAsString()); + assertNull(flag1.getVersion()); + assertNull(flag1.getFlagVersion()); + assertEquals(-1, flag1.getVersionForEvents()); // version exists in shared preferences and patch. // --------------------------- userManager.putCurrentUserFlags("{\"flag1\": {\"version\": 558, \"flagVersion\": 110,\"value\": \"value1\", \"variation\": 1, \"trackEvents\": false}}"); userManager.patchCurrentUserFlags("{\"key\":\"flag1\",\"version\":559,\"flagVersion\":3,\"value\":\"value-from-patch\",\"variation\":1,\"trackEvents\":false}").get(); - assertEquals("value-from-patch", sharedPrefs.getString("flag1", "")); - assertEquals(559, flagResponseSharedPreferences.getStoredVersion("flag1")); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagVersion("flag1")); - assertEquals(3, flagResponseSharedPreferences.getVersionForEvents("flag1")); + flag1 = flagStore.getFlag("flag1"); + assertEquals("value-from-patch", flag1.getValue().getAsString()); + assertEquals(559, (int) flag1.getVersion()); + assertEquals(3, (int) flag1.getFlagVersion()); + assertEquals(3, flag1.getVersionForEvents()); } @Test - public void TestPatchWithVersion() throws ExecutionException, InterruptedException { - userManager.clearFlagResponseSharedPreferences(); - - Future future = setUser("userKey", new JsonObject()); + public void testPatchWithVersion() throws ExecutionException, InterruptedException { + Future future = setUserClear("userKey", new JsonObject()); future.get(); String json = "{\n" + @@ -331,35 +344,38 @@ public void TestPatchWithVersion() throws ExecutionException, InterruptedExcepti userManager.patchCurrentUserFlags("{\"key\":\"stringFlag1\",\"version\":16,\"value\":\"string2\"}").get(); - SharedPreferences sharedPrefs = userManager.getCurrentUserSharedPrefs(); - FlagResponseSharedPreferences flagResponseSharedPreferences = userManager.getFlagResponseSharedPreferences(); - assertEquals("string1", sharedPrefs.getString("stringFlag1", "")); - assertEquals(-1, flagResponseSharedPreferences.getStoredFlagVersion("stringFlag1")); - assertEquals(125, flagResponseSharedPreferences.getVersionForEvents("stringFlag1")); + FlagStore flagStore = userManager.getCurrentUserFlagStore(); + Flag stringFlag1 = flagStore.getFlag("stringFlag1"); + assertEquals("string1", stringFlag1.getValue().getAsString()); + assertNull(stringFlag1.getFlagVersion()); + assertEquals(125, stringFlag1.getVersionForEvents()); userManager.patchCurrentUserFlags("{\"key\":\"stringFlag1\",\"version\":126,\"value\":\"string2\"}").get(); - assertEquals("string2", sharedPrefs.getString("stringFlag1", "")); - assertEquals(126, flagResponseSharedPreferences.getStoredVersion("stringFlag1")); - assertEquals(-1, flagResponseSharedPreferences.getStoredFlagVersion("stringFlag1")); - assertEquals(126, flagResponseSharedPreferences.getVersionForEvents("stringFlag1")); + stringFlag1 = flagStore.getFlag("stringFlag1"); + assertEquals("string2", stringFlag1.getValue().getAsString()); + assertEquals(126, (int) stringFlag1.getVersion()); + assertNull(stringFlag1.getFlagVersion()); + assertEquals(126, stringFlag1.getVersionForEvents()); userManager.patchCurrentUserFlags("{\"key\":\"stringFlag1\",\"version\":127,\"flagVersion\":3,\"value\":\"string3\"}").get(); - assertEquals("string3", sharedPrefs.getString("stringFlag1", "")); - assertEquals(127, flagResponseSharedPreferences.getStoredVersion("stringFlag1")); - assertEquals(3, flagResponseSharedPreferences.getStoredFlagVersion("stringFlag1")); - assertEquals(3, flagResponseSharedPreferences.getVersionForEvents("stringFlag1")); + stringFlag1 = flagStore.getFlag("stringFlag1"); + assertEquals("string3", stringFlag1.getValue().getAsString()); + assertEquals(127, (int) stringFlag1.getVersion()); + assertEquals(3, (int) stringFlag1.getFlagVersion()); + assertEquals(3, stringFlag1.getVersionForEvents()); userManager.patchCurrentUserFlags("{\"key\":\"stringFlag20\",\"version\":1,\"value\":\"stringValue\"}").get(); - assertEquals("stringValue", sharedPrefs.getString("stringFlag20", "")); + Flag stringFlag20 = flagStore.getFlag("stringFlag20"); + assertEquals("stringValue", stringFlag20.getValue().getAsString()); } @Test - public void TestPatchForInvalidResponse() throws ExecutionException, InterruptedException { + public void testPatchForInvalidResponse() throws ExecutionException, InterruptedException { String expectedStringFlagValue = "string1"; JsonObject jsonObject = new JsonObject(); - jsonObject.addProperty("boolFlag1", true); - jsonObject.addProperty("stringFlag1", expectedStringFlagValue); + addSimpleFlag(jsonObject, "boolFlag1", true); + addSimpleFlag(jsonObject, "stringFlag1", expectedStringFlagValue); Future future = setUser("userKey", jsonObject); future.get(); @@ -373,12 +389,12 @@ public void TestPatchForInvalidResponse() throws ExecutionException, Interrupted } @Test - public void TestPutForReplaceFlags() throws ExecutionException, InterruptedException { + public void testPutForReplaceFlags() throws ExecutionException, InterruptedException { JsonObject jsonObject = new JsonObject(); - jsonObject.addProperty("stringFlag1", "string1"); - jsonObject.addProperty("boolFlag1", true); - jsonObject.addProperty("floatFlag1", 3.0f); + addSimpleFlag(jsonObject, "stringFlag1", "string1"); + addSimpleFlag(jsonObject, "boolFlag1", true); + addSimpleFlag(jsonObject, "floatFlag1", 3.0f); Future future = setUser("userKey", jsonObject); future.get(); @@ -403,24 +419,24 @@ public void TestPutForReplaceFlags() throws ExecutionException, InterruptedExcep userManager.putCurrentUserFlags(json).get(); - SharedPreferences sharedPrefs = userManager.getCurrentUserSharedPrefs(); + FlagStore flagStore = userManager.getCurrentUserFlagStore(); - assertEquals("string2", sharedPrefs.getString("stringFlag1", "")); - assertEquals(false, sharedPrefs.getBoolean("boolFlag1", false)); + assertEquals("string2", flagStore.getFlag("stringFlag1").getValue().getAsString()); + assertEquals(false, flagStore.getFlag("boolFlag1").getValue().getAsBoolean()); - // Should have value Float.MIN_VALUE instead of 3.0f which was deleted by PUT. - assertEquals(Float.MIN_VALUE, sharedPrefs.getFloat("floatFlag1", Float.MIN_VALUE)); + // Should no exist as was deleted by PUT. + assertNull(flagStore.getFlag("floatFlag1")); - assertEquals(8.0f, sharedPrefs.getFloat("floatFlag2", 1.0f)); + assertEquals(8.0f, flagStore.getFlag("floatFlag2").getValue().getAsFloat()); } @Test - public void TestPutForInvalidResponse() throws ExecutionException, InterruptedException { + public void testPutForInvalidResponse() throws ExecutionException, InterruptedException { String expectedStringFlagValue = "string1"; JsonObject jsonObject = new JsonObject(); - jsonObject.addProperty("boolFlag1", true); - jsonObject.addProperty("stringFlag1", expectedStringFlagValue); + addSimpleFlag(jsonObject, "boolFlag1", true); + addSimpleFlag(jsonObject, "stringFlag1", expectedStringFlagValue); Future future = setUser("userKey", jsonObject); future.get(); @@ -444,6 +460,18 @@ private Future setUser(String userKey, JsonObject flags) { return future; } + private Future setUserClear(String userKey, JsonObject flags) { + LDUser user = new LDUser.Builder(userKey).build(); + ListenableFuture jsonObjectFuture = Futures.immediateFuture(flags); + expect(fetcher.fetch(user)).andReturn(jsonObjectFuture); + replayAll(); + userManager.setCurrentUser(user); + userManager.getCurrentUserFlagStore().clear(); + Future future = userManager.updateCurrentUser(); + reset(fetcher); + return future; + } + private void setUserAndFailToFetchFlags(String userKey) throws InterruptedException { LaunchDarklyException expectedException = new LaunchDarklyException("Could not fetch feature flags"); ListenableFuture failedFuture = immediateFailedFuture(expectedException); @@ -462,9 +490,9 @@ private void setUserAndFailToFetchFlags(String userKey) throws InterruptedExcept reset(fetcher); } - private void assertFlagValue(String flagKey, Object expectedValue) { - SharedPreferences sharedPrefs = userManager.getCurrentUserSharedPrefs(); - assertEquals(expectedValue, sharedPrefs.getAll().get(flagKey)); + private void assertFlagValue(String flagKey, String expectedValue) { + FlagStore flagStore = userManager.getCurrentUserFlagStore(); + assertEquals(expectedValue, flagStore.getFlag(flagKey).getValue().getAsString()); } } diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserSummaryEventSharedPreferencesTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserSummaryEventSharedPreferencesTest.java similarity index 78% rename from launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserSummaryEventSharedPreferencesTest.java rename to launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserSummaryEventSharedPreferencesTest.java index d3a101fa..fcc20573 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserSummaryEventSharedPreferencesTest.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/UserSummaryEventSharedPreferencesTest.java @@ -1,24 +1,22 @@ -package com.launchdarkly.android.response; +package com.launchdarkly.android; import android.support.test.rule.ActivityTestRule; import android.support.test.runner.AndroidJUnit4; import com.google.gson.JsonArray; import com.google.gson.JsonObject; -import com.launchdarkly.android.LDClient; -import com.launchdarkly.android.LDConfig; -import com.launchdarkly.android.LDUser; import com.launchdarkly.android.test.TestActivity; import junit.framework.Assert; import org.junit.After; import org.junit.Before; -import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; /** @@ -63,18 +61,8 @@ public void startDateIsSaved() { ldClient.boolVariation("boolFlag", true); - JsonObject features = summaryEventSharedPreferences.getFeaturesJsonObject(); - - Long startDate = null; - for (String key : features.keySet()) { - JsonObject asJsonObject = features.get(key).getAsJsonObject(); - if (asJsonObject.has("startDate")) { - startDate = asJsonObject.get("startDate").getAsLong(); - break; - } - } - - Assert.assertNotNull(startDate); + SummaryEvent summaryEvent = summaryEventSharedPreferences.getSummaryEvent(); + assertNotNull(summaryEvent.startDate); } @Test @@ -84,7 +72,7 @@ public void counterIsUpdated() { ldClient.clearSummaryEventSharedPreferences(); ldClient.boolVariation("boolFlag", true); - JsonObject features = summaryEventSharedPreferences.getFeaturesJsonObject(); + JsonObject features = summaryEventSharedPreferences.getSummaryEvent().features; JsonArray counters = features.get("boolFlag").getAsJsonObject().get("counters").getAsJsonArray(); Assert.assertEquals(counters.size(), 1); @@ -94,7 +82,7 @@ public void counterIsUpdated() { Assert.assertEquals(counter.get("count").getAsInt(), 1); ldClient.boolVariation("boolFlag", true); - features = summaryEventSharedPreferences.getFeaturesJsonObject(); + features = summaryEventSharedPreferences.getSummaryEvent().features; counters = features.get("boolFlag").getAsJsonObject().get("counters").getAsJsonArray(); Assert.assertEquals(counters.size(), 1); @@ -115,7 +103,7 @@ public void evaluationsAreSaved() { ldClient.intVariation("intFlag", 6); ldClient.stringVariation("stringFlag", "string"); - JsonObject features = summaryEventSharedPreferences.getFeaturesJsonObject(); + JsonObject features = summaryEventSharedPreferences.getSummaryEvent().features; Assert.assertTrue(features.keySet().contains("boolFlag")); Assert.assertTrue(features.keySet().contains("jsonFlag")); @@ -138,16 +126,14 @@ public void sharedPreferencesAreCleared() { ldClient.boolVariation("boolFlag", true); ldClient.stringVariation("stringFlag", "string"); - JsonObject features = summaryEventSharedPreferences.getFeaturesJsonObject(); + JsonObject features = summaryEventSharedPreferences.getSummaryEvent().features; Assert.assertTrue(features.keySet().contains("boolFlag")); Assert.assertTrue(features.keySet().contains("stringFlag")); ldClient.clearSummaryEventSharedPreferences(); - features = summaryEventSharedPreferences.getFeaturesJsonObject(); - - Assert.assertFalse(features.keySet().contains("boolFlag")); - Assert.assertFalse(features.keySet().contains("stringFlag")); + SummaryEvent summaryEvent = summaryEventSharedPreferences.getSummaryEvent(); + assertNull(summaryEvent); } } diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagBuilder.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagBuilder.java new file mode 100644 index 00000000..7d021494 --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagBuilder.java @@ -0,0 +1,62 @@ +package com.launchdarkly.android.flagstore; + +import android.support.annotation.NonNull; + +import com.google.gson.JsonElement; +import com.launchdarkly.android.EvaluationReason; + +public class FlagBuilder { + + @NonNull + private String key; + private JsonElement value = null; + private Integer version = null; + private Integer flagVersion = null; + private Integer variation = null; + private Boolean trackEvents = null; + private Long debugEventsUntilDate = null; + private EvaluationReason reason = null; + + public FlagBuilder(@NonNull String key) { + this.key = key; + } + + public FlagBuilder value(JsonElement value) { + this.value = value; + return this; + } + + public FlagBuilder version(Integer version) { + this.version = version; + return this; + } + + public FlagBuilder flagVersion(Integer flagVersion) { + this.flagVersion = flagVersion; + return this; + } + + public FlagBuilder variation(Integer variation) { + this.variation = variation; + return this; + } + + public FlagBuilder trackEvents(Boolean trackEvents) { + this.trackEvents = trackEvents; + return this; + } + + public FlagBuilder debugEventsUntilDate(Long debugEventsUntilDate) { + this.debugEventsUntilDate = debugEventsUntilDate; + return this; + } + + public FlagBuilder reason(EvaluationReason reason) { + this.reason = reason; + return this; + } + + public Flag build() { + return new Flag(key, value, version, flagVersion, variation, trackEvents, debugEventsUntilDate, reason); + } +} diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagStoreManagerTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagStoreManagerTest.java new file mode 100644 index 00000000..13f0b5e7 --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagStoreManagerTest.java @@ -0,0 +1,273 @@ +package com.launchdarkly.android.flagstore; + +import android.os.Looper; +import android.support.annotation.NonNull; + +import com.launchdarkly.android.FeatureFlagChangeListener; + +import org.easymock.Capture; +import org.easymock.EasyMockSupport; +import org.easymock.IAnswer; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.anyString; +import static org.easymock.EasyMock.capture; +import static org.easymock.EasyMock.checkOrder; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.easymock.EasyMock.isA; +import static org.easymock.EasyMock.newCapture; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public abstract class FlagStoreManagerTest extends EasyMockSupport { + + public abstract FlagStoreManager createFlagStoreManager(String mobileKey, FlagStoreFactory flagStoreFactory); + + @Test + public void initialFlagStoreIsNull() { + final FlagStoreManager manager = createFlagStoreManager("testKey", null); + assertNull(manager.getCurrentUserStore()); + } + + @Test + public void testSwitchToUser() { + final FlagStoreFactory mockCreate = strictMock(FlagStoreFactory.class); + final FlagStore mockStore = strictMock(FlagStore.class); + final FlagStoreManager manager = createFlagStoreManager("testKey", mockCreate); + + expect(mockCreate.createFlagStore(anyString())).andReturn(mockStore); + mockStore.registerOnStoreUpdatedListener(isA(StoreUpdatedListener.class)); + + replayAll(); + + manager.switchToUser("user1"); + + verifyAll(); + + assertSame(mockStore, manager.getCurrentUserStore()); + } + + @Test + public void deletesOlderThanLastFiveStoredUsers() { + final FlagStoreFactory mockCreate = strictMock(FlagStoreFactory.class); + final FlagStore oldestStore = strictMock(FlagStore.class); + final FlagStore fillerStore = strictMock(FlagStore.class); + final FlagStoreManager manager = createFlagStoreManager("testKey", mockCreate); + final Capture oldestIdentifier = newCapture(); + final int[] oldestCountBox = {0}; + + checkOrder(fillerStore, false); + fillerStore.registerOnStoreUpdatedListener(anyObject(StoreUpdatedListener.class)); + expectLastCall().anyTimes(); + fillerStore.unregisterOnStoreUpdatedListener(); + expectLastCall().anyTimes(); + //noinspection ConstantConditions + expect(mockCreate.createFlagStore(capture(oldestIdentifier))).andReturn(oldestStore); + oldestStore.registerOnStoreUpdatedListener(anyObject(StoreUpdatedListener.class)); + expectLastCall().anyTimes(); + oldestStore.unregisterOnStoreUpdatedListener(); + expectLastCall().anyTimes(); + expect(mockCreate.createFlagStore(anyString())).andReturn(fillerStore); + expect(mockCreate.createFlagStore(anyString())).andReturn(fillerStore); + expect(mockCreate.createFlagStore(anyString())).andReturn(fillerStore); + expect(mockCreate.createFlagStore(anyString())).andReturn(fillerStore); + expect(mockCreate.createFlagStore(anyString())).andDelegateTo(new FlagStoreFactory() { + @Override + public FlagStore createFlagStore(@NonNull String identifier) { + if (identifier.equals(oldestIdentifier.getValue())) { + oldestCountBox[0]++; + return oldestStore; + } + return fillerStore; + } + }); + expect(mockCreate.createFlagStore(anyString())).andDelegateTo(new FlagStoreFactory() { + @Override + public FlagStore createFlagStore(@NonNull String identifier) { + if (identifier.equals(oldestIdentifier.getValue())) { + oldestCountBox[0]++; + return oldestStore; + } + return fillerStore; + } + }); + oldestStore.delete(); + expectLastCall(); + + replayAll(); + + manager.switchToUser("oldest"); + manager.switchToUser("fourth"); + manager.switchToUser("third"); + manager.switchToUser("second"); + manager.switchToUser("first"); + manager.switchToUser("new"); + + verifyAll(); + + assertEquals(1, oldestCountBox[0]); + } + + @Test + public void testGetListenersForKey() { + final FlagStoreFactory mockCreate = strictMock(FlagStoreFactory.class); + final FlagStoreManager manager = createFlagStoreManager("testKey", mockCreate); + final FeatureFlagChangeListener mockFlagListener = strictMock(FeatureFlagChangeListener.class); + final FeatureFlagChangeListener mockFlagListener2 = strictMock(FeatureFlagChangeListener.class); + + assertEquals(0, manager.getListenersByKey("flag").size()); + manager.registerListener("flag", mockFlagListener); + assertEquals(1, manager.getListenersByKey("flag").size()); + assertTrue(manager.getListenersByKey("flag").contains(mockFlagListener)); + assertEquals(0, manager.getListenersByKey("otherKey").size()); + manager.registerListener("flag", mockFlagListener2); + assertEquals(2, manager.getListenersByKey("flag").size()); + assertTrue(manager.getListenersByKey("flag").contains(mockFlagListener)); + assertTrue(manager.getListenersByKey("flag").contains(mockFlagListener2)); + assertEquals(0, manager.getListenersByKey("otherKey").size()); + } + + @Test + public void listenerIsCalledOnCreate() throws InterruptedException { + final FlagStoreFactory mockCreate = strictMock(FlagStoreFactory.class); + final FlagStore mockStore = strictMock(FlagStore.class); + final FlagStoreManager manager = createFlagStoreManager("testKey", mockCreate); + final FeatureFlagChangeListener mockFlagListener = strictMock(FeatureFlagChangeListener.class); + final Capture managerListener = newCapture(); + final CountDownLatch waitLatch = new CountDownLatch(1); + + expect(mockCreate.createFlagStore(anyString())).andReturn(mockStore); + mockStore.registerOnStoreUpdatedListener(capture(managerListener)); + mockFlagListener.onFeatureFlagChange("flag"); + expectLastCall().andAnswer(new IAnswer() { + @Override + public Void answer() { + waitLatch.countDown(); + return null; + } + }); + + replayAll(); + + manager.switchToUser("user1"); + manager.registerListener("flag", mockFlagListener); + managerListener.getValue().onStoreUpdate("flag", FlagStoreUpdateType.FLAG_CREATED); + waitLatch.await(1000, TimeUnit.MILLISECONDS); + + verifyAll(); + } + + @Test + public void listenerIsCalledOnUpdate() throws InterruptedException { + final FlagStoreFactory mockCreate = strictMock(FlagStoreFactory.class); + final FlagStore mockStore = strictMock(FlagStore.class); + final FlagStoreManager manager = createFlagStoreManager("testKey", mockCreate); + final FeatureFlagChangeListener mockFlagListener = strictMock(FeatureFlagChangeListener.class); + final Capture managerListener = newCapture(); + final CountDownLatch waitLatch = new CountDownLatch(1); + + expect(mockCreate.createFlagStore(anyString())).andReturn(mockStore); + mockStore.registerOnStoreUpdatedListener(capture(managerListener)); + mockFlagListener.onFeatureFlagChange("flag"); + expectLastCall().andAnswer(new IAnswer() { + @Override + public Void answer() { + waitLatch.countDown(); + return null; + } + }); + + replayAll(); + + manager.switchToUser("user1"); + manager.registerListener("flag", mockFlagListener); + managerListener.getValue().onStoreUpdate("flag", FlagStoreUpdateType.FLAG_UPDATED); + waitLatch.await(1000, TimeUnit.MILLISECONDS); + + verifyAll(); + } + + @Test + public void listenerIsNotCalledOnDelete() throws InterruptedException { + final FlagStoreFactory mockCreate = strictMock(FlagStoreFactory.class); + final FlagStore mockStore = strictMock(FlagStore.class); + final FlagStoreManager manager = createFlagStoreManager("testKey", mockCreate); + final FeatureFlagChangeListener mockFlagListener = strictMock(FeatureFlagChangeListener.class); + final Capture managerListener = newCapture(); + + expect(mockCreate.createFlagStore(anyString())).andReturn(mockStore); + mockStore.registerOnStoreUpdatedListener(capture(managerListener)); + + replayAll(); + + manager.switchToUser("user1"); + manager.registerListener("flag", mockFlagListener); + managerListener.getValue().onStoreUpdate("flag", FlagStoreUpdateType.FLAG_DELETED); + // Unfortunately we are testing that an asynchronous method is *not* called, we just have to + // wait a bit to be sure. + Thread.sleep(100); + + verifyAll(); + } + + @Test + public void listenerIsNotCalledAfterUnregistering() throws InterruptedException { + final FlagStoreFactory mockCreate = strictMock(FlagStoreFactory.class); + final FlagStore mockStore = strictMock(FlagStore.class); + final FlagStoreManager manager = createFlagStoreManager("testKey", mockCreate); + final FeatureFlagChangeListener mockFlagListener = strictMock(FeatureFlagChangeListener.class); + final Capture managerListener = newCapture(); + + expect(mockCreate.createFlagStore(anyString())).andReturn(mockStore); + mockStore.registerOnStoreUpdatedListener(capture(managerListener)); + + replayAll(); + + manager.switchToUser("user1"); + manager.registerListener("flag", mockFlagListener); + manager.unRegisterListener("flag", mockFlagListener); + managerListener.getValue().onStoreUpdate("flag", FlagStoreUpdateType.FLAG_CREATED); + // Unfortunately we are testing that an asynchronous method is *not* called, we just have to + // wait a bit to be sure. + Thread.sleep(100); + + verifyAll(); + } + + @Test + public void listenerIsCalledOnMainThread() throws InterruptedException { + final FlagStoreFactory mockCreate = strictMock(FlagStoreFactory.class); + final FlagStore mockStore = strictMock(FlagStore.class); + final FlagStoreManager manager = createFlagStoreManager("testKey", mockCreate); + final FeatureFlagChangeListener mockFlagListener = strictMock(FeatureFlagChangeListener.class); + final Capture managerListener = newCapture(); + final CountDownLatch waitLatch = new CountDownLatch(1); + + expect(mockCreate.createFlagStore(anyString())).andReturn(mockStore); + mockStore.registerOnStoreUpdatedListener(capture(managerListener)); + mockFlagListener.onFeatureFlagChange("flag"); + expectLastCall().andDelegateTo(new FeatureFlagChangeListener() { + @Override + public void onFeatureFlagChange(String flagKey) { + assertSame(Looper.myLooper(), Looper.getMainLooper()); + waitLatch.countDown(); + } + }); + + replayAll(); + + manager.switchToUser("user1"); + manager.registerListener("flag", mockFlagListener); + managerListener.getValue().onStoreUpdate("flag", FlagStoreUpdateType.FLAG_CREATED); + waitLatch.await(1000, TimeUnit.MILLISECONDS); + + verifyAll(); + } +} diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagStoreTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagStoreTest.java new file mode 100644 index 00000000..31e41de8 --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagStoreTest.java @@ -0,0 +1,344 @@ +package com.launchdarkly.android.flagstore; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.launchdarkly.android.EvaluationReason; + +import org.easymock.EasyMockSupport; +import org.easymock.IArgumentMatcher; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.reportMatcher; +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; + +public abstract class FlagStoreTest extends EasyMockSupport { + + public abstract FlagStore createFlagStore(String identifier); + + protected static class FlagMatcher implements IArgumentMatcher { + + private final Flag flag; + + FlagMatcher(Flag flag) { + this.flag = flag; + } + + @Override + public boolean matches(Object argument) { + if (argument == flag) { + return true; + } + if (argument instanceof Flag) { + Flag received = (Flag) argument; + return Objects.equals(flag.getKey(), received.getKey()) && + Objects.equals(flag.getValue(), received.getValue()) && + Objects.equals(flag.getVersion(), received.getVersion()) && + Objects.equals(flag.getFlagVersion(), received.getFlagVersion()) && + Objects.equals(flag.getVariation(), received.getVariation()) && + Objects.equals(flag.getTrackEvents(), received.getTrackEvents()) && + Objects.equals(flag.getDebugEventsUntilDate(), + received.getDebugEventsUntilDate()) && + Objects.equals(flag.getReason(), received.getReason()); + } + return false; + } + + @Override + public void appendTo(StringBuffer buffer) { + if (flag == null) { + buffer.append("null"); + } else { + buffer.append("Flag(\""); + buffer.append(flag.getKey()); + buffer.append("\")"); + } + } + } + + + private void assertExpectedFlag(Flag expected, Flag received) { + assertEquals(expected.getKey(), received.getKey()); + assertEquals(expected.getValue(), received.getValue()); + assertEquals(expected.getVersion(), received.getVersion()); + assertEquals(expected.getFlagVersion(), received.getFlagVersion()); + assertEquals(expected.getVariation(), received.getVariation()); + assertEquals(expected.getTrackEvents(), received.getTrackEvents()); + assertEquals(expected.getDebugEventsUntilDate(), received.getDebugEventsUntilDate()); + assertEquals(expected.getReason(), received.getReason()); + } + + private List makeTestFlags() { + // This test assumes that if the store correctly serializes and deserializes one kind of + // EvaluationReason, it can handle any kind, + // since the actual marshaling is being done by UserFlagResponse. Therefore, the other + // variants of EvaluationReason are tested by + // FlagTest. + final EvaluationReason reason = EvaluationReason.ruleMatch(1, "id"); + final JsonObject jsonObj = new JsonObject(); + jsonObj.add("bool", new JsonPrimitive(true)); + jsonObj.add("num", new JsonPrimitive(3.4)); + jsonObj.add("string", new JsonPrimitive("string")); + jsonObj.add("array", new JsonArray()); + jsonObj.add("obj", new JsonObject()); + final Flag testFlag1 = new FlagBuilder("testFlag1").build(); + final Flag testFlag2 = new FlagBuilder("testFlag2") + .value(new JsonArray()) + .version(2) + .debugEventsUntilDate(123456789L) + .trackEvents(true) + .build(); + final Flag testFlag3 = new Flag("testFlag3", jsonObj, 250, 102, 3, + false, 2500000000L, reason); + return Arrays.asList(testFlag1, testFlag2, testFlag3); + } + + private static Flag eqFlag(Flag in) { + reportMatcher(new FlagMatcher(in)); + return null; + } + + @Test + public void mockFlagCreateBehavior() { + final Flag initialFlag = new FlagBuilder("flag").build(); + + final FlagUpdate mockCreate = strictMock(FlagUpdate.class); + expect(mockCreate.flagToUpdate()).andReturn("flag"); + expect(mockCreate.updateFlag(eqFlag(null))).andReturn(initialFlag); + + final StoreUpdatedListener mockUpdateListener = strictMock(StoreUpdatedListener.class); + mockUpdateListener.onStoreUpdate("flag", FlagStoreUpdateType.FLAG_CREATED); + + replayAll(); + + final FlagStore underTest = createFlagStore("abc"); + underTest.registerOnStoreUpdatedListener(mockUpdateListener); + underTest.applyFlagUpdate(mockCreate); + + verifyAll(); + + assertEquals(1, underTest.getAllFlags().size()); + final Flag retrieved = underTest.getFlag("flag"); + assertNotNull(retrieved); + assertExpectedFlag(initialFlag, retrieved); + assertTrue(underTest.containsKey("flag")); + } + + @Test + public void mockFlagUpdateBehavior() { + final Flag initialFlag = new FlagBuilder("flag").build(); + final FlagStore underTest = createFlagStore("abc"); + underTest.applyFlagUpdate(initialFlag); + + final Flag updatedFlag = new FlagBuilder("flag").variation(5).build(); + final FlagUpdate mockUpdate = strictMock(FlagUpdate.class); + expect(mockUpdate.flagToUpdate()).andReturn("flag"); + expect(mockUpdate.updateFlag(eqFlag(initialFlag))).andReturn(updatedFlag); + + final StoreUpdatedListener mockUpdateListener = strictMock(StoreUpdatedListener.class); + mockUpdateListener.onStoreUpdate("flag", FlagStoreUpdateType.FLAG_UPDATED); + + replayAll(); + + underTest.registerOnStoreUpdatedListener(mockUpdateListener); + underTest.applyFlagUpdate(mockUpdate); + + verifyAll(); + + assertEquals(1, underTest.getAllFlags().size()); + final Flag retrieved = underTest.getFlag("flag"); + assertNotNull(retrieved); + assertExpectedFlag(updatedFlag, retrieved); + assertTrue(underTest.containsKey("flag")); + } + + @Test + public void mockFlagDeleteBehavior() { + final Flag initialFlag = new FlagBuilder("flag").build(); + final FlagStore underTest = createFlagStore("abc"); + underTest.applyFlagUpdate(initialFlag); + + final FlagUpdate mockDelete = strictMock(FlagUpdate.class); + expect(mockDelete.flagToUpdate()).andReturn("flag"); + expect(mockDelete.updateFlag(eqFlag(initialFlag))).andReturn(null); + + final StoreUpdatedListener mockUpdateListener = strictMock(StoreUpdatedListener.class); + mockUpdateListener.onStoreUpdate("flag", FlagStoreUpdateType.FLAG_DELETED); + + replayAll(); + + underTest.registerOnStoreUpdatedListener(mockUpdateListener); + underTest.applyFlagUpdate(mockDelete); + + verifyAll(); + + assertNull(underTest.getFlag("flag")); + assertEquals(0, underTest.getAllFlags().size()); + assertFalse(underTest.containsKey("flag")); + } + + @Test + public void testUnregisterStoreUpdate() { + final Flag initialFlag = new FlagBuilder("flag").build(); + + final FlagUpdate mockCreate = strictMock(FlagUpdate.class); + expect(mockCreate.flagToUpdate()).andReturn("flag"); + expect(mockCreate.updateFlag(eqFlag(null))).andReturn(initialFlag); + + final StoreUpdatedListener mockUpdateListener = strictMock(StoreUpdatedListener.class); + + replayAll(); + + final FlagStore underTest = createFlagStore("abc"); + underTest.registerOnStoreUpdatedListener(mockUpdateListener); + underTest.unregisterOnStoreUpdatedListener(); + underTest.applyFlagUpdate(mockCreate); + + // Verifies mockUpdateListener doesn't get a call + verifyAll(); + } + + @Test + public void savesAndRetrievesFlags() { + final List testFlags = makeTestFlags(); + FlagStore flagStore = createFlagStore("abc"); + + for (Flag flag : testFlags) { + flagStore.applyFlagUpdate(flag); + final Flag retrieved = flagStore.getFlag(flag.getKey()); + assertNotNull(retrieved); + assertExpectedFlag(flag, retrieved); + } + + // Get a new instance of FlagStore to test persistence (as best we can) + flagStore = createFlagStore("abc"); + for (Flag flag : testFlags) { + flagStore.applyFlagUpdate(flag); + final Flag retrieved = flagStore.getFlag(flag.getKey()); + assertNotNull(retrieved); + assertExpectedFlag(flag, retrieved); + } + } + + @Test + public void testGetAllFlags() { + final List testFlags = makeTestFlags(); + final FlagStore flagStore = createFlagStore("abc"); + + for (Flag flag : testFlags) { + flagStore.applyFlagUpdate(flag); + } + + final List allFlags = flagStore.getAllFlags(); + assertEquals(testFlags.size(), flagStore.getAllFlags().size()); + int matchCount = 0; + for (Flag flag : testFlags) { + for (Flag retrieved : allFlags) { + if (flag.getKey().equals(retrieved.getKey())) { + matchCount += 1; + assertExpectedFlag(flag, retrieved); + } + } + } + assertEquals(matchCount, testFlags.size()); + } + + @Test + public void testContainsKey() { + final List testFlags = makeTestFlags(); + final FlagStore flagStore = createFlagStore("abc"); + for (Flag flag : testFlags) { + assertFalse(flagStore.containsKey(flag.getKey())); + flagStore.applyFlagUpdate(flag); + assertTrue(flagStore.containsKey(flag.getKey())); + } + } + + @Test + public void testApplyFlagUpdates() { + final List testFlags = makeTestFlags(); + FlagStore flagStore = createFlagStore("abc"); + + flagStore.applyFlagUpdates(testFlags); + + // Get a new instance of FlagStore to test persistence (as best we can) + flagStore = createFlagStore("abc"); + assertEquals(testFlags.size(), flagStore.getAllFlags().size()); + for (Flag flag : testFlags) { + flagStore.applyFlagUpdate(flag); + final Flag retrieved = flagStore.getFlag(flag.getKey()); + assertNotNull(retrieved); + assertExpectedFlag(flag, retrieved); + } + } + + @Test + public void testClear() { + final List testFlags = makeTestFlags(); + FlagStore flagStore = createFlagStore("abc"); + + flagStore.applyFlagUpdates(testFlags); + flagStore.clear(); + + assertEquals(0, flagStore.getAllFlags().size()); + for (Flag flag : testFlags) { + final Flag retrieved = flagStore.getFlag(flag.getKey()); + assertNull(retrieved); + } + + // Get a new instance of FlagStore to test persistence (as best we can) + flagStore = createFlagStore("abc"); + assertEquals(0, flagStore.getAllFlags().size()); + for (Flag flag : testFlags) { + final Flag retrieved = flagStore.getFlag(flag.getKey()); + assertNull(retrieved); + } + } + + @Test + public void testClearAndApplyFlagUpdates() { + final List testFlags = makeTestFlags(); + final Flag initialFlag = new FlagBuilder("flag").build(); + FlagStore flagStore = createFlagStore("abc"); + + flagStore.applyFlagUpdate(initialFlag); + flagStore.clearAndApplyFlagUpdates(testFlags); + + flagStore = createFlagStore("abc"); + assertNull(flagStore.getFlag("flag")); + assertFalse(flagStore.containsKey("flag")); + assertEquals(testFlags.size(), flagStore.getAllFlags().size()); + for (Flag flag : testFlags) { + flagStore.applyFlagUpdate(flag); + final Flag retrieved = flagStore.getFlag(flag.getKey()); + assertNotNull(retrieved); + assertExpectedFlag(flag, retrieved); + } + } + + @Test + public void testDelete() { + final List testFlags = makeTestFlags(); + FlagStore flagStore = createFlagStore("abc"); + + flagStore.applyFlagUpdates(testFlags); + flagStore.clear(); + + // Get a new instance of FlagStore + flagStore = createFlagStore("abc"); + assertEquals(0, flagStore.getAllFlags().size()); + for (Flag flag : testFlags) { + final Flag retrieved = flagStore.getFlag(flag.getKey()); + assertNull(retrieved); + } + } +} diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagTest.java new file mode 100644 index 00000000..6d7fcd28 --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/FlagTest.java @@ -0,0 +1,293 @@ +package com.launchdarkly.android.flagstore; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.launchdarkly.android.EvaluationReason; +import com.launchdarkly.android.gson.GsonCache; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +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; + +public class FlagTest { + private static final Gson gson = GsonCache.getGson(); + + private static final Map TEST_REASONS = ImmutableMap.builder() + .put(EvaluationReason.off(), "{\"kind\": \"OFF\"}") + .put(EvaluationReason.fallthrough(), "{\"kind\": \"FALLTHROUGH\"}") + .put(EvaluationReason.targetMatch(), "{\"kind\": \"TARGET_MATCH\"}") + .put(EvaluationReason.ruleMatch(1, "id"), "{\"kind\": \"RULE_MATCH\", \"ruleIndex\": 1, \"ruleId\": \"id\"}") + .put(EvaluationReason.prerequisiteFailed("flag"), "{\"kind\": \"PREREQUISITE_FAILED\", \"prerequisiteKey\": \"flag\"}") + .put(EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND), "{\"kind\": \"ERROR\", \"errorKind\": \"FLAG_NOT_FOUND\"}") + .build(); + + @Test + public void keyIsSerialized() { + final Flag r = new FlagBuilder("flag").build(); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); + assertEquals(new JsonPrimitive("flag"), json.get("key")); + } + + @Test + public void keyIsDeserialized() { + final String jsonStr = "{\"key\": \"flag\"}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertEquals("flag", r.getKey()); + } + + @Test + public void valueIsSerialized() { + JsonElement jsonBool = new JsonPrimitive(true); + JsonElement jsonString = new JsonPrimitive("string"); + JsonElement jsonNum = new JsonPrimitive(5.3); + JsonArray jsonArray = new JsonArray(); + jsonArray.add(jsonBool); + jsonArray.add(jsonString); + jsonArray.add(jsonNum); + jsonArray.add(new JsonArray()); + jsonArray.add(new JsonObject()); + JsonObject jsonObj = new JsonObject(); + jsonObj.add("bool", jsonBool); + jsonObj.add("num", jsonNum); + jsonObj.add("string", jsonString); + jsonObj.add("array", jsonArray); + jsonObj.add("obj", new JsonObject()); + + List testValues = Arrays.asList(jsonBool, jsonString, jsonNum, jsonArray, jsonObj); + for (JsonElement value : testValues) { + final Flag r = new FlagBuilder("flag").value(value).build(); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); + assertEquals(value, json.get("value")); + } + } + + @Test + public void valueIsDeserialized() { + final String jsonStr = "{\"value\": \"yes\"}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertEquals(new JsonPrimitive("yes"), r.getValue()); + } + + @Test + public void valueDefaultWhenOmitted() { + final String jsonStr = "{\"key\": \"flag\"}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertNull(r.getValue()); + } + + @Test + public void versionIsSerialized() { + final Flag r = new FlagBuilder("flag").version(99).build(); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); + assertEquals(new JsonPrimitive(99), json.get("version")); + } + + @Test + public void versionIsDeserialized() { + final String jsonStr = "{\"version\": 99}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertNotNull(r.getVersion()); + assertEquals(99, (int) r.getVersion()); + } + + @Test + public void versionDefaultWhenOmitted() { + final String jsonStr = "{\"flagVersion\": 99}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertNull(r.getVersion()); + } + + @Test + public void flagVersionIsSerialized() { + final Flag r = new FlagBuilder("flag").flagVersion(100).build(); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); + assertEquals(new JsonPrimitive(100), json.get("flagVersion")); + } + + @Test + public void flagVersionIsDeserialized() { + final String jsonStr = "{\"version\": 99, \"flagVersion\": 100}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertNotNull(r.getFlagVersion()); + assertEquals(100, (int) r.getFlagVersion()); + } + + @Test + public void flagVersionDefaultWhenOmitted() { + final String jsonStr = "{\"version\": 99}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertNull(r.getFlagVersion()); + } + + @Test + public void variationIsSerialized() { + final Flag r = new FlagBuilder("flag").variation(2).build(); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); + assertEquals(new JsonPrimitive(2), json.get("variation")); + } + + @Test + public void variationIsDeserialized() { + final String jsonStr = "{\"version\": 99, \"variation\": 2}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertEquals(new Integer(2), r.getVariation()); + } + + @Test + public void variationDefaultWhenOmitted() { + final String jsonStr = "{\"version\": 99}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertNull(r.getVariation()); + } + + @Test + public void trackEventsIsSerialized() { + final Flag r = new FlagBuilder("flag").trackEvents(true).build(); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); + assertEquals(new JsonPrimitive(true), json.get("trackEvents")); + } + + @Test + public void trackEventsIsDeserialized() { + final String jsonStr = "{\"version\": 99, \"trackEvents\": true}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertTrue(r.getTrackEvents()); + } + + @Test + public void trackEventsDefaultWhenOmitted() { + final String jsonStr = "{\"version\": 99}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertFalse(r.getTrackEvents()); + } + + @Test + public void debugEventsUntilDateIsSerialized() { + final long date = 12345L; + final Flag r = new FlagBuilder("flag").debugEventsUntilDate(date).build(); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); + assertEquals(new JsonPrimitive(date), json.get("debugEventsUntilDate")); + + // Test long sized number + final long datel = 2500000000L; + final Flag rl = new FlagBuilder("flag").debugEventsUntilDate(datel).build(); + final JsonObject jsonl = gson.toJsonTree(rl).getAsJsonObject(); + assertEquals(new JsonPrimitive(datel), jsonl.get("debugEventsUntilDate")); + } + + @Test + public void debugEventsUntilDateIsDeserialized() { + final String jsonStr = "{\"version\": 99, \"debugEventsUntilDate\": 12345}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertEquals(new Long(12345L), r.getDebugEventsUntilDate()); + + // Test long sized number + final String jsonStrl = "{\"version\": 99, \"debugEventsUntilDate\": 2500000000}"; + final Flag rl = gson.fromJson(jsonStrl, Flag.class); + assertEquals(new Long(2500000000L), rl.getDebugEventsUntilDate()); + } + + @Test + public void debugEventsUntilDateDefaultWhenOmitted() { + final String jsonStr = "{\"version\": 99}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertNull(r.getDebugEventsUntilDate()); + } + + @Test + public void reasonIsSerialized() { + for (Map.Entry e : TEST_REASONS.entrySet()) { + final EvaluationReason reason = e.getKey(); + final String expectedJsonStr = e.getValue(); + final JsonObject expectedJson = gson.fromJson(expectedJsonStr, JsonObject.class); + final Flag r = new FlagBuilder("flag").reason(reason).build(); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); + assertEquals(expectedJson, json.get("reason")); + } + } + + @Test + public void reasonIsDeserialized() { + for (Map.Entry e : TEST_REASONS.entrySet()) { + final EvaluationReason reason = e.getKey(); + final String reasonJsonStr = e.getValue(); + final JsonObject reasonJson = gson.fromJson(reasonJsonStr, JsonObject.class); + final JsonObject json = new JsonObject(); + json.add("reason", reasonJson); + final Flag r = gson.fromJson(json, Flag.class); + assertEquals(reason, r.getReason()); + } + } + + @Test + public void reasonDefaultWhenOmitted() { + final String jsonStr = "{\"version\": 99}"; + final Flag r = gson.fromJson(jsonStr, Flag.class); + assertNull(r.getReason()); + } + + @Test + public void emptyPropertiesAreNotSerialized() { + final Flag r = new FlagBuilder("flag").value(new JsonPrimitive("yes")).version(99).flagVersion(100).trackEvents(false).build(); + final JsonObject json = gson.toJsonTree(r).getAsJsonObject(); + assertEquals(ImmutableSet.of("key", "trackEvents", "value", "version", "flagVersion"), json.keySet()); + } + + @Test + public void testIsVersionMissing() { + final Flag noVersion = new FlagBuilder("flag").build(); + final Flag withVersion = new FlagBuilder("flag").version(10).build(); + assertTrue(noVersion.isVersionMissing()); + assertFalse(withVersion.isVersionMissing()); + } + + @Test + public void testGetVersionForEvents() { + final Flag noVersions = new FlagBuilder("flag").build(); + final Flag withVersion = new FlagBuilder("flag").version(10).build(); + final Flag withFlagVersion = new FlagBuilder("flag").flagVersion(5).build(); + final Flag withBothVersions = new FlagBuilder("flag").version(10).flagVersion(5).build(); + + assertEquals(-1, noVersions.getVersionForEvents()); + assertEquals(10, withVersion.getVersionForEvents()); + assertEquals(5, withFlagVersion.getVersionForEvents()); + assertEquals(5, withBothVersions.getVersionForEvents()); + } + + @Test + public void flagToUpdateReturnsKey() { + final Flag flag = new FlagBuilder("flag").build(); + assertEquals(flag.getKey(), flag.flagToUpdate()); + } + + @Test + public void testUpdateFlag() { + final Flag flagNoVersion = new FlagBuilder("flagNoVersion").build(); + final Flag flagNoVersion2 = new FlagBuilder("flagNoVersion2").build(); + final Flag flagLowVersion = new FlagBuilder("flagLowVersion").version(50).build(); + final Flag flagSameVersion = new FlagBuilder("flagSameVersion").version(50).build(); + final Flag flagHighVersion = new FlagBuilder("flagHighVersion").version(100).build(); + + assertEquals(flagNoVersion, flagNoVersion.updateFlag(null)); + assertEquals(flagLowVersion, flagLowVersion.updateFlag(null)); + assertEquals(flagNoVersion, flagNoVersion.updateFlag(flagNoVersion2)); + assertEquals(flagLowVersion, flagLowVersion.updateFlag(flagNoVersion)); + assertEquals(flagNoVersion, flagNoVersion.updateFlag(flagLowVersion)); + assertEquals(flagSameVersion, flagLowVersion.updateFlag(flagSameVersion)); + assertEquals(flagHighVersion, flagHighVersion.updateFlag(flagLowVersion)); + assertEquals(flagHighVersion, flagLowVersion.updateFlag(flagHighVersion)); + } +} diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreFactoryTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreFactoryTest.java new file mode 100644 index 00000000..0b5f9494 --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreFactoryTest.java @@ -0,0 +1,30 @@ +package com.launchdarkly.android.flagstore.sharedprefs; + +import android.app.Application; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; + +import com.launchdarkly.android.flagstore.FlagStore; +import com.launchdarkly.android.test.TestActivity; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +public class SharedPrefsFlagStoreFactoryTest { + + @Rule + public final ActivityTestRule activityTestRule = + new ActivityTestRule<>(TestActivity.class, false, true); + + @Test + public void createsSharedPrefsFlagStore() { + Application application = activityTestRule.getActivity().getApplication(); + SharedPrefsFlagStoreFactory factory = new SharedPrefsFlagStoreFactory(application); + FlagStore flagStore = factory.createFlagStore("flagstore_factory_test"); + assertTrue(flagStore instanceof SharedPrefsFlagStore); + } +} diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreManagerTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreManagerTest.java new file mode 100644 index 00000000..a296c5bd --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreManagerTest.java @@ -0,0 +1,34 @@ +package com.launchdarkly.android.flagstore.sharedprefs; + +import android.app.Application; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; + +import com.launchdarkly.android.flagstore.FlagStoreFactory; +import com.launchdarkly.android.flagstore.FlagStoreManager; +import com.launchdarkly.android.flagstore.FlagStoreManagerTest; +import com.launchdarkly.android.test.TestActivity; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class SharedPrefsFlagStoreManagerTest extends FlagStoreManagerTest { + + private Application testApplication; + + @Rule + public final ActivityTestRule activityTestRule = + new ActivityTestRule<>(TestActivity.class, false, true); + + @Before + public void setUp() { + this.testApplication = activityTestRule.getActivity().getApplication(); + } + + public FlagStoreManager createFlagStoreManager(String mobileKey, FlagStoreFactory flagStoreFactory) { + return new SharedPrefsFlagStoreManager(testApplication, mobileKey, flagStoreFactory); + } + +} diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreTest.java new file mode 100644 index 00000000..050fec7f --- /dev/null +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreTest.java @@ -0,0 +1,203 @@ +package com.launchdarkly.android.flagstore.sharedprefs; + +import android.app.Application; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; + +import com.launchdarkly.android.EvaluationReason; +import com.launchdarkly.android.flagstore.Flag; +import com.launchdarkly.android.flagstore.FlagBuilder; +import com.launchdarkly.android.flagstore.FlagStore; +import com.launchdarkly.android.flagstore.FlagStoreTest; +import com.launchdarkly.android.flagstore.FlagUpdate; +import com.launchdarkly.android.response.DeleteFlagResponse; +import com.launchdarkly.android.test.TestActivity; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@RunWith(AndroidJUnit4.class) +public class SharedPrefsFlagStoreTest extends FlagStoreTest { + + private Application testApplication; + + @Rule + public final ActivityTestRule activityTestRule = + new ActivityTestRule<>(TestActivity.class, false, true); + + @Before + public void setUp() { + this.testApplication = activityTestRule.getActivity().getApplication(); + } + + public FlagStore createFlagStore(String identifier) { + return new SharedPrefsFlagStore(testApplication, identifier); + } + + @Test + public void savesVersions() { + final Flag key1 = new FlagBuilder("key1").version(12).build(); + final Flag key2 = new FlagBuilder("key2").version(null).build(); + + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2)); + + assertEquals(flagStore.getFlag(key1.getKey()).getVersion(), 12, 0); + assertNull(flagStore.getFlag(key2.getKey()).getVersion()); + } + + @Test + public void deletesVersions() { + final Flag key1 = new FlagBuilder("key1").version(12).build(); + + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); + flagStore.applyFlagUpdates(Collections.singletonList(key1)); + flagStore.applyFlagUpdate(new DeleteFlagResponse(key1.getKey(), null)); + + Assert.assertNull(flagStore.getFlag(key1.getKey())); + } + + @Test + public void updatesVersions() { + final Flag key1 = new FlagBuilder("key1").version(12).build(); + final Flag updatedKey1 = new FlagBuilder(key1.getKey()).version(15).build(); + + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); + flagStore.applyFlagUpdates(Collections.singletonList(key1)); + + flagStore.applyFlagUpdate(updatedKey1); + + assertEquals(flagStore.getFlag(key1.getKey()).getVersion(), 15, 0); + } + + @Test + public void clearsFlags() { + final Flag key1 = new FlagBuilder("key1").version(12).build(); + final Flag key2 = new FlagBuilder("key2").version(14).build(); + + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2)); + flagStore.clear(); + + Assert.assertNull(flagStore.getFlag(key1.getKey())); + Assert.assertNull(flagStore.getFlag(key2.getKey())); + assertEquals(0, flagStore.getAllFlags().size(), 0); + } + + @Test + public void savesVariation() { + final Flag key1 = new FlagBuilder("key1").variation(16).build(); + final Flag key2 = new FlagBuilder("key2").variation(null).build(); + + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2)); + + assertEquals(flagStore.getFlag(key1.getKey()).getVariation(), 16, 0); + assertNull(flagStore.getFlag(key2.getKey()).getVariation()); + } + + @Test + public void savesTrackEvents() { + final Flag key1 = new FlagBuilder("key1").trackEvents(false).build(); + final Flag key2 = new FlagBuilder("key2").trackEvents(true).build(); + final Flag key3 = new FlagBuilder("key3").trackEvents(null).build(); + + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2, key3)); + + assertEquals(flagStore.getFlag(key1.getKey()).getTrackEvents(), false); + assertEquals(flagStore.getFlag(key2.getKey()).getTrackEvents(), true); + Assert.assertFalse(flagStore.getFlag(key3.getKey()).getTrackEvents()); + } + + @Test + public void savesDebugEventsUntilDate() { + final Flag key1 = new FlagBuilder("key1").debugEventsUntilDate(123456789L).build(); + final Flag key2 = new FlagBuilder("key2").debugEventsUntilDate(2500000000L).build(); + final Flag key3 = new FlagBuilder("key3").debugEventsUntilDate(null).build(); + + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2, key3)); + + assertEquals(flagStore.getFlag(key1.getKey()).getDebugEventsUntilDate(), 123456789L, 0); + assertEquals(flagStore.getFlag(key2.getKey()).getDebugEventsUntilDate(), 2500000000L, 0); + Assert.assertNull(flagStore.getFlag(key3.getKey()).getDebugEventsUntilDate()); + } + + + @Test + public void savesFlagVersions() { + final Flag key1 = new FlagBuilder("key1").flagVersion(12).build(); + final Flag key2 = new FlagBuilder("key2").flagVersion(null).build(); + + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); + flagStore.applyFlagUpdates(Arrays.asList(key1, key2)); + + assertEquals(flagStore.getFlag(key1.getKey()).getFlagVersion(), 12, 0); + assertNull(flagStore.getFlag(key2.getKey()).getFlagVersion()); + } + + @Test + public void deletesFlagVersions() { + final Flag key1 = new FlagBuilder("key1").flagVersion(12).build(); + + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); + flagStore.applyFlagUpdates(Collections.singletonList(key1)); + flagStore.applyFlagUpdate(new DeleteFlagResponse(key1.getKey(), null)); + + Assert.assertNull(flagStore.getFlag(key1.getKey())); + } + + @Test + public void updatesFlagVersions() { + final Flag key1 = new FlagBuilder("key1").flagVersion(12).build(); + final Flag updatedKey1 = new FlagBuilder(key1.getKey()).flagVersion(15).build(); + + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); + flagStore.applyFlagUpdates(Collections.singletonList(key1)); + + flagStore.applyFlagUpdate(updatedKey1); + + assertEquals(flagStore.getFlag(key1.getKey()).getFlagVersion(), 15, 0); + } + + @Test + public void versionForEventsReturnsFlagVersionIfPresentOtherwiseReturnsVersion() { + final Flag withFlagVersion = + new FlagBuilder("withFlagVersion").version(12).flagVersion(13).build(); + final Flag withOnlyVersion = new FlagBuilder("withOnlyVersion").version(12).build(); + + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); + flagStore.applyFlagUpdates(Arrays.asList(withFlagVersion, withOnlyVersion)); + + assertEquals(flagStore.getFlag(withFlagVersion.getKey()).getVersionForEvents(), 13, 0); + assertEquals(flagStore.getFlag(withOnlyVersion.getKey()).getVersionForEvents(), 12, 0); + } + + @Test + public void savesReasons() { + // This test assumes that if the store correctly serializes and deserializes one kind of + // EvaluationReason, it can handle any kind, + // since the actual marshaling is being done by UserFlagResponse. Therefore, the other + // variants of EvaluationReason are tested by + // FlagTest. + final EvaluationReason reason = EvaluationReason.ruleMatch(1, "id"); + final Flag flag1 = new FlagBuilder("key1").reason(reason).build(); + final Flag flag2 = new FlagBuilder("key2").build(); + + final SharedPrefsFlagStore flagStore = new SharedPrefsFlagStore(testApplication, "abc"); + flagStore.applyFlagUpdates(Arrays.asList(flag1, flag2)); + + assertEquals(reason, flagStore.getFlag(flag1.getKey()).getReason()); + assertNull(flagStore.getFlag(flag2.getKey()).getReason()); + } +} \ No newline at end of file diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferencesTest.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferencesTest.java deleted file mode 100644 index ed0a7acc..00000000 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferencesTest.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.launchdarkly.android.response; - -import android.support.test.rule.ActivityTestRule; -import android.support.test.runner.AndroidJUnit4; - -import com.google.gson.JsonPrimitive; -import com.launchdarkly.android.test.TestActivity; - -import org.junit.Assert; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.Arrays; -import java.util.Collections; - -@RunWith(AndroidJUnit4.class) -public class UserFlagResponseSharedPreferencesTest { - - @Rule - public final ActivityTestRule activityTestRule = - new ActivityTestRule<>(TestActivity.class, false, true); - - @Test - public void savesVersions() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1); - final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true)); - - FlagResponseSharedPreferences versionSharedPreferences - = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); - versionSharedPreferences.saveAll(Arrays.asList(key1, key2)); - - Assert.assertEquals(versionSharedPreferences.getStoredVersion(key1.getKey()), 12, 0); - Assert.assertEquals(versionSharedPreferences.getStoredVersion(key2.getKey()), -1, 0); - } - - @Test - public void deletesVersions() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1); - - FlagResponseSharedPreferences versionSharedPreferences - = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); - versionSharedPreferences.saveAll(Collections.singletonList(key1)); - versionSharedPreferences.deleteStoredFlagResponse(key1); - - Assert.assertEquals(versionSharedPreferences.getStoredVersion(key1.getKey()), -1, 0); - } - - @Test - public void updatesVersions() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1); - UserFlagResponse updatedKey1 = new UserFlagResponse(key1.getKey(), key1.getValue(), 15, -1); - - FlagResponseSharedPreferences versionSharedPreferences - = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); - versionSharedPreferences.saveAll(Collections.singletonList(key1)); - - versionSharedPreferences.updateStoredFlagResponse(updatedKey1); - - Assert.assertEquals(versionSharedPreferences.getStoredVersion(key1.getKey()), 15, 0); - } - - @Test - public void clearsFlags() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1); - final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true), 14, -1); - - UserFlagResponseSharedPreferences versionSharedPreferences - = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); - versionSharedPreferences.saveAll(Arrays.asList(key1, key2)); - versionSharedPreferences.clear(); - - Assert.assertEquals(versionSharedPreferences.getStoredVersion(key1.getKey()), -1, 0); - Assert.assertEquals(versionSharedPreferences.getStoredVersion(key2.getKey()), -1, 0); - Assert.assertEquals(0, versionSharedPreferences.getLength(), 0); - } - - @Test - public void savesVariation() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1, 16, null, null); - final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true), 14, -1, 23, null, null); - final UserFlagResponse key3 = new UserFlagResponse("key3", new JsonPrimitive(true), 16, -1); - - FlagResponseSharedPreferences versionSharedPreferences - = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); - versionSharedPreferences.saveAll(Arrays.asList(key1, key2, key3)); - - Assert.assertEquals(versionSharedPreferences.getStoredVariation(key1.getKey()), 16, 0); - Assert.assertEquals(versionSharedPreferences.getStoredVariation(key2.getKey()), 23, 0); - Assert.assertEquals(versionSharedPreferences.getStoredVariation(key3.getKey()), -1, 0); - } - - @Test - public void savesTrackEvents() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1, 16, false, 123456789L); - final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true), 14, -1, 23, true, 987654321L); - final UserFlagResponse key3 = new UserFlagResponse("key3", new JsonPrimitive(true), 16, -1); - - FlagResponseSharedPreferences versionSharedPreferences - = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); - versionSharedPreferences.saveAll(Arrays.asList(key1, key2, key3)); - - Assert.assertEquals(versionSharedPreferences.getStoredTrackEvents(key1.getKey()), false); - Assert.assertEquals(versionSharedPreferences.getStoredTrackEvents(key2.getKey()), true); - Assert.assertFalse(versionSharedPreferences.getStoredTrackEvents(key3.getKey())); - } - - @Test - public void savesDebugEventsUntilDate() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), 12, -1, 16, false, 123456789L); - final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true), 14, -1, 23, true, 987654321L); - final UserFlagResponse key3 = new UserFlagResponse("key3", new JsonPrimitive(true), 16, -1); - - FlagResponseSharedPreferences versionSharedPreferences - = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); - versionSharedPreferences.saveAll(Arrays.asList(key1, key2, key3)); - - //noinspection ConstantConditions - Assert.assertEquals(versionSharedPreferences.getStoredDebugEventsUntilDate(key1.getKey()), 123456789L, 0); - //noinspection ConstantConditions - Assert.assertEquals(versionSharedPreferences.getStoredDebugEventsUntilDate(key2.getKey()), 987654321L, 0); - Assert.assertNull(versionSharedPreferences.getStoredDebugEventsUntilDate(key3.getKey())); - } - - - @Test - public void savesFlagVersions() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), -1, 12); - final UserFlagResponse key2 = new UserFlagResponse("key2", new JsonPrimitive(true)); - - FlagResponseSharedPreferences versionSharedPreferences - = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); - versionSharedPreferences.saveAll(Arrays.asList(key1, key2)); - - Assert.assertEquals(versionSharedPreferences.getStoredFlagVersion(key1.getKey()), 12, 0); - Assert.assertEquals(versionSharedPreferences.getStoredFlagVersion(key2.getKey()), -1, 0); - } - - @Test - public void deletesFlagVersions() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), -1, 12); - - FlagResponseSharedPreferences versionSharedPreferences - = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); - versionSharedPreferences.saveAll(Collections.singletonList(key1)); - versionSharedPreferences.deleteStoredFlagResponse(key1); - - Assert.assertEquals(versionSharedPreferences.getStoredFlagVersion(key1.getKey()), -1, 0); - } - - @Test - public void updatesFlagVersions() { - final UserFlagResponse key1 = new UserFlagResponse("key1", new JsonPrimitive(true), -1, 12); - UserFlagResponse updatedKey1 = new UserFlagResponse(key1.getKey(), key1.getValue(), -1, 15); - - FlagResponseSharedPreferences versionSharedPreferences - = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); - versionSharedPreferences.saveAll(Collections.singletonList(key1)); - - versionSharedPreferences.updateStoredFlagResponse(updatedKey1); - - Assert.assertEquals(versionSharedPreferences.getStoredFlagVersion(key1.getKey()), 15, 0); - } - - @Test - public void versionForEventsReturnsFlagVersionIfPresentOtherwiseReturnsVersion() { - final UserFlagResponse withFlagVersion = new UserFlagResponse("withFlagVersion", new JsonPrimitive(true), 12, 13); - final UserFlagResponse withOnlyVersion = new UserFlagResponse("withOnlyVersion", new JsonPrimitive(true), 12, -1); - - FlagResponseSharedPreferences versionSharedPreferences - = new UserFlagResponseSharedPreferences(activityTestRule.getActivity().getApplication(), "abc"); - versionSharedPreferences.saveAll(Arrays.asList(withFlagVersion, withOnlyVersion)); - - Assert.assertEquals(versionSharedPreferences.getVersionForEvents(withFlagVersion.getKey()), 13, 0); - Assert.assertEquals(versionSharedPreferences.getVersionForEvents(withOnlyVersion.getKey()), 12, 0); - } - -} diff --git a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/test/TestActivity.java b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/test/TestActivity.java index e4f32e0c..f8569ddd 100644 --- a/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/test/TestActivity.java +++ b/launchdarkly-android-client/src/androidTest/java/com/launchdarkly/android/test/TestActivity.java @@ -1,7 +1,7 @@ package com.launchdarkly.android.test; -import android.support.v7.app.AppCompatActivity; import android.os.Bundle; +import android.support.v7.app.AppCompatActivity; public class TestActivity extends AppCompatActivity { diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/ConnectivityReceiver.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/ConnectivityReceiver.java index 8bd54470..bb3484c9 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/ConnectivityReceiver.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/ConnectivityReceiver.java @@ -14,6 +14,10 @@ public class ConnectivityReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { + if(!CONNECTIVITY_CHANGE.equals(intent.getAction())) { + return; + } + if (isInternetConnected(context)) { Timber.d("Connected to the internet"); try { diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Debounce.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Debounce.java index a2d19da2..01e7b30d 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Debounce.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Debounce.java @@ -1,7 +1,8 @@ package com.launchdarkly.android; - -import com.google.common.util.concurrent.*; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; import java.util.concurrent.Callable; import java.util.concurrent.Executors; @@ -13,8 +14,7 @@ public class Debounce { private volatile ListenableFuture inFlight; private volatile Callable pending; - ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); - + private ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); public synchronized void call(Callable task) { pending = task; @@ -30,19 +30,13 @@ private synchronized void schedulePending() { if (inFlight == null) { inFlight = service.submit(pending); pending = null; - Futures.addCallback(inFlight, new FutureCallback() { - - public void onSuccess(Void aVoid) { - inFlight = null; - schedulePending(); - } - - public void onFailure(Throwable throwable) { + inFlight.addListener(new Runnable() { + @Override + public void run() { inFlight = null; schedulePending(); } }, MoreExecutors.directExecutor()); - } } diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationDetail.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationDetail.java new file mode 100644 index 00000000..030c0276 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationDetail.java @@ -0,0 +1,85 @@ +package com.launchdarkly.android; + +import com.google.common.base.Objects; + +/** + * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetail(String, Boolean)}, + * combining the result of a flag evaluation with an explanation of how it was calculated. + * + * @since 2.7.0 + */ +public class EvaluationDetail { + + private final EvaluationReason reason; + private final Integer variationIndex; + private final T value; + + public EvaluationDetail(EvaluationReason reason, Integer variationIndex, T value) { + this.reason = reason; + this.variationIndex = variationIndex; + this.value = value; + } + + static EvaluationDetail error(EvaluationReason.ErrorKind errorKind, T defaultValue) { + return new EvaluationDetail<>(EvaluationReason.error(errorKind), null, defaultValue); + } + + /** + * An object describing the main factor that influenced the flag evaluation value. + * + * @return an {@link EvaluationReason} + */ + public EvaluationReason getReason() { + return reason; + } + + /** + * The index of the returned value within the flag's list of variations, e.g. 0 for the first variation - + * or {@code null} if the default value was returned. + * + * @return the variation index or null + */ + public Integer getVariationIndex() { + return variationIndex; + } + + /** + * The result of the flag evaluation. This will be either one of the flag's variations or the default + * value that was passed to the {@code variation} method. + * + * @return the flag value + */ + public T getValue() { + return value; + } + + /** + * Returns true if the flag evaluation returned the default value, rather than one of the flag's + * variations. + * + * @return true if this is the default value + */ + public boolean isDefaultValue() { + return variationIndex == null; + } + + @Override + public boolean equals(Object other) { + if (other instanceof EvaluationDetail) { + @SuppressWarnings("unchecked") + EvaluationDetail o = (EvaluationDetail) other; + return Objects.equal(reason, o.reason) && Objects.equal(variationIndex, o.variationIndex) && Objects.equal(value, o.value); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(reason, variationIndex, value); + } + + @Override + public String toString() { + return "{" + reason + "," + variationIndex + "," + value + "}"; + } +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java new file mode 100644 index 00000000..4ad16576 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EvaluationReason.java @@ -0,0 +1,345 @@ +package com.launchdarkly.android; + +import android.support.annotation.Nullable; + +import com.google.gson.annotations.Expose; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Describes the reason that a flag evaluation produced a particular value. This is returned by + * methods such as {@link LDClientInterface#boolVariationDetail(String, Boolean)}. + *

+ * Note that this is an enum-like class hierarchy rather than an enum, because some of the + * possible reasons have their own properties. + * + * @since 2.7.0 + */ +public abstract class EvaluationReason { + + /** + * Enumerated type defining the possible values of {@link EvaluationReason#getKind()}. + */ + public static enum Kind { + /** + * Indicates that the flag was off and therefore returned its configured off value. + */ + OFF, + /** + * Indicates that the flag was on but the user did not match any targets or rules. + */ + FALLTHROUGH, + /** + * Indicates that the user key was specifically targeted for this flag. + */ + TARGET_MATCH, + /** + * Indicates that the user matched one of the flag's rules. + */ + RULE_MATCH, + /** + * Indicates that the flag was considered off because it had at least one prerequisite flag + * that either was off or did not return the desired variation. + */ + PREREQUISITE_FAILED, + /** + * Indicates that the flag could not be evaluated, e.g. because it does not exist or due to an unexpected + * error. In this case the result value will be the default value that the caller passed to the client. + * Check the errorKind property for more details on the problem. + */ + ERROR, + /** + * Indicates that LaunchDarkly provided a Kind value that is not supported by this version of the SDK. + */ + UNKNOWN + } + + /** + * Enumerated type defining the possible values of {@link EvaluationReason.Error#getErrorKind()}. + */ + public static enum ErrorKind { + /** + * Indicates that the caller tried to evaluate a flag before the client had successfully initialized. + */ + CLIENT_NOT_READY, + /** + * Indicates that the caller provided a flag key that did not match any known flag. + */ + FLAG_NOT_FOUND, + /** + * Indicates that there was an internal inconsistency in the flag data, e.g. a rule specified a nonexistent + * variation. An error message will always be logged in this case. + */ + MALFORMED_FLAG, + /** + * Indicates that the caller passed {@code null} for the user parameter, or the user lacked a key. + */ + USER_NOT_SPECIFIED, + /** + * Indicates that the result value was not of the requested type, e.g. you called + * {@code boolVariationDetail()} but the value was an integer. + */ + WRONG_TYPE, + /** + * Indicates that an unexpected exception stopped flag evaluation. An error message will always be logged + * in this case. + */ + EXCEPTION, + /** + * Indicates that LaunchDarkly provided an ErrorKind value that is not supported by this version of the SDK. + */ + UNKNOWN + } + + @Expose + private final Kind kind; + + /** + * Returns an enum indicating the general category of the reason. + * + * @return a {@link Kind} value + */ + public Kind getKind() { + return kind; + } + + @Override + public String toString() { + return getKind().name(); + } + + protected EvaluationReason(Kind kind) { + this.kind = kind; + } + + /** + * Returns an instance of {@link Off}. + * + * @return a reason object + */ + public static Off off() { + return Off.instance; + } + + /** + * Returns an instance of {@link TargetMatch}. + * + * @return a reason object + */ + public static TargetMatch targetMatch() { + return TargetMatch.instance; + } + + /** + * Returns an instance of {@link RuleMatch}. + * + * @param ruleIndex the rule index + * @param ruleId the rule identifier + * @return a reason object + */ + public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { + return new RuleMatch(ruleIndex, ruleId); + } + + /** + * Returns an instance of {@link PrerequisiteFailed}. + * + * @param prerequisiteKey the flag key of the prerequisite that failed + * @return a reason object + */ + public static PrerequisiteFailed prerequisiteFailed(String prerequisiteKey) { + return new PrerequisiteFailed(prerequisiteKey); + } + + /** + * Returns an instance of {@link Fallthrough}. + * + * @return a reason object + */ + public static Fallthrough fallthrough() { + return Fallthrough.instance; + } + + /** + * Returns an instance of {@link Error}. + * + * @param errorKind describes the type of error + * @return a reason object + */ + public static Error error(ErrorKind errorKind) { + return new Error(errorKind); + } + + /** + * Returns an instance of {@link Unknown}. + * + * @return a reason object + */ + public static Unknown unknown() { + return Unknown.instance; + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the flag was off and therefore returned + * its configured off value. + */ + public static class Off extends EvaluationReason { + private Off() { + super(Kind.OFF); + } + + private static final Off instance = new Off(); + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the user key was specifically targeted + * for this flag. + */ + public static class TargetMatch extends EvaluationReason { + private TargetMatch() { + super(Kind.TARGET_MATCH); + } + + private static final TargetMatch instance = new TargetMatch(); + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the user matched one of the flag's rules. + */ + public static class RuleMatch extends EvaluationReason { + @Expose + private final int ruleIndex; + + @Expose + private final String ruleId; + + private RuleMatch(int ruleIndex, String ruleId) { + super(Kind.RULE_MATCH); + this.ruleIndex = ruleIndex; + this.ruleId = ruleId; + } + + public int getRuleIndex() { + return ruleIndex; + } + + public String getRuleId() { + return ruleId; + } + + @Override + public boolean equals(Object other) { + if (other instanceof RuleMatch) { + RuleMatch o = (RuleMatch) other; + return ruleIndex == o.ruleIndex && objectsEqual(ruleId, o.ruleId); + } + return false; + } + + @Override + public int hashCode() { + return (ruleIndex * 31) + (ruleId == null ? 0 : ruleId.hashCode()); + } + + @Override + public String toString() { + return getKind().name() + "(" + ruleIndex + (ruleId == null ? "" : ("," + ruleId)) + ")"; + } + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the flag was considered off because it + * had at least one prerequisite flag that either was off or did not return the desired variation. + */ + public static class PrerequisiteFailed extends EvaluationReason { + @Expose + private final String prerequisiteKey; + + private PrerequisiteFailed(String prerequisiteKey) { + super(Kind.PREREQUISITE_FAILED); + this.prerequisiteKey = checkNotNull(prerequisiteKey); + } + + public String getPrerequisiteKey() { + return prerequisiteKey; + } + + @Override + public boolean equals(Object other) { + return (other instanceof PrerequisiteFailed) && + ((PrerequisiteFailed) other).prerequisiteKey.equals(prerequisiteKey); + } + + @Override + public int hashCode() { + return prerequisiteKey.hashCode(); + } + + @Override + public String toString() { + return getKind().name() + "(" + prerequisiteKey + ")"; + } + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the flag was on but the user did not + * match any targets or rules. + */ + public static class Fallthrough extends EvaluationReason { + private Fallthrough() { + super(Kind.FALLTHROUGH); + } + + private static final Fallthrough instance = new Fallthrough(); + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the flag could not be evaluated. + */ + public static class Error extends EvaluationReason { + @Expose + private final ErrorKind errorKind; + + private Error(ErrorKind errorKind) { + super(Kind.ERROR); + checkNotNull(errorKind); + this.errorKind = errorKind; + } + + public ErrorKind getErrorKind() { + return errorKind; + } + + @Override + public boolean equals(Object other) { + return other instanceof Error && errorKind == ((Error) other).errorKind; + } + + @Override + public int hashCode() { + return errorKind.hashCode(); + } + + @Override + public String toString() { + return getKind().name() + "(" + errorKind.name() + ")"; + } + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the server sent a reason that is + * not supported by this version of the SDK. + */ + public static class Unknown extends EvaluationReason { + private Unknown() { + super(Kind.UNKNOWN); + } + + private static final Unknown instance = new Unknown(); + } + + // Android API v16 doesn't support Objects.equals() + private static boolean objectsEqual(@Nullable T a, @Nullable T b) { + return a == b || (a != null && b != null && a.equals(b)); + } +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Event.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Event.java index 14914d55..da9d4efa 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Event.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Event.java @@ -1,7 +1,7 @@ package com.launchdarkly.android; -import android.support.annotation.FloatRange; import android.support.annotation.IntRange; +import android.support.annotation.Nullable; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -75,6 +75,9 @@ class FeatureRequestEvent extends GenericEvent { @Expose Integer variation; + @Expose + EvaluationReason reason; + /** * Creates a FeatureRequestEvent which includes the full user object. * @@ -87,11 +90,12 @@ class FeatureRequestEvent extends GenericEvent { */ FeatureRequestEvent(String key, LDUser user, JsonElement value, JsonElement defaultVal, @IntRange(from=(0), to=(Integer.MAX_VALUE)) int version, - @IntRange(from=(0), to=(Integer.MAX_VALUE)) int variation) { + @Nullable Integer variation, + @Nullable EvaluationReason reason) { super("feature", key, user); this.value = value; this.defaultVal = defaultVal; - setOptionalValues(version, variation); + setOptionalValues(version, variation, reason); } @@ -107,33 +111,36 @@ class FeatureRequestEvent extends GenericEvent { */ FeatureRequestEvent(String key, String userKey, JsonElement value, JsonElement defaultVal, @IntRange(from=(0), to=(Integer.MAX_VALUE)) int version, - @IntRange(from=(0), to=(Integer.MAX_VALUE)) int variation) { + @Nullable Integer variation, + @Nullable EvaluationReason reason) { super("feature", key, null); this.value = value; this.defaultVal = defaultVal; this.userKey = userKey; - setOptionalValues(version, variation); + setOptionalValues(version, variation, reason); } - private void setOptionalValues(int version, int variation) { + private void setOptionalValues(int version, @Nullable Integer variation, @Nullable EvaluationReason reason) { if (version != -1) { this.version = version; } else { Timber.d("Feature Event: Ignoring version for flag: %s", key); } - if (variation != -1) { + if (variation != null) { this.variation = variation; } else { Timber.d("Feature Event: Ignoring variation for flag: %s", key); } + + this.reason = reason; } } class DebugEvent extends FeatureRequestEvent { - DebugEvent(String key, LDUser user, JsonElement value, JsonElement defaultVal, @IntRange(from=(0), to=(Integer.MAX_VALUE)) int version, @IntRange(from=(0), to=(Integer.MAX_VALUE)) int variation) { - super(key, user, value, defaultVal, version, variation); + DebugEvent(String key, LDUser user, JsonElement value, JsonElement defaultVal, @IntRange(from=(0), to=(Integer.MAX_VALUE)) int version, @Nullable Integer variation, @Nullable EvaluationReason reason) { + super(key, user, value, defaultVal, version, variation, reason); this.kind = "debug"; } } diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EventProcessor.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EventProcessor.java index 62e08398..4c21b62c 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EventProcessor.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/EventProcessor.java @@ -4,7 +4,6 @@ import android.os.Build; import com.google.common.util.concurrent.ThreadFactoryBuilder; -import com.launchdarkly.android.response.SummaryEventSharedPreferences; import com.launchdarkly.android.tls.ModernTLSSocketFactory; import com.launchdarkly.android.tls.SSLHandshakeInterceptor; import com.launchdarkly.android.tls.TLSUtils; @@ -43,7 +42,6 @@ class EventProcessor implements Closeable { private final LDConfig config; private final String environmentName; private ScheduledExecutorService scheduler; - private SummaryEvent summaryEvent = null; private final SummaryEventSharedPreferences summaryEventSharedPreferences; private long currentTimeMs = System.currentTimeMillis(); @@ -92,12 +90,8 @@ boolean sendEvent(Event e) { return queue.offer(e); } - void setSummaryEvent(SummaryEvent summaryEvent) { - this.summaryEvent = summaryEvent; - } - @Override - public void close() throws IOException { + public void close() { stop(); flush(); } @@ -122,14 +116,14 @@ public void run() { flush(); } - public synchronized void flush() { + synchronized void flush() { if (isClientConnected(context, environmentName)) { List events = new ArrayList<>(queue.size() + 1); queue.drainTo(events); + SummaryEvent summaryEvent = summaryEventSharedPreferences.getSummaryEventAndClear(); + if (summaryEvent != null) { events.add(summaryEvent); - summaryEvent = null; - summaryEventSharedPreferences.clear(); } if (!events.isEmpty()) { diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/FeatureFlagChangeListener.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/FeatureFlagChangeListener.java index 163ac1cf..3791ddf3 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/FeatureFlagChangeListener.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/FeatureFlagChangeListener.java @@ -2,7 +2,16 @@ /** * Callback interface used for listening to changes to a feature flag. + * + * @see LDClientInterface#registerFeatureFlagListener(String, FeatureFlagChangeListener) */ public interface FeatureFlagChangeListener { + /** + * The SDK calls this method when a feature flag value has changed for the current user. + *

+ * To obtain the new value, call one of the client methods such as {@link LDClientInterface#boolVariation(String, Boolean)}. + * + * @param flagKey the feature flag key + */ void onFeatureFlagChange(String flagKey); } diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Foreground.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Foreground.java index 2f85a8ef..2ddc313b 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Foreground.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Foreground.java @@ -48,7 +48,7 @@ */ class Foreground implements Application.ActivityLifecycleCallbacks { - static final long CHECK_DELAY = 500; + private static final long CHECK_DELAY = 500; interface Listener { diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/HttpFeatureFlagFetcher.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/HttpFeatureFlagFetcher.java index 296205ef..f2284555 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/HttpFeatureFlagFetcher.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/HttpFeatureFlagFetcher.java @@ -91,7 +91,7 @@ public void onFailure(@NonNull Call call, @NonNull IOException e) { } @Override - public void onResponse(@NonNull Call call, @NonNull final Response response) throws IOException { + public void onResponse(@NonNull Call call, @NonNull final Response response) { String body = ""; try { ResponseBody responseBody = response.body(); @@ -135,23 +135,27 @@ public void onResponse(@NonNull Call call, @NonNull final Response response) thr private Request getDefaultRequest(LDUser user) { String uri = config.getBaseUri() + "/msdk/evalx/users/" + user.getAsUrlSafeBase64(); + if (config.isEvaluationReasons()) { + uri += "?withReasons=true"; + } Timber.d("Attempting to fetch Feature flags using uri: %s", uri); - final Request request = config.getRequestBuilderFor(environmentName) // default GET verb + return config.getRequestBuilderFor(environmentName) // default GET verb .url(uri) .build(); - return request; } private Request getReportRequest(LDUser user) { String reportUri = config.getBaseUri() + "/msdk/evalx/user"; + if (config.isEvaluationReasons()) { + reportUri += "?withReasons=true"; + } Timber.d("Attempting to report user using uri: %s", reportUri); String userJson = GSON.toJson(user); RequestBody reportBody = RequestBody.create(MediaType.parse("application/json;charset=UTF-8"), userJson); - final Request report = config.getRequestBuilderFor(environmentName) + return config.getRequestBuilderFor(environmentName) .method("REPORT", reportBody) // custom REPORT verb .url(reportUri) .build(); - return report; } diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClient.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClient.java index f2f6f7d0..fc904ca6 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClient.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClient.java @@ -9,32 +9,21 @@ import android.os.Build; import android.support.annotation.NonNull; -import com.google.android.gms.common.GooglePlayServicesNotAvailableException; -import com.google.android.gms.common.GooglePlayServicesRepairableException; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Function; -import com.google.common.base.Preconditions; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.SettableFuture; import com.google.gson.JsonElement; -import com.google.gson.JsonNull; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.JsonPrimitive; -import com.google.gson.JsonSyntaxException; -import com.launchdarkly.android.response.SummaryEventSharedPreferences; -import com.google.android.gms.security.ProviderInstaller; +import com.launchdarkly.android.flagstore.Flag; +import com.launchdarkly.android.gson.GsonCache; import java.io.Closeable; -import java.io.File; import java.io.IOException; import java.lang.ref.WeakReference; -import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.HashMap; -import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; @@ -44,11 +33,10 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import javax.net.ssl.SSLContext; - import timber.log.Timber; import static com.launchdarkly.android.Util.isClientConnected; +import static com.launchdarkly.android.tls.TLSUtils.patchTLSIfNeeded; /** * Client for accessing LaunchDarkly's Feature Flag system. This class enforces a singleton pattern. @@ -72,6 +60,7 @@ public class LDClient implements LDClientInterface, Closeable { private final UpdateProcessor updateProcessor; private final FeatureFlagFetcher fetcher; private final Throttler throttler; + private final Foreground.Listener foregroundListener; private ConnectivityReceiver connectivityReceiver; private volatile boolean isOffline = false; @@ -92,17 +81,14 @@ public class LDClient implements LDClientInterface, Closeable { * @param user The user used in evaluating feature flags * @return a {@link Future} which will complete once the client has been initialized. */ - public static synchronized Future init(@NonNull Application application, @NonNull LDConfig config, @NonNull LDUser user) { - boolean applicationValid = validateParameter(application); - boolean configValid = validateParameter(config); - boolean userValid = validateParameter(user); - if (!applicationValid) { + public static Future init(@NonNull Application application, @NonNull LDConfig config, @NonNull LDUser user) { + if (application == null) { return Futures.immediateFailedFuture(new LaunchDarklyException("Client initialization requires a valid application")); } - if (!configValid) { + if (config == null) { return Futures.immediateFailedFuture(new LaunchDarklyException("Client initialization requires a valid configuration")); } - if (!userValid) { + if (user == null) { return Futures.immediateFailedFuture(new LaunchDarklyException("Client initialization requires a valid user")); } @@ -116,18 +102,7 @@ public static synchronized Future init(@NonNull Application applicatio Timber.plant(new Timber.DebugTree()); } - try { - SSLContext.getInstance("TLSv1.2"); - } catch (NoSuchAlgorithmException e) { - Timber.w("No TLSv1.2 implementation available, attempting patch."); - try { - ProviderInstaller.installIfNeeded(application.getApplicationContext()); - } catch (GooglePlayServicesRepairableException e1) { - Timber.w("Patch failed, Google Play Services too old."); - } catch (GooglePlayServicesNotAvailableException e1) { - Timber.w("Patch failed, no Google Play Services available."); - } - } + patchTLSIfNeeded(application); ConnectivityManager cm = (ConnectivityManager) application.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); @@ -151,7 +126,7 @@ public static synchronized Future init(@NonNull Application applicatio instanceId = instanceIdSharedPrefs.getString(INSTANCE_ID_KEY, instanceId); Timber.i("Using instance id: %s", instanceId); - migrateWhenNeeded(application, config); + Migration.migrateWhenNeeded(application, config); for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) { final LDClient instance = new LDClient(application, config, mobileKeys.getKey()); @@ -185,28 +160,17 @@ public LDClient apply(List input) { }, MoreExecutors.directExecutor()); } - private static boolean validateParameter(T parameter) { - boolean parameterValid; - try { - Preconditions.checkNotNull(parameter); - parameterValid = true; - } catch (NullPointerException e) { - parameterValid = false; - } - return parameterValid; - } - /** * Initializes the singleton instance and blocks for up to startWaitSeconds seconds * until the client has been initialized. If the client does not initialize within * startWaitSeconds seconds, it is returned anyway and can be used, but may not * have fetched the most recent feature flag values. * - * @param application - * @param config - * @param user - * @param startWaitSeconds - * @return + * @param application Your Android application. + * @param config Configuration used to set up the client + * @param user The user used in evaluating feature flags + * @param startWaitSeconds Maximum number of seconds to wait for the client to initialize + * @return The primary LDClient instance */ public static synchronized LDClient init(Application application, LDConfig config, LDUser user, int startWaitSeconds) { Timber.i("Initializing Client and waiting up to %s for initialization to complete", startWaitSeconds); @@ -268,7 +232,7 @@ protected LDClient(final Application application, @NonNull final LDConfig config this.userManager = UserManager.newInstance(application, fetcher, environmentName, config.getMobileKeys().get(environmentName)); Foreground foreground = Foreground.get(application); - Foreground.Listener foregroundListener = new Foreground.Listener() { + foregroundListener = new Foreground.Listener() { @Override public void onBecameForeground() { PollingUpdater.stop(application); @@ -309,109 +273,6 @@ public void run() { } } - private static void migrateWhenNeeded(Application application, LDConfig config) { - SharedPreferences migrations = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "migrations", Context.MODE_PRIVATE); - - if (!migrations.contains("v2.6.0")) { - Timber.d("Migrating to v2.6.0 multi-environment shared preferences"); - - File directory = new File(application.getFilesDir().getParent() + "/shared_prefs/"); - File[] files = directory.listFiles(); - ArrayList filenames = new ArrayList<>(); - for (File file : files) { - if (file.isFile()) - filenames.add(file.getName()); - } - - filenames.remove(LDConfig.SHARED_PREFS_BASE_KEY + "id.xml"); - filenames.remove(LDConfig.SHARED_PREFS_BASE_KEY + "users.xml"); - filenames.remove(LDConfig.SHARED_PREFS_BASE_KEY + "version.xml"); - filenames.remove(LDConfig.SHARED_PREFS_BASE_KEY + "active.xml"); - filenames.remove(LDConfig.SHARED_PREFS_BASE_KEY + "summaryevents.xml"); - filenames.remove(LDConfig.SHARED_PREFS_BASE_KEY + "migrations.xml"); - - Iterator nameIter = filenames.iterator(); - while (nameIter.hasNext()) { - String name = nameIter.next(); - if (!name.startsWith(LDConfig.SHARED_PREFS_BASE_KEY) || !name.endsWith(".xml")) { - nameIter.remove(); - continue; - } - for (String mobileKey : config.getMobileKeys().values()) { - if (name.contains(mobileKey)) { - nameIter.remove(); - break; - } - } - } - - ArrayList userKeys = new ArrayList<>(); - for (String filename : filenames) { - userKeys.add(filename.substring(LDConfig.SHARED_PREFS_BASE_KEY.length(), filename.length() - 4)); - } - - boolean allSuccess = true; - for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) { - String mobileKey = mobileKeys.getValue(); - boolean users = copySharedPreferences(application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "users", Context.MODE_PRIVATE), - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-users", Context.MODE_PRIVATE)); - boolean version = copySharedPreferences(application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "version", Context.MODE_PRIVATE), - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-version", Context.MODE_PRIVATE)); - boolean active = copySharedPreferences(application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "active", Context.MODE_PRIVATE), - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-active", Context.MODE_PRIVATE)); - boolean stores = true; - for (String key : userKeys) { - boolean store = copySharedPreferences(application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + key, Context.MODE_PRIVATE), - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + key + "-user", Context.MODE_PRIVATE)); - stores = stores && store; - } - allSuccess = allSuccess && users && version && active && stores; - } - - if (allSuccess) { - Timber.d("Migration to v2.6.0 multi-environment shared preferences successful"); - boolean logged = migrations.edit().putString("v2.6.0", "v2.6.0").commit(); - if (logged) { - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "users", Context.MODE_PRIVATE).edit().clear().apply(); - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "version", Context.MODE_PRIVATE).edit().clear().apply(); - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "active", Context.MODE_PRIVATE).edit().clear().apply(); - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "summaryevents", Context.MODE_PRIVATE).edit().clear().apply(); - for (String key : userKeys) { - application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + key, Context.MODE_PRIVATE).edit().clear().apply(); - } - } - } - } - } - - private static boolean copySharedPreferences(SharedPreferences oldPreferences, SharedPreferences newPreferences) { - SharedPreferences.Editor editor = newPreferences.edit(); - - for (Map.Entry entry : oldPreferences.getAll().entrySet()) { - Object value = entry.getValue(); - String key = entry.getKey(); - - if (value instanceof Boolean) - editor.putBoolean(key, (Boolean) value); - else if (value instanceof Float) - editor.putFloat(key, (Float) value); - else if (value instanceof Integer) - editor.putInt(key, (Integer) value); - else if (value instanceof Long) - editor.putLong(key, (Long) value); - else if (value instanceof String) - editor.putString(key, ((String) value)); - } - - return editor.commit(); - } - - /** - * Tracks that a user performed an event. - * - * @param eventName the name of the event - * @param data a JSON object containing additional data associated with the event - */ @Override public void track(String eventName, JsonElement data) { if (config.inlineUsersInEvents()) { @@ -421,27 +282,11 @@ public void track(String eventName, JsonElement data) { } } - /** - * Tracks that a user performed an event. - * - * @param eventName the name of the event - */ @Override public void track(String eventName) { - if (config.inlineUsersInEvents()) { - sendEvent(new CustomEvent(eventName, userManager.getCurrentUser(), null)); - } else { - sendEvent(new CustomEvent(eventName, userManager.getCurrentUser().getKeyAsString(), null)); - } + track(eventName, null); } - /** - * Sets the current user, retrieves flags for that user, then sends an Identify Event to LaunchDarkly. - * The 5 most recent users' flag settings are kept locally. - * - * @param user - * @return Future whose success indicates this user's flag settings have been stored locally and are ready for evaluation. - */ @Override public synchronized Future identify(LDUser user) { return LDClient.identifyInstances(user); @@ -489,213 +334,118 @@ public Void apply(List input) { }, MoreExecutors.directExecutor()); } - /** - * Returns a map of all feature flags for the current user. No events are sent to LaunchDarkly. - * - * @return - */ @Override public Map allFlags() { - return userManager.getCurrentUserSharedPrefs().getAll(); + Map result = new HashMap<>(); + List flags = userManager.getCurrentUserFlagStore().getAllFlags(); + for (Flag flag : flags) { + JsonElement jsonVal = flag.getValue(); + if (jsonVal == null || jsonVal.isJsonNull()) { + // TODO(gwhelanld): Include null flag values in results in 3.0.0 + continue; + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isBoolean()) { + result.put(flag.getKey(), jsonVal.getAsBoolean()); + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isNumber()) { + result.put(flag.getKey(), jsonVal.getAsFloat()); + } else if (jsonVal.isJsonPrimitive() && jsonVal.getAsJsonPrimitive().isString()) { + result.put(flag.getKey(), jsonVal.getAsString()); + } else { + // Returning JSON flag as String for backwards compatibility. In the next major + // release (3.0.0) this method will return a Map containing JsonElements for JSON + // flags + result.put(flag.getKey(), GsonCache.getGson().toJson(jsonVal)); + } + } + return result; } - /** - * Returns the flag value for the current user. Returns fallback when one of the following occurs: - *

    - *
  1. Flag is missing
  2. - *
  3. The flag is not of a boolean type
  4. - *
  5. Any other error
  6. - *
- * - * @param flagKey - * @param fallback - * @return - */ - @SuppressWarnings("ConstantConditions") @Override public Boolean boolVariation(String flagKey, Boolean fallback) { - Boolean result = fallback; - try { - result = userManager.getCurrentUserSharedPrefs().getBoolean(flagKey, fallback); - } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get boolean flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } catch (NullPointerException npe) { - Timber.e(npe, "Attempted to get boolean flag with a default null value for key: %s Returning fallback: %s", flagKey, fallback); - } - int version = userManager.getFlagResponseSharedPreferences().getVersionForEvents(flagKey); - int variation = userManager.getFlagResponseSharedPreferences().getStoredVariation(flagKey); - if (result == null && fallback == null) { - updateSummaryEvents(flagKey, null, null); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, JsonNull.INSTANCE, version, variation); - } else if (result == null) { - updateSummaryEvents(flagKey, null, new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, new JsonPrimitive(fallback), version, variation); - } else if (fallback == null) { - updateSummaryEvents(flagKey, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), JsonNull.INSTANCE, version, variation); - } else { - updateSummaryEvents(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback), version, variation); - } - Timber.d("boolVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); - return result; + return variationDetailInternal(flagKey, fallback, ValueTypes.BOOLEAN, false).getValue(); + } + + @Override + public EvaluationDetail boolVariationDetail(String flagKey, Boolean fallback) { + return variationDetailInternal(flagKey, fallback, ValueTypes.BOOLEAN, true); } - /** - * Returns the flag value for the current user. Returns fallback when one of the following occurs: - *
    - *
  1. Flag is missing
  2. - *
  3. The flag is not of an integer type
  4. - *
  5. Any other error
  6. - *
- * - * @param flagKey - * @param fallback - * @return - */ @Override public Integer intVariation(String flagKey, Integer fallback) { - Integer result = fallback; - try { - result = (int) userManager.getCurrentUserSharedPrefs().getFloat(flagKey, fallback); - } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get integer flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } catch (NullPointerException npe) { - Timber.e(npe, "Attempted to get integer flag with a default null value for key: %s Returning fallback: %s", flagKey, fallback); - } - int version = userManager.getFlagResponseSharedPreferences().getVersionForEvents(flagKey); - int variation = userManager.getFlagResponseSharedPreferences().getStoredVariation(flagKey); - if (result == null && fallback == null) { - updateSummaryEvents(flagKey, null, null); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, JsonNull.INSTANCE, version, variation); - } else if (result == null) { - updateSummaryEvents(flagKey, null, new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, new JsonPrimitive(fallback), version, variation); - } else if (fallback == null) { - updateSummaryEvents(flagKey, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), JsonNull.INSTANCE, version, variation); - } else { - updateSummaryEvents(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback), version, variation); - } - Timber.d("intVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); - return result; + return variationDetailInternal(flagKey, fallback, ValueTypes.INT, false).getValue(); + } + + @Override + public EvaluationDetail intVariationDetail(String flagKey, Integer fallback) { + return variationDetailInternal(flagKey, fallback, ValueTypes.INT, true); } - /** - * Returns the flag value for the current user. Returns fallback when one of the following occurs: - *
    - *
  1. Flag is missing
  2. - *
  3. The flag is not of a float type
  4. - *
  5. Any other error
  6. - *
- * - * @param flagKey - * @param fallback - * @return - */ @Override public Float floatVariation(String flagKey, Float fallback) { - Float result = fallback; - try { - result = userManager.getCurrentUserSharedPrefs().getFloat(flagKey, fallback); - } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get float flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } catch (NullPointerException npe) { - Timber.e(npe, "Attempted to get float flag with a default null value for key: %s Returning fallback: %s", flagKey, fallback); - } - int version = userManager.getFlagResponseSharedPreferences().getVersionForEvents(flagKey); - int variation = userManager.getFlagResponseSharedPreferences().getStoredVariation(flagKey); - if (result == null && fallback == null) { - updateSummaryEvents(flagKey, null, null); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, JsonNull.INSTANCE, version, variation); - } else if (result == null) { - updateSummaryEvents(flagKey, null, new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, new JsonPrimitive(fallback), version, variation); - } else if (fallback == null) { - updateSummaryEvents(flagKey, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), JsonNull.INSTANCE, version, variation); - } else { - updateSummaryEvents(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback), version, variation); - } - Timber.d("floatVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); - return result; + return variationDetailInternal(flagKey, fallback, ValueTypes.FLOAT, false).getValue(); + } + + @Override + public EvaluationDetail floatVariationDetail(String flagKey, Float fallback) { + return variationDetailInternal(flagKey, fallback, ValueTypes.FLOAT, true); } - /** - * Returns the flag value for the current user. Returns fallback when one of the following occurs: - *
    - *
  1. Flag is missing
  2. - *
  3. The flag is not of a String type
  4. - *
  5. Any other error
  6. - *
- * - * @param flagKey - * @param fallback - * @return - */ @Override public String stringVariation(String flagKey, String fallback) { - String result = fallback; - try { - result = userManager.getCurrentUserSharedPrefs().getString(flagKey, fallback); - } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get string flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } catch (NullPointerException npe) { - Timber.e(npe, "Attempted to get string flag with a default null value for key: %s Returning fallback: %s", flagKey, fallback); - } - int version = userManager.getFlagResponseSharedPreferences().getVersionForEvents(flagKey); - int variation = userManager.getFlagResponseSharedPreferences().getStoredVariation(flagKey); - if (result == null && fallback == null) { - updateSummaryEvents(flagKey, null, null); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, JsonNull.INSTANCE, version, variation); - } else if (result == null) { - updateSummaryEvents(flagKey, null, new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, JsonNull.INSTANCE, new JsonPrimitive(fallback), version, variation); - } else if (fallback == null) { - updateSummaryEvents(flagKey, new JsonPrimitive(result), null); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), JsonNull.INSTANCE, version, variation); - } else { - updateSummaryEvents(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback)); - sendFlagRequestEvent(flagKey, new JsonPrimitive(result), new JsonPrimitive(fallback), version, variation); - } - Timber.d("stringVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); - return result; + // TODO(gwhelanld): Change to ValueTypes.String in 3.0.0 + return variationDetailInternal(flagKey, fallback, ValueTypes.STRINGCOMPAT, false).getValue(); + } + + @Override + public EvaluationDetail stringVariationDetail(String flagKey, String fallback) { + // TODO(gwhelanld): Change to ValueTypes.String in 3.0.0 + return variationDetailInternal(flagKey, fallback, ValueTypes.STRINGCOMPAT, true); } - /** - * Returns the flag value for the current user. Returns fallback when one of the following occurs: - *
    - *
  1. Flag is missing
  2. - *
  3. The flag is not valid JSON
  4. - *
  5. Any other error
  6. - *
- * - * @param flagKey - * @param fallback - * @return - */ @Override public JsonElement jsonVariation(String flagKey, JsonElement fallback) { - JsonElement result = fallback; - try { - String stringResult = userManager.getCurrentUserSharedPrefs().getString(flagKey, null); - if (stringResult != null) { - result = new JsonParser().parse(stringResult); + return variationDetailInternal(flagKey, fallback, ValueTypes.JSON, false).getValue(); + } + + @Override + public EvaluationDetail jsonVariationDetail(String flagKey, JsonElement fallback) { + return variationDetailInternal(flagKey, fallback, ValueTypes.JSON, true); + } + + private EvaluationDetail variationDetailInternal(String flagKey, T fallback, ValueTypes.Converter typeConverter, boolean includeReasonInEvent) { + if (flagKey == null) { + Timber.e("Attempted to get flag with a null value for key. Returning fallback: %s", fallback); + return EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, fallback); // no event is sent in this case + } + + Flag flag = userManager.getCurrentUserFlagStore().getFlag(flagKey); + JsonElement fallbackJson = fallback == null ? null : typeConverter.valueToJson(fallback); + JsonElement valueJson = fallbackJson; + EvaluationDetail result; + + if (flag == null) { + Timber.e("Attempted to get non-existent flag for key: %s Returning fallback: %s", flagKey, fallback); + result = EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, fallback); + } else { + valueJson = flag.getValue(); + if (valueJson == null || valueJson.isJsonNull()) { + Timber.e("Attempted to get flag without value for key: %s Returning fallback: %s", flagKey, fallback); + result = new EvaluationDetail<>(flag.getReason(), flag.getVariation(), fallback); + valueJson = fallbackJson; + } else { + T value = typeConverter.valueFromJson(valueJson); + if (value == null) { + Timber.e("Attempted to get flag with wrong type for key: %s Returning fallback: %s", flagKey, fallback); + result = EvaluationDetail.error(EvaluationReason.ErrorKind.WRONG_TYPE, fallback); + valueJson = fallbackJson; + } else { + result = new EvaluationDetail<>(flag.getReason(), flag.getVariation(), value); + } } - } catch (ClassCastException cce) { - Timber.e(cce, "Attempted to get json (string) flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } catch (NullPointerException npe) { - Timber.e(npe, "Attempted to get json (string flag with a default null value for key: %s Returning fallback: %s", flagKey, fallback); - } catch (JsonSyntaxException jse) { - Timber.e(jse, "Attempted to get json (string flag that exists as another type for key: %s Returning fallback: %s", flagKey, fallback); - } - int version = userManager.getFlagResponseSharedPreferences().getVersionForEvents(flagKey); - int variation = userManager.getFlagResponseSharedPreferences().getStoredVariation(flagKey); - updateSummaryEvents(flagKey, result, fallback); - sendFlagRequestEvent(flagKey, result, fallback, version, variation); - Timber.d("jsonVariation: returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); + } + + updateSummaryEvents(flagKey, flag, valueJson, fallbackJson); + sendFlagRequestEvent(flagKey, flag, valueJson, fallbackJson, includeReasonInEvent ? result.getReason() : null); + Timber.d("returning variation: %s flagKey: %s user key: %s", result, flagKey, userManager.getCurrentUser().getKeyAsString()); return result; } @@ -709,30 +459,30 @@ public void close() throws IOException { LDClient.closeInstances(); } - private void closeInternal() throws IOException { + private void closeInternal() { updateProcessor.stop(); eventProcessor.close(); - if (connectivityReceiver != null && application.get() != null) { - application.get().unregisterReceiver(connectivityReceiver); + Application app = application.get(); + if (connectivityReceiver != null && app != null) { + app.unregisterReceiver(connectivityReceiver); + connectivityReceiver = null; + } + try { + Foreground foreground = Foreground.get(); + if (foregroundListener != null) { + foreground.removeListener(foregroundListener); + } + } catch (IllegalStateException ex) { + // Foreground not initialized } } private static void closeInstances() throws IOException { - IOException exception = null; for (LDClient client : instances.values()) { - try { - client.closeInternal(); - } catch (IOException e) { - exception = e; - } + client.closeInternal(); } - if (exception != null) - throw exception; } - /** - * Sends all pending events to LaunchDarkly. - */ @Override public void flush() { LDClient.flushInstances(); @@ -758,15 +508,6 @@ public boolean isOffline() { return isOffline; } - /** - * Shuts down any network connections maintained by the client and puts the client in offline - * mode, preventing the client from opening new network connections until - * setOnline() is called. - *

- * Note: The client automatically monitors the device's network connectivity and app foreground - * status, so calling setOffline() or setOnline() is normally - * unnecessary in most situations. - */ @Override public synchronized void setOffline() { LDClient.setInstancesOffline(); @@ -787,14 +528,6 @@ private synchronized static void setInstancesOffline() { } } - /** - * Restores network connectivity for the client, if the client was previously in offline mode. - * This operation may be throttled if it is called too frequently. - *

- * Note: The client automatically monitors the device's network connectivity and app foreground - * status, so calling setOffline() or setOnline() is normally - * unnecessary in most situations. - */ @Override public synchronized void setOnline() { throttler.attemptRun(); @@ -822,24 +555,11 @@ private static void setOnlineStatusInstances() { } } - /** - * Registers a {@link FeatureFlagChangeListener} to be called when the flagKey changes - * from its current value. If the feature flag is deleted, the listener will be unregistered. - * - * @param flagKey - * @param listener - */ @Override public void registerFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener) { userManager.registerListener(flagKey, listener); } - /** - * Unregisters a {@link FeatureFlagChangeListener} for the flagKey - * - * @param flagKey - * @param listener - */ @Override public void unregisterFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener) { userManager.unregisterListener(flagKey, listener); @@ -850,6 +570,11 @@ public boolean isDisableBackgroundPolling() { return config.isDisableBackgroundPolling(); } + @Override + public String getVersion() { + return BuildConfig.VERSION_NAME; + } + static String getInstanceId() { return instanceId; } @@ -864,24 +589,28 @@ void startForegroundUpdating() { } } - private void sendFlagRequestEvent(String flagKey, JsonElement value, JsonElement fallback, int version, int variation) { - if (userManager.getFlagResponseSharedPreferences().getStoredTrackEvents(flagKey)) { + private void sendFlagRequestEvent(String flagKey, Flag flag, JsonElement value, JsonElement fallback, EvaluationReason reason) { + if (flag == null) { + return; + } + + int version = flag.getVersionForEvents(); + Integer variation = flag.getVariation(); + if (flag.getTrackEvents()) { if (config.inlineUsersInEvents()) { - sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser(), value, fallback, version, variation)); + sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser(), value, fallback, version, variation, reason)); } else { - sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser().getKeyAsString(), value, fallback, version, variation)); + sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser().getKeyAsString(), value, fallback, version, variation, reason)); } } else { - Long debugEventsUntilDate = userManager.getFlagResponseSharedPreferences().getStoredDebugEventsUntilDate(flagKey); + Long debugEventsUntilDate = flag.getDebugEventsUntilDate(); if (debugEventsUntilDate != null) { long serverTimeMs = eventProcessor.getCurrentTimeMs(); if (debugEventsUntilDate > System.currentTimeMillis() && debugEventsUntilDate > serverTimeMs) { - sendEvent(new DebugEvent(flagKey, userManager.getCurrentUser(), value, fallback, version, variation)); + sendEvent(new DebugEvent(flagKey, userManager.getCurrentUser(), value, fallback, version, variation, reason)); } } } - - sendSummaryEvent(); } void startBackgroundPolling() { @@ -905,37 +634,18 @@ private void sendEvent(Event event) { * Nothing is sent to the server. * * @param flagKey The flagKey that will be updated + * @param flag The stored flag used in the evaluation of the flagKey * @param result The value that was returned in the evaluation of the flagKey * @param fallback The fallback value used in the evaluation of the flagKey */ - private void updateSummaryEvents(String flagKey, JsonElement result, JsonElement fallback) { - int version = userManager.getFlagResponseSharedPreferences().getVersionForEvents(flagKey); - int variation = userManager.getFlagResponseSharedPreferences().getStoredVariation(flagKey); - boolean isUnknown = !userManager.getFlagResponseSharedPreferences().containsKey(flagKey); - - userManager.getSummaryEventSharedPreferences().addOrUpdateEvent(flagKey, result, fallback, version, variation, isUnknown); - } - - /** - * Updates the cached summary event that will be sent to the server with the next batch of events. - */ - private void sendSummaryEvent() { - JsonObject features = userManager.getSummaryEventSharedPreferences().getFeaturesJsonObject(); - if (features.keySet().size() == 0) { - return; - } - Long startDate = null; - for (String key : features.keySet()) { - JsonObject asJsonObject = features.get(key).getAsJsonObject(); - if (asJsonObject.has("startDate")) { - startDate = asJsonObject.get("startDate").getAsLong(); - asJsonObject.remove("startDate"); - break; - } + private void updateSummaryEvents(String flagKey, Flag flag, JsonElement result, JsonElement fallback) { + if (flag == null) { + userManager.getSummaryEventSharedPreferences().addOrUpdateEvent(flagKey, result, fallback, -1, null); + } else { + int version = flag.getVersionForEvents(); + Integer variation = flag.getVariation(); + userManager.getSummaryEventSharedPreferences().addOrUpdateEvent(flagKey, result, fallback, version, variation); } - SummaryEvent summaryEvent = new SummaryEvent(startDate, System.currentTimeMillis(), features); - Timber.d("Sending Summary Event: %s", summaryEvent.toString()); - eventProcessor.setSummaryEvent(summaryEvent); } @VisibleForTesting diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClientInterface.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClientInterface.java index 02b19aa1..c82b396e 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClientInterface.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDClientInterface.java @@ -1,44 +1,271 @@ package com.launchdarkly.android; +import android.app.Application; + import com.google.gson.JsonElement; import java.io.Closeable; import java.util.Map; import java.util.concurrent.Future; +/** + * The interface for the LaunchDarkly SDK client. + *

+ * To obtain a client instance, use {@link LDClient} methods such as {@link LDClient#init(Application, LDConfig, LDUser)}. + */ public interface LDClientInterface extends Closeable { + /** + * Checks whether the client is ready to return feature flag values. This is true if either + * the client has successfully connected to LaunchDarkly and received feature flags, or the + * client has been put into offline mode (in which case it will return only default flag values). + * + * @return true if the client is initialized or offline + */ boolean isInitialized(); + /** + * Checks whether the client has been put into offline mode. This is true only if {@link #setOffline()} + * was called, or if the configuration had {@link LDConfig.Builder#setOffline(boolean)} set to true, + * not if the client is simply offline due to a loss of network connectivity. + * + * @return true if the client is in offline mode + */ boolean isOffline(); + /** + * Shuts down any network connections maintained by the client and puts the client in offline + * mode, preventing the client from opening new network connections until + * setOnline() is called. + *

+ * Note: The client automatically monitors the device's network connectivity and app foreground + * status, so calling setOffline() or setOnline() is normally + * unnecessary in most situations. + */ void setOffline(); + /** + * Restores network connectivity for the client, if the client was previously in offline mode. + * This operation may be throttled if it is called too frequently. + *

+ * Note: The client automatically monitors the device's network connectivity and app foreground + * status, so calling setOffline() or setOnline() is normally + * unnecessary in most situations. + */ void setOnline(); + /** + * Tracks that a user performed an event. + * + * @param eventName the name of the event + * @param data a JSON object containing additional data associated with the event + */ void track(String eventName, JsonElement data); + /** + * Tracks that a user performed an event. + * + * @param eventName the name of the event + */ void track(String eventName); + /** + * Sets the current user, retrieves flags for that user, then sends an Identify Event to LaunchDarkly. + * The 5 most recent users' flag settings are kept locally. + * + * @param user + * @return Future whose success indicates this user's flag settings have been stored locally and are ready for evaluation. + */ Future identify(LDUser user); + /** + * Sends all pending events to LaunchDarkly. + */ void flush(); + /** + * Returns a map of all feature flags for the current user. No events are sent to LaunchDarkly. + * + * @return a map of all feature flags + */ Map allFlags(); + /** + * Returns the flag value for the current user. Returns fallback when one of the following occurs: + *

    + *
  1. Flag is missing
  2. + *
  3. The flag is not of a boolean type
  4. + *
  5. Any other error
  6. + *
+ * + * @param flagKey key for the flag to evaluate + * @param fallback fallback value in case of errors evaluating the flag + * @return value of the flag or fallback + */ Boolean boolVariation(String flagKey, Boolean fallback); + /** + * Returns the flag value for the current user, along with information about how it was calculated. + * + * Note that this will only work if you have set {@code evaluationReasons} to true in + * {@link LDConfig.Builder#evaluationReasons}. Otherwise, the {@code reason} property of the result + * will be null. + * + * @param flagKey key for the flag to evaluate + * @param fallback fallback value in case of errors evaluating the flag (see {@link #boolVariation(String, Boolean)}) + * @return an {@link EvaluationDetail} object containing the value and other information. + * + * @since 2.7.0 + */ + EvaluationDetail boolVariationDetail(String flagKey, Boolean fallback); + + /** + * Returns the flag value for the current user. Returns fallback when one of the following occurs: + *
    + *
  1. Flag is missing
  2. + *
  3. The flag is not of a numeric type
  4. + *
  5. Any other error
  6. + *
+ * + * @param flagKey key for the flag to evaluate + * @param fallback fallback value in case of errors evaluating the flag + * @return value of the flag or fallback + */ Integer intVariation(String flagKey, Integer fallback); + /** + * Returns the flag value for the current user, along with information about how it was calculated. + * + * Note that this will only work if you have set {@code evaluationReasons} to true in + * {@link LDConfig.Builder#evaluationReasons}. Otherwise, the {@code reason} property of the result + * will be null. + * + * @param flagKey key for the flag to evaluate + * @param fallback fallback value in case of errors evaluating the flag (see {@link #intVariation(String, Integer)}) + * @return an {@link EvaluationDetail} object containing the value and other information. + * + * @since 2.7.0 + */ + EvaluationDetail intVariationDetail(String flagKey, Integer fallback); + + /** + * Returns the flag value for the current user. Returns fallback when one of the following occurs: + *
    + *
  1. Flag is missing
  2. + *
  3. The flag is not of a numeric type
  4. + *
  5. Any other error
  6. + *
+ * + * @param flagKey key for the flag to evaluate + * @param fallback fallback value in case of errors evaluating the flag + * @return value of the flag or fallback + */ Float floatVariation(String flagKey, Float fallback); + /** + * Returns the flag value for the current user, along with information about how it was calculated. + * + * Note that this will only work if you have set {@code evaluationReasons} to true in + * {@link LDConfig.Builder#evaluationReasons}. Otherwise, the {@code reason} property of the result + * will be null. + * + * @param flagKey key for the flag to evaluate + * @param fallback fallback value in case of errors evaluating the flag (see {@link #floatVariation(String, Float)}) + * @return an {@link EvaluationDetail} object containing the value and other information. + * + * @since 2.7.0 + */ + EvaluationDetail floatVariationDetail(String flagKey, Float fallback); + + /** + * Returns the flag value for the current user. Returns fallback when one of the following occurs: + *
    + *
  1. Flag is missing
  2. + *
  3. The flag is not of a string type
  4. + *
  5. Any other error
  6. + *
+ * + * @param flagKey key for the flag to evaluate + * @param fallback fallback value in case of errors evaluating the flag + * @return value of the flag or fallback + */ String stringVariation(String flagKey, String fallback); + /** + * Returns the flag value for the current user, along with information about how it was calculated. + * + * Note that this will only work if you have set {@code evaluationReasons} to true in + * {@link LDConfig.Builder#evaluationReasons}. Otherwise, the {@code reason} property of the result + * will be null. + * + * @param flagKey key for the flag to evaluate + * @param fallback fallback value in case of errors evaluating the flag (see {@link #stringVariation(String, String)}) + * @return an {@link EvaluationDetail} object containing the value and other information. + * + * @since 2.7.0 + */ + EvaluationDetail stringVariationDetail(String flagKey, String fallback); + + /** + * Returns the flag value for the current user. Returns fallback when one of the following occurs: + *
    + *
  1. Flag is missing
  2. + *
  3. Any other error
  4. + *
+ * + * @param flagKey key for the flag to evaluate + * @param fallback fallback value in case of errors evaluating the flag + * @return value of the flag or fallback + */ JsonElement jsonVariation(String flagKey, JsonElement fallback); + /** + * Returns the flag value for the current user, along with information about how it was calculated. + * + * Note that this will only work if you have set {@code evaluationReasons} to true in + * {@link LDConfig.Builder#evaluationReasons}. Otherwise, the {@code reason} property of the result + * will be null. + * + * @param flagKey key for the flag to evaluate + * @param fallback fallback value in case of errors evaluating the flag (see {@link #jsonVariation(String, JsonElement)}) + * @return an {@link EvaluationDetail} object containing the value and other information. + * + * @since 2.7.0 + */ + EvaluationDetail jsonVariationDetail(String flagKey, JsonElement fallback); + + /** + * Registers a {@link FeatureFlagChangeListener} to be called when the flagKey changes + * from its current value. If the feature flag is deleted, the listener will be unregistered. + * + * @param flagKey the flag key to attach the listener to + * @param listener the listener to attach to the flag key + * @see #unregisterFeatureFlagListener(String, FeatureFlagChangeListener) + */ void registerFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener); + /** + * Unregisters a {@link FeatureFlagChangeListener} for the flagKey. + * + * @param flagKey the flag key to remove the listener from + * @param listener the listener to remove from the flag key + * @see #registerFeatureFlagListener(String, FeatureFlagChangeListener) + */ void unregisterFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener); + /** + * Checks whether {@link LDConfig.Builder#setDisableBackgroundUpdating(boolean)} was set to + * {@code true} in the configuration. + * + * @return true if background polling is disabled + */ boolean isDisableBackgroundPolling(); + + /** + * Returns the version of the SDK, for instance "2.7.0". + * + * @return the version string + * @since 2.7.0 + */ + String getVersion(); } diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDConfig.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDConfig.java index c2a7f05e..e60700f9 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDConfig.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDConfig.java @@ -15,6 +15,10 @@ import okhttp3.Request; import timber.log.Timber; +/** + * This class exposes advanced configuration options for {@link LDClient}. Instances of this class + * must be constructed with {@link LDConfig.Builder}. + */ public class LDConfig { static final String SHARED_PREFS_BASE_KEY = "LaunchDarkly-"; @@ -61,6 +65,8 @@ public class LDConfig { private final boolean inlineUsersInEvents; + private final boolean evaluationReasons; + LDConfig(Map mobileKeys, Uri baseUri, Uri eventsUri, @@ -76,7 +82,8 @@ public class LDConfig { boolean useReport, boolean allAttributesPrivate, Set privateAttributeNames, - boolean inlineUsersInEvents) { + boolean inlineUsersInEvents, + boolean evaluationReasons) { this.mobileKeys = mobileKeys; this.baseUri = baseUri; @@ -94,6 +101,7 @@ public class LDConfig { this.allAttributesPrivate = allAttributesPrivate; this.privateAttributeNames = privateAttributeNames; this.inlineUsersInEvents = inlineUsersInEvents; + this.evaluationReasons = evaluationReasons; this.filteredEventGson = new GsonBuilder() .registerTypeAdapter(LDUser.class, new LDUser.LDUserPrivateAttributesTypeAdapter(this)) @@ -186,6 +194,10 @@ public boolean inlineUsersInEvents() { return inlineUsersInEvents; } + public boolean isEvaluationReasons() { + return evaluationReasons; + } + public static class Builder { private String mobileKey; private Map secondaryMobileKeys; @@ -209,9 +221,14 @@ public static class Builder { private Set privateAttributeNames = new HashSet<>(); private boolean inlineUsersInEvents = false; + private boolean evaluationReasons = false; /** - * Sets the flag for making all attributes private. The default is false. + * Specifies that user attributes (other than the key) should be hidden from LaunchDarkly. + * If this is set, all user attribute values will be private, not just the attributes + * specified in {@link #setPrivateAttributeNames(Set)}. + * + * @return the builder */ public Builder allAttributesPrivate() { this.allAttributesPrivate = true; @@ -219,8 +236,14 @@ public Builder allAttributesPrivate() { } /** - * Sets the name of private attributes. - * Private attributes are not sent to LaunchDarkly. + * Marks a set of attributes private. Any users sent to LaunchDarkly with this configuration + * active will have attributes with these names removed. + * + * This can also be specified on a per-user basis with {@link LDUser.Builder} methods like + * {@link LDUser.Builder#privateName(String)}. + * + * @param privateAttributeNames a set of names that will be removed from user data sent to LaunchDarkly + * @return the builder */ public Builder setPrivateAttributeNames(Set privateAttributeNames) { this.privateAttributeNames = Collections.unmodifiableSet(privateAttributeNames); @@ -231,7 +254,7 @@ public Builder setPrivateAttributeNames(Set privateAttributeNames) { * Sets the key for authenticating with LaunchDarkly. This is required unless you're using the client in offline mode. * * @param mobileKey Get this from the LaunchDarkly web app under Team Settings. - * @return + * @return the builder */ public LDConfig.Builder setMobileKey(String mobileKey) { if (secondaryMobileKeys != null && secondaryMobileKeys.containsValue(mobileKey)) { @@ -243,10 +266,10 @@ public LDConfig.Builder setMobileKey(String mobileKey) { } /** - * Sets the secondary keys for authenticating to additional LaunchDarkly environments + * Sets the secondary keys for authenticating to additional LaunchDarkly environments. * * @param secondaryMobileKeys A map of identifying names to unique mobile keys to access secondary environments - * @return + * @return the builder */ public LDConfig.Builder setSecondaryMobileKeys(Map secondaryMobileKeys) { if (secondaryMobileKeys == null) { @@ -273,6 +296,9 @@ public LDConfig.Builder setSecondaryMobileKeys(Map secondaryMobi /** * Sets the flag for choosing the REPORT api call. The default is GET. * Do not use unless advised by LaunchDarkly. + * + * @param useReport true if HTTP requests should use the REPORT verb + * @return the builder */ public LDConfig.Builder setUseReport(boolean useReport) { this.useReport = useReport; @@ -280,10 +306,10 @@ public LDConfig.Builder setUseReport(boolean useReport) { } /** - * Set the base uri for connecting to LaunchDarkly. You probably don't need to set this unless instructed by LaunchDarkly. + * Set the base URI for connecting to LaunchDarkly. You probably don't need to set this unless instructed by LaunchDarkly. * - * @param baseUri - * @return + * @param baseUri the URI of the main LaunchDarkly service + * @return the builder */ public LDConfig.Builder setBaseUri(Uri baseUri) { this.baseUri = baseUri; @@ -291,10 +317,10 @@ public LDConfig.Builder setBaseUri(Uri baseUri) { } /** - * Set the events uri for sending analytics to LaunchDarkly. You probably don't need to set this unless instructed by LaunchDarkly. + * Set the events URI for sending analytics to LaunchDarkly. You probably don't need to set this unless instructed by LaunchDarkly. * - * @param eventsUri - * @return + * @param eventsUri the URI of the LaunchDarkly analytics event service + * @return the builder */ public LDConfig.Builder setEventsUri(Uri eventsUri) { this.eventsUri = eventsUri; @@ -302,10 +328,10 @@ public LDConfig.Builder setEventsUri(Uri eventsUri) { } /** - * Set the stream uri for connecting to the flag update stream. You probably don't need to set this unless instructed by LaunchDarkly. + * Set the stream URI for connecting to the flag update stream. You probably don't need to set this unless instructed by LaunchDarkly. * - * @param streamUri - * @return + * @param streamUri the URI of the LaunchDarkly streaming service + * @return the builder */ public LDConfig.Builder setStreamUri(Uri streamUri) { this.streamUri = streamUri; @@ -313,10 +339,15 @@ public LDConfig.Builder setStreamUri(Uri streamUri) { } /** - * Sets the max number of events to queue before sending them to LaunchDarkly. Default: {@value LDConfig#DEFAULT_EVENTS_CAPACITY} + * Set the capacity of the event buffer. The client buffers up to this many events in memory before flushing. + * If the capacity is exceeded before the buffer is flushed, events will be discarded. Increasing the capacity + * means that events are less likely to be discarded, at the cost of consuming more memory. + *

+ * The default value is {@value LDConfig#DEFAULT_EVENTS_CAPACITY}. * - * @param eventsCapacity - * @return + * @param eventsCapacity the capacity of the event buffer + * @return the builder + * @see #setEventsFlushIntervalMillis(int) */ public LDConfig.Builder setEventsCapacity(int eventsCapacity) { this.eventsCapacity = eventsCapacity; @@ -324,11 +355,13 @@ public LDConfig.Builder setEventsCapacity(int eventsCapacity) { } /** - * Sets the maximum amount of time in milliseconds to wait in between sending analytics events to LaunchDarkly. - * Default: {@value LDConfig#DEFAULT_FLUSH_INTERVAL_MILLIS} + * Sets the maximum amount of time to wait in between sending analytics events to LaunchDarkly. + *

+ * The default value is {@value LDConfig#DEFAULT_FLUSH_INTERVAL_MILLIS}. * - * @param eventsFlushIntervalMillis - * @return + * @param eventsFlushIntervalMillis the interval between event flushes, in milliseconds + * @return the builder + * @see #setEventsCapacity(int) */ public LDConfig.Builder setEventsFlushIntervalMillis(int eventsFlushIntervalMillis) { this.eventsFlushIntervalMillis = eventsFlushIntervalMillis; @@ -337,10 +370,12 @@ public LDConfig.Builder setEventsFlushIntervalMillis(int eventsFlushIntervalMill /** - * Sets the timeout in milliseconds when connecting to LaunchDarkly. Default: {@value LDConfig#DEFAULT_CONNECTION_TIMEOUT_MILLIS} + * Sets the timeout when connecting to LaunchDarkly. + *

+ * The default value is {@value LDConfig#DEFAULT_CONNECTION_TIMEOUT_MILLIS}. * - * @param connectionTimeoutMillis - * @return + * @param connectionTimeoutMillis the connection timeout, in milliseconds + * @return the builder */ public LDConfig.Builder setConnectionTimeoutMillis(int connectionTimeoutMillis) { this.connectionTimeoutMillis = connectionTimeoutMillis; @@ -349,11 +384,11 @@ public LDConfig.Builder setConnectionTimeoutMillis(int connectionTimeoutMillis) /** - * Enables or disables real-time streaming flag updates. Default: true. When set to false, - * an efficient caching polling mechanism is used. + * Enables or disables real-time streaming flag updates. By default, streaming is enabled. + * When disabled, an efficient caching polling mechanism is used. * - * @param enabled - * @return + * @param enabled true if streaming should be enabled + * @return the builder */ public LDConfig.Builder setStream(boolean enabled) { this.stream = enabled; @@ -361,11 +396,15 @@ public LDConfig.Builder setStream(boolean enabled) { } /** - * Only relevant when setStream(false) is called. Sets the interval between feature flag updates. Default: {@link LDConfig#DEFAULT_POLLING_INTERVAL_MILLIS} - * Minimum value: {@link LDConfig#MIN_POLLING_INTERVAL_MILLIS}. When set, this will also set the eventsFlushIntervalMillis to the same value. + * Sets the interval in between feature flag updates, when streaming mode is disabled. + * This is ignored unless {@link #setStream(boolean)} is set to {@code true}. When set, it + * will also change the default value for {@link #setEventsFlushIntervalMillis(int)} to the + * same value. + *

+ * The default value is {@link LDConfig#DEFAULT_POLLING_INTERVAL_MILLIS}. * - * @param pollingIntervalMillis - * @return + * @param pollingIntervalMillis the feature flag polling interval, in milliseconds + * @return the builder */ public LDConfig.Builder setPollingIntervalMillis(int pollingIntervalMillis) { this.pollingIntervalMillis = pollingIntervalMillis; @@ -373,10 +412,12 @@ public LDConfig.Builder setPollingIntervalMillis(int pollingIntervalMillis) { } /** - * Sets the interval in milliseconds that twe will poll for flag updates when your app is in the background. Default: - * {@link LDConfig#DEFAULT_BACKGROUND_POLLING_INTERVAL_MILLIS} + * Sets how often the client will poll for flag updates when your application is in the background. + *

+ * The default value is {@link LDConfig#DEFAULT_BACKGROUND_POLLING_INTERVAL_MILLIS}. * - * @param backgroundPollingIntervalMillis + * @param backgroundPollingIntervalMillis the feature flag polling interval when in the background, + * in milliseconds */ public LDConfig.Builder setBackgroundPollingIntervalMillis(int backgroundPollingIntervalMillis) { this.backgroundPollingIntervalMillis = backgroundPollingIntervalMillis; @@ -384,9 +425,11 @@ public LDConfig.Builder setBackgroundPollingIntervalMillis(int backgroundPolling } /** - * Disables feature flag updates when your app is in the background. Default: false + * Sets whether feature flag updates should be disabled when your app is in the background. + *

+ * The default value is false (flag updates will be done in the background). * - * @param disableBackgroundUpdating + * @param disableBackgroundUpdating true if the client should skip updating flags when in the background */ public LDConfig.Builder setDisableBackgroundUpdating(boolean disableBackgroundUpdating) { this.disableBackgroundUpdating = disableBackgroundUpdating; @@ -394,11 +437,15 @@ public LDConfig.Builder setDisableBackgroundUpdating(boolean disableBackgroundUp } /** - * Disables all network calls from the LaunchDarkly Client. Once the client has been created, - * use the {@link LDClient#setOffline()} method to disable network calls. Default: false + * Disables all network calls from the LaunchDarkly client. + *

+ * This can also be specified after the client has been created, using + * {@link LDClientInterface#setOffline()}. + *

+ * The default value is true (the client will make network calls). * - * @param offline - * @return + * @param offline true if the client should run in offline mode + * @return the builder */ public LDConfig.Builder setOffline(boolean offline) { this.offline = offline; @@ -407,17 +454,39 @@ public LDConfig.Builder setOffline(boolean offline) { /** * If enabled, events to the server will be created containing the entire User object. - * If disabled, events to the server will be created without the entire User object, including only the userKey instead. + * If disabled, events to the server will be created without the entire User object, including only the user key instead; + * the rest of the user properties will still be included in Identify events. + *

* Defaults to false in order to reduce network bandwidth. * - * @param inlineUsersInEvents - * @return + * @param inlineUsersInEvents true if all user properties should be included in events + * @return the builder */ public LDConfig.Builder setInlineUsersInEvents(boolean inlineUsersInEvents) { this.inlineUsersInEvents = inlineUsersInEvents; return this; } + /** + * If enabled, LaunchDarkly will provide additional information about how flag values were + * calculated. The additional information will then be available through the client's + * "detail" methods ({@link LDClientInterface#boolVariationDetail(String, boolean)}, etc.). + * + * Since this increases the size of network requests, the default is false (detail + * information will not be sent). + * + * @param evaluationReasons true if detail/reason information should be made available + * @return the builder + */ + public LDConfig.Builder setEvaluationReasons(boolean evaluationReasons) { + this.evaluationReasons = evaluationReasons; + return this; + } + + /** + * Returns the configured {@link LDConfig} object. + * @return the configuration + */ public LDConfig build() { if (!stream) { if (pollingIntervalMillis < MIN_POLLING_INTERVAL_MILLIS) { @@ -474,7 +543,8 @@ public LDConfig build() { useReport, allAttributesPrivate, privateAttributeNames, - inlineUsersInEvents); + inlineUsersInEvents, + evaluationReasons); } } } diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDUser.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDUser.java index f7396669..d87dbb54 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDUser.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LDUser.java @@ -1,6 +1,5 @@ package com.launchdarkly.android; - import android.os.Build; import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LaunchDarklyException.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LaunchDarklyException.java index 8dfc0e4f..c65be5b8 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LaunchDarklyException.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/LaunchDarklyException.java @@ -1,5 +1,8 @@ package com.launchdarkly.android; +/** + * Exception class that can be thrown by LaunchDarkly client methods. + */ public class LaunchDarklyException extends Exception { public LaunchDarklyException(String s) { super(s); diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java new file mode 100644 index 00000000..3b81bd21 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Migration.java @@ -0,0 +1,224 @@ +package com.launchdarkly.android; + +import android.app.Application; +import android.content.Context; +import android.content.SharedPreferences; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; +import com.launchdarkly.android.gson.GsonCache; + +import java.io.File; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +import timber.log.Timber; + +class Migration { + + static void migrateWhenNeeded(Application application, LDConfig config) { + SharedPreferences migrations = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "migrations", Context.MODE_PRIVATE); + + if (migrations.contains("v2.7.0")) { + return; + } + + if (!migrations.contains("v2.6.0")) { + migrate_2_7_fresh(application, config); + } + + if (migrations.contains("v2.6.0") && !migrations.contains("v2.7.0")) { + migrate_2_7_from_2_6(application); + } + } + + private static String reconstructFlag(String key, String metadata, Object value) { + JsonObject flagJson = GsonCache.getGson().fromJson(metadata, JsonObject.class); + flagJson.addProperty("key", key); + if (value instanceof Float) { + flagJson.addProperty("value", (Float) value); + } else if (value instanceof Boolean) { + flagJson.addProperty("value", (Boolean) value); + } else if (value instanceof String) { + try { + JsonElement jsonVal = GsonCache.getGson().fromJson((String) value, JsonElement.class); + flagJson.add("value", jsonVal); + } catch (JsonSyntaxException unused) { + flagJson.addProperty("value", (String) value); + } + } + + return GsonCache.getGson().toJson(flagJson); + } + + private static void migrate_2_7_fresh(Application application, LDConfig config) { + Timber.d("Migrating to v2.7.0 shared preferences store"); + + ArrayList userKeys = getUserKeysPre_2_6(application, config); + SharedPreferences versionSharedPrefs = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "version", Context.MODE_PRIVATE); + Map flagData = versionSharedPrefs.getAll(); + Set flagKeys = flagData.keySet(); + + boolean allSuccess = true; + for (Map.Entry mobileKeys : config.getMobileKeys().entrySet()) { + String mobileKey = mobileKeys.getValue(); + boolean users = copySharedPreferences(application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "users", Context.MODE_PRIVATE), + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-users", Context.MODE_PRIVATE)); + boolean stores = true; + for (String key : userKeys) { + Map flagValues = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + key, Context.MODE_PRIVATE).getAll(); + String prefsKey = LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + key + "-flags"; + SharedPreferences.Editor userFlagStoreEditor = application.getSharedPreferences(prefsKey, Context.MODE_PRIVATE).edit(); + for (String flagKey : flagKeys) { + String flagString = reconstructFlag(flagKey, (String) flagData.get(flagKey), flagValues.get(flagKey)); + userFlagStoreEditor.putString(flagKey, flagString); + } + stores = stores && userFlagStoreEditor.commit(); + } + allSuccess = allSuccess && users && stores; + } + + if (allSuccess) { + Timber.d("Migration to v2.7.0 shared preferences store successful"); + SharedPreferences migrations = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "migrations", Context.MODE_PRIVATE); + boolean logged = migrations.edit().putString("v2.7.0", "v2.7.0").commit(); + if (logged) { + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "users", Context.MODE_PRIVATE).edit().clear().apply(); + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "version", Context.MODE_PRIVATE).edit().clear().apply(); + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "active", Context.MODE_PRIVATE).edit().clear().apply(); + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "summaryevents", Context.MODE_PRIVATE).edit().clear().apply(); + for (String key : userKeys) { + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + key, Context.MODE_PRIVATE).edit().clear().apply(); + } + } + } + } + + private static void migrate_2_7_from_2_6(Application application) { + Timber.d("Migrating to v2.7.0 shared preferences store from v2.6.0"); + + Multimap keyUsers = getUserKeys_2_6(application); + + boolean allSuccess = true; + for (String mobileKey : keyUsers.keySet()) { + SharedPreferences versionSharedPrefs = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-version", Context.MODE_PRIVATE); + Map flagData = versionSharedPrefs.getAll(); + Set flagKeys = flagData.keySet(); + + for (String key : keyUsers.get(mobileKey)) { + Map flagValues = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + key + "-user", Context.MODE_PRIVATE).getAll(); + SharedPreferences.Editor userFlagStoreEditor = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + key + "-flags", Context.MODE_PRIVATE).edit(); + for (String flagKey : flagKeys) { + String flagString = reconstructFlag(flagKey, (String) flagData.get(flagKey), flagValues.get(flagKey)); + userFlagStoreEditor.putString(flagKey, flagString); + } + allSuccess = allSuccess && userFlagStoreEditor.commit(); + } + } + + if (allSuccess) { + Timber.d("Migration to v2.7.0 shared preferences store successful"); + SharedPreferences migrations = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + "migrations", Context.MODE_PRIVATE); + boolean logged = migrations.edit().putString("v2.7.0", "v2.7.0").commit(); + if (logged) { + for (String mobileKey : keyUsers.keySet()) { + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-version", Context.MODE_PRIVATE).edit().clear().apply(); + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-active", Context.MODE_PRIVATE).edit().clear().apply(); + for (String key : keyUsers.get(mobileKey)) { + application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + key + "-user", Context.MODE_PRIVATE).edit().clear().apply(); + } + } + } + } + } + + static ArrayList getUserKeysPre_2_6(Application application, LDConfig config) { + File directory = new File(application.getFilesDir().getParent() + "/shared_prefs/"); + File[] files = directory.listFiles(); + ArrayList filenames = new ArrayList<>(); + for (File file : files) { + if (file.isFile()) + filenames.add(file.getName()); + } + + filenames.remove(LDConfig.SHARED_PREFS_BASE_KEY + "id.xml"); + filenames.remove(LDConfig.SHARED_PREFS_BASE_KEY + "users.xml"); + filenames.remove(LDConfig.SHARED_PREFS_BASE_KEY + "version.xml"); + filenames.remove(LDConfig.SHARED_PREFS_BASE_KEY + "active.xml"); + filenames.remove(LDConfig.SHARED_PREFS_BASE_KEY + "summaryevents.xml"); + filenames.remove(LDConfig.SHARED_PREFS_BASE_KEY + "migrations.xml"); + + Iterator nameIter = filenames.iterator(); + while (nameIter.hasNext()) { + String name = nameIter.next(); + if (!name.startsWith(LDConfig.SHARED_PREFS_BASE_KEY) || !name.endsWith(".xml")) { + nameIter.remove(); + continue; + } + for (String mobileKey : config.getMobileKeys().values()) { + if (mobileKey != null && name.contains(mobileKey)) { + nameIter.remove(); + break; + } + } + } + + ArrayList userKeys = new ArrayList<>(); + for (String filename : filenames) { + userKeys.add(filename.substring(LDConfig.SHARED_PREFS_BASE_KEY.length(), filename.length() - 4)); + } + return userKeys; + } + + static Multimap getUserKeys_2_6(Application application) { + File directory = new File(application.getFilesDir().getParent() + "/shared_prefs/"); + File[] files = directory.listFiles(); + ArrayList filenames = new ArrayList<>(); + for (File file : files) { + String name = file.getName(); + if (file.isFile() && name.startsWith(LDConfig.SHARED_PREFS_BASE_KEY) && name.endsWith("-user.xml")) { + filenames.add(file.getName()); + } + } + + Multimap keyUserMap = HashMultimap.create(); + for (String filename : filenames) { + String strip = filename.substring(LDConfig.SHARED_PREFS_BASE_KEY.length(), filename.length() - 9); + int splitAt = strip.length() - 44; + if (splitAt > 0) { + String mobileKey = strip.substring(0, splitAt); + String userKey = strip.substring(splitAt); + keyUserMap.put(mobileKey, userKey); + } + } + return keyUserMap; + } + + private static boolean copySharedPreferences(SharedPreferences oldPreferences, SharedPreferences newPreferences) { + SharedPreferences.Editor editor = newPreferences.edit(); + + for (Map.Entry entry : oldPreferences.getAll().entrySet()) { + Object value = entry.getValue(); + String key = entry.getKey(); + + if (value instanceof Boolean) + editor.putBoolean(key, (Boolean) value); + else if (value instanceof Float) + editor.putFloat(key, (Float) value); + else if (value instanceof Integer) + editor.putInt(key, (Integer) value); + else if (value instanceof Long) + editor.putLong(key, (Long) value); + else if (value instanceof String) + editor.putString(key, ((String) value)); + } + + return editor.commit(); + } + +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/PollingUpdateProcessor.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/PollingUpdateProcessor.java index 782efd2f..3eb39cd7 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/PollingUpdateProcessor.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/PollingUpdateProcessor.java @@ -1,6 +1,5 @@ package com.launchdarkly.android; - import android.content.Context; import com.google.common.util.concurrent.ListenableFuture; diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/StreamUpdateProcessor.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/StreamUpdateProcessor.java index e355c646..e45df010 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/StreamUpdateProcessor.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/StreamUpdateProcessor.java @@ -77,12 +77,12 @@ public void onClosed() { } @Override - public void onMessage(final String name, MessageEvent event) throws Exception { + public void onMessage(final String name, MessageEvent event) { Timber.d("onMessage: name: %s", name); final String eventData = event.getData(); Callable updateCurrentUserFunction = new Callable() { @Override - public Void call() throws Exception { + public Void call() { Timber.d("consumeThis: event: %s", eventData); if (!initialized.getAndSet(true)) { initFuture.setFuture(handle(name, eventData)); @@ -161,6 +161,10 @@ private URI getUri(@Nullable LDUser user) { str += "/" + user.getAsUrlSafeBase64(); } + if (config.isEvaluationReasons()) { + str += "?withReasons=true"; + } + return URI.create(str); } diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/SummaryEventSharedPreferences.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/SummaryEventSharedPreferences.java new file mode 100644 index 00000000..f41d0cf2 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/SummaryEventSharedPreferences.java @@ -0,0 +1,17 @@ +package com.launchdarkly.android; + +import android.support.annotation.Nullable; + +import com.google.gson.JsonElement; + +/** + * Created by jamesthacker on 4/12/18. + */ + +public interface SummaryEventSharedPreferences { + + void clear(); + void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElement defaultVal, int version, @Nullable Integer variation); + SummaryEvent getSummaryEvent(); + SummaryEvent getSummaryEventAndClear(); +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Throttler.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Throttler.java index 81a6dae0..6582cd37 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Throttler.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Throttler.java @@ -1,7 +1,7 @@ package com.launchdarkly.android; -/** - * Created by jamesthacker on 4/2/18. +/* + Created by jamesthacker on 4/2/18. */ import android.os.Handler; diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UpdateProcessor.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UpdateProcessor.java index 0019d31d..914c0436 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UpdateProcessor.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UpdateProcessor.java @@ -20,7 +20,7 @@ interface UpdateProcessor { /** * Returns true once the UpdateProcessor has been initialized and will never return false again. * - * @return + * @return true once the UpdateProcessor has been initialized and ever after */ boolean isInitialized(); diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserHasher.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserHasher.java index 81cb87a1..8f5ee859 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserHasher.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserHasher.java @@ -1,13 +1,11 @@ package com.launchdarkly.android; - import android.util.Base64; import com.google.common.base.Charsets; import com.google.common.hash.HashFunction; import com.google.common.hash.Hashing; - /** * Provides a single hash method that takes a String and returns a unique filename-safe hash of it. * It exists as a separate class so we can unit test it and assert that different instances diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserLocalSharePreferences.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserLocalSharePreferences.java deleted file mode 100644 index 8c756a27..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserLocalSharePreferences.java +++ /dev/null @@ -1,371 +0,0 @@ -package com.launchdarkly.android; - -import android.annotation.SuppressLint; -import android.app.Application; -import android.content.Context; -import android.content.SharedPreferences; -import android.util.Pair; - -import com.google.common.collect.HashMultimap; -import com.google.common.collect.Multimap; -import com.google.common.collect.Multimaps; - -import java.io.File; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.Date; -import java.util.HashMap; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; - -import timber.log.Timber; - -class UserLocalSharedPreferences { - - private static final int MAX_USERS = 5; - - // The active user is the one that we track for changes to enable listeners. - // Its values will mirror the current user, but it is a different SharedPreferences - // than the current user so we can attach OnSharedPreferenceChangeListeners to it. - private final SharedPreferences activeUserSharedPrefs; - - // Keeps track of the 5 most recent current users - private final SharedPreferences usersSharedPrefs; - - private final Application application; - // Maintains references enabling (de)registration of listeners for realtime updates - private final Multimap> listeners; - - // The current user- we'll always fetch this user from the response we get from the api - private SharedPreferences currentUserSharedPrefs; - - private String mobileKey; - - UserLocalSharedPreferences(Application application, String mobileKey) { - this.application = application; - this.usersSharedPrefs = application.getSharedPreferences(LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-users", Context.MODE_PRIVATE); - this.mobileKey = mobileKey; - this.activeUserSharedPrefs = loadSharedPrefsForActiveUser(); - HashMultimap> multimap = HashMultimap.create(); - listeners = Multimaps.synchronizedMultimap(multimap); - } - - SharedPreferences getCurrentUserSharedPrefs() { - return currentUserSharedPrefs; - } - - void setCurrentUser(LDUser user) { - currentUserSharedPrefs = loadSharedPrefsForUser(user.getSharedPrefsKey()); - - usersSharedPrefs.edit() - .putLong(user.getSharedPrefsKey(), System.currentTimeMillis()) - .apply(); - - while (usersSharedPrefs.getAll().size() > MAX_USERS) { - List allUsers = getAllUsers(); - String removed = allUsers.get(0); - Timber.d("Exceeded max # of users: [%s] Removing user: [%s]", MAX_USERS, removed); - deleteSharedPreferences(removed); - usersSharedPrefs.edit() - .remove(removed) - .apply(); - } - - } - - private SharedPreferences loadSharedPrefsForUser(String user) { - Timber.d("Using SharedPreferences key: [%s]", sharedPrefsKeyForUser(user)); - return application.getSharedPreferences(sharedPrefsKeyForUser(user), Context.MODE_PRIVATE); - } - - private String sharedPrefsKeyForUser(String user) { - return LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + user + "-user"; - } - - // Gets all users sorted by creation time (oldest first) - private List getAllUsers() { - Map all = usersSharedPrefs.getAll(); - Map allTyped = new HashMap<>(); - //get typed versions of the users' timestamps: - for (String k : all.keySet()) { - try { - allTyped.put(k, usersSharedPrefs.getLong(k, Long.MIN_VALUE)); - Timber.d("Found user: %s", userAndTimeStampToHumanReadableString(k, allTyped.get(k))); - } catch (ClassCastException cce) { - Timber.e(cce, "Unexpected type! This is not good"); - } - } - - List> sorted = new LinkedList<>(allTyped.entrySet()); - Collections.sort(sorted, new EntryComparator()); - List results = new LinkedList<>(); - for (Map.Entry e : sorted) { - Timber.d("Found sorted user: %s", userAndTimeStampToHumanReadableString(e.getKey(), e.getValue())); - results.add(e.getKey()); - } - return results; - } - - private static String userAndTimeStampToHumanReadableString(String userSharedPrefsKey, Long timestamp) { - return userSharedPrefsKey + " [" + userSharedPrefsKey + "] timestamp: [" + timestamp + "] [" + new Date(timestamp) + "]"; - } - - /** - * Completely deletes a user's saved flag settings and the remaining empty SharedPreferences xml file. - * - * @param userKey - */ - @SuppressWarnings("JavaDoc") - @SuppressLint("ApplySharedPref") - private void deleteSharedPreferences(String userKey) { - SharedPreferences sharedPrefsToDelete = loadSharedPrefsForUser(userKey); - sharedPrefsToDelete.edit() - .clear() - .commit(); - - File file = new File(application.getFilesDir().getParent() + "/shared_prefs/" + sharedPrefsKeyForUser(userKey) + ".xml"); - Timber.i("Deleting SharedPrefs file:%s", file.getAbsolutePath()); - - //noinspection ResultOfMethodCallIgnored - file.delete(); - } - - private SharedPreferences loadSharedPrefsForActiveUser() { - String sharedPrefsKey = LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-active"; - Timber.d("Using SharedPreferences key for active user: [%s]", sharedPrefsKey); - return application.getSharedPreferences(sharedPrefsKey, Context.MODE_PRIVATE); - } - - Collection> getListener(String key) { - synchronized (listeners) { - return listeners.get(key); - } - } - - void registerListener(final String key, final FeatureFlagChangeListener listener) { - SharedPreferences.OnSharedPreferenceChangeListener sharedPrefsListener = new SharedPreferences.OnSharedPreferenceChangeListener() { - @Override - public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String s) { - if (s.equals(key)) { - Timber.d("Found changed flag: [%s]", key); - listener.onFeatureFlagChange(s); - } - } - }; - synchronized (listeners) { - listeners.put(key, new Pair<>(listener, sharedPrefsListener)); - Timber.d("Added listener. Total count: [%s]", listeners.size()); - } - activeUserSharedPrefs.registerOnSharedPreferenceChangeListener(sharedPrefsListener); - - } - - void unRegisterListener(String key, FeatureFlagChangeListener listener) { - synchronized (listeners) { - Iterator> it = listeners.get(key).iterator(); - while (it.hasNext()) { - Pair pair = it.next(); - if (pair.first.equals(listener)) { - Timber.d("Removing listener for key: [%s]", key); - activeUserSharedPrefs.unregisterOnSharedPreferenceChangeListener(pair.second); - it.remove(); - } - } - } - } - - void saveCurrentUserFlags(SharedPreferencesEntries sharedPreferencesEntries) { - sharedPreferencesEntries.clearAndSave(currentUserSharedPrefs); - } - - /** - * Copies the current user's feature flag values to the active user {@link SharedPreferences}. - * Only changed values will be modified to avoid unwanted triggering of listeners as described - * - * here. - *

- * Any flag values no longer found in the current user will be removed from the - * active user as well as their listeners. - */ - void syncCurrentUserToActiveUser() { - SharedPreferences.Editor activeEditor = activeUserSharedPrefs.edit(); - Map active = activeUserSharedPrefs.getAll(); - Map current = currentUserSharedPrefs.getAll(); - - for (Map.Entry entry : current.entrySet()) { - Object v = entry.getValue(); - String key = entry.getKey(); - Timber.d("key: [%s] CurrentUser value: [%s] ActiveUser value: [%s]", key, v, active.get(key)); - if (v instanceof Boolean) { - if (!v.equals(active.get(key))) { - activeEditor.putBoolean(key, (Boolean) v); - Timber.d("Found new boolean flag value for key: [%s] with value: [%s]", key, v); - } - } else if (v instanceof Float) { - if (!v.equals(active.get(key))) { - activeEditor.putFloat(key, (Float) v); - Timber.d("Found new numeric flag value for key: [%s] with value: [%s]", key, v); - } - } else if (v instanceof String) { - if (!v.equals(active.get(key))) { - activeEditor.putString(key, (String) v); - Timber.d("Found new json or string flag value for key: [%s] with value: [%s]", key, v); - } - } else { - Timber.w("Found some unknown feature flag type for key: [%s] with value: [%s]", key, v); - } - } - - // Because we didn't clear the active editor to avoid triggering listeners, - // we need to remove any flags that have been deleted: - for (String key : active.keySet()) { - if (current.get(key) == null) { - Timber.d("Deleting value and listeners for key: [%s]", key); - activeEditor.remove(key); - synchronized (listeners) { - listeners.removeAll(key); - } - } - } - activeEditor.apply(); - - } - - void logCurrentUserFlags() { - Map all = currentUserSharedPrefs.getAll(); - if (all.size() == 0) { - Timber.d("found zero saved feature flags"); - } else { - Timber.d("Found %s feature flags:", all.size()); - for (Map.Entry kv : all.entrySet()) { - Timber.d("\tKey: [%s] value: [%s]", kv.getKey(), kv.getValue()); - } - } - } - - void deleteCurrentUserFlag(String flagKey) { - Timber.d("Request to delete key: [%s]", flagKey); - - removeCurrentUserFlag(flagKey); - - } - - @SuppressLint("ApplySharedPref") - private void removeCurrentUserFlag(String flagKey) { - SharedPreferences.Editor editor = currentUserSharedPrefs.edit(); - Map current = currentUserSharedPrefs.getAll(); - - for (Map.Entry entry : current.entrySet()) { - Object v = entry.getValue(); - String key = entry.getKey(); - - if (key.equals(flagKey)) { - editor.remove(flagKey); - Timber.d("Deleting key: [%s] CurrentUser value: [%s]", key, v); - } - } - - editor.commit(); - } - - void patchCurrentUserFlags(SharedPreferencesEntries sharedPreferencesEntries) { - sharedPreferencesEntries.update(currentUserSharedPrefs); - } - - class EntryComparator implements Comparator> { - @Override - public int compare(Map.Entry lhs, Map.Entry rhs) { - return (int) (lhs.getValue() - rhs.getValue()); - } - } - - @SuppressLint("ApplySharedPref") - static class SharedPreferencesEntries { - - private final List sharedPreferencesEntryList; - - SharedPreferencesEntries(List sharedPreferencesEntryList) { - this.sharedPreferencesEntryList = sharedPreferencesEntryList; - } - - void clearAndSave(SharedPreferences sharedPreferences) { - SharedPreferences.Editor editor = sharedPreferences.edit(); - editor.clear(); - for (SharedPreferencesEntry entry : sharedPreferencesEntryList) { - entry.saveWithoutApply(editor); - } - editor.commit(); - } - - void update(SharedPreferences sharedPreferences) { - - SharedPreferences.Editor editor = sharedPreferences.edit(); - - for (SharedPreferencesEntry entry : sharedPreferencesEntryList) { - entry.saveWithoutApply(editor); - } - editor.commit(); - } - } - - abstract static class SharedPreferencesEntry { - - protected final String key; - protected final K value; - - SharedPreferencesEntry(String key, K value) { - this.key = key; - this.value = value; - } - - public String getKey() { - return key; - } - - public K getValue() { - return value; - } - - abstract void saveWithoutApply(SharedPreferences.Editor editor); - } - - static class BooleanSharedPreferencesEntry extends SharedPreferencesEntry { - - BooleanSharedPreferencesEntry(String key, Boolean value) { - super(key, value); - } - - @Override - void saveWithoutApply(SharedPreferences.Editor editor) { - editor.putBoolean(key, value); - } - } - - static class StringSharedPreferencesEntry extends SharedPreferencesEntry { - - StringSharedPreferencesEntry(String key, String value) { - super(key, value); - } - - @Override - void saveWithoutApply(SharedPreferences.Editor editor) { - editor.putString(key, value); - } - } - - static class FloatSharedPreferencesEntry extends SharedPreferencesEntry { - - FloatSharedPreferencesEntry(String key, Float value) { - super(key, value); - } - - @Override - void saveWithoutApply(SharedPreferences.Editor editor) { - editor.putFloat(key, value); - } - } - -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserManager.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserManager.java index 9b1f95ad..2fb573b2 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserManager.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserManager.java @@ -2,12 +2,9 @@ import android.app.Application; import android.content.SharedPreferences; -import android.content.SharedPreferences.OnSharedPreferenceChangeListener; import android.support.annotation.NonNull; -import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; import android.util.Base64; -import android.util.Pair; import com.google.common.base.Function; import com.google.common.util.concurrent.FutureCallback; @@ -15,26 +12,18 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; -import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.JsonSyntaxException; -import com.launchdarkly.android.response.FlagResponse; -import com.launchdarkly.android.response.FlagResponseSharedPreferences; -import com.launchdarkly.android.response.FlagResponseStore; -import com.launchdarkly.android.response.SummaryEventSharedPreferences; -import com.launchdarkly.android.response.UserFlagResponseSharedPreferences; -import com.launchdarkly.android.response.UserFlagResponseStore; -import com.launchdarkly.android.response.UserSummaryEventSharedPreferences; -import com.launchdarkly.android.response.interpreter.DeleteFlagResponseInterpreter; -import com.launchdarkly.android.response.interpreter.PatchFlagResponseInterpreter; -import com.launchdarkly.android.response.interpreter.PingFlagResponseInterpreter; -import com.launchdarkly.android.response.interpreter.PutFlagResponseInterpreter; +import com.launchdarkly.android.flagstore.Flag; +import com.launchdarkly.android.flagstore.FlagStore; +import com.launchdarkly.android.flagstore.FlagStoreManager; +import com.launchdarkly.android.flagstore.sharedprefs.SharedPrefsFlagStoreFactory; +import com.launchdarkly.android.flagstore.sharedprefs.SharedPrefsFlagStoreManager; +import com.launchdarkly.android.gson.GsonCache; +import com.launchdarkly.android.response.DeleteFlagResponse; +import com.launchdarkly.android.response.FlagsResponse; -import java.util.ArrayList; import java.util.Collection; import java.util.List; -import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import timber.log.Timber; @@ -50,13 +39,11 @@ class UserManager { private volatile boolean initialized = false; private final Application application; - private final UserLocalSharedPreferences userLocalSharedPreferences; - private final FlagResponseSharedPreferences flagResponseSharedPreferences; + private final FlagStoreManager flagStoreManager; private final SummaryEventSharedPreferences summaryEventSharedPreferences; private final String environmentName; private LDUser currentUser; - private final Util.LazySingleton jsonParser; private final ExecutorService executor; @@ -67,17 +54,10 @@ static synchronized UserManager newInstance(Application application, FeatureFlag UserManager(Application application, FeatureFlagFetcher fetcher, String environmentName, String mobileKey) { this.application = application; this.fetcher = fetcher; - this.userLocalSharedPreferences = new UserLocalSharedPreferences(application, mobileKey); - this.flagResponseSharedPreferences = new UserFlagResponseSharedPreferences(application, LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-version"); + this.flagStoreManager = new SharedPrefsFlagStoreManager(application, mobileKey, new SharedPrefsFlagStoreFactory(application)); this.summaryEventSharedPreferences = new UserSummaryEventSharedPreferences(application, LDConfig.SHARED_PREFS_BASE_KEY + mobileKey + "-summaryevents"); this.environmentName = environmentName; - jsonParser = new Util.LazySingleton<>(new Util.Provider() { - @Override - public JsonParser get() { - return new JsonParser(); - } - }); executor = new BackgroundThreadExecutor().newFixedThreadPool(1); } @@ -85,12 +65,8 @@ LDUser getCurrentUser() { return currentUser; } - SharedPreferences getCurrentUserSharedPrefs() { - return userLocalSharedPreferences.getCurrentUserSharedPrefs(); - } - - FlagResponseSharedPreferences getFlagResponseSharedPreferences() { - return flagResponseSharedPreferences; + FlagStore getCurrentUserFlagStore() { + return flagStoreManager.getCurrentUserStore(); } SummaryEventSharedPreferences getSummaryEventSharedPreferences() { @@ -101,14 +77,13 @@ SummaryEventSharedPreferences getSummaryEventSharedPreferences() { * Sets the current user. If there are more than MAX_USERS stored in shared preferences, * the oldest one is deleted. * - * @param user + * @param user The user to switch to. */ - @SuppressWarnings("JavaDoc") void setCurrentUser(final LDUser user) { String userBase64 = user.getAsUrlSafeBase64(); Timber.d("Setting current user to: [%s] [%s]", userBase64, userBase64ToJson(userBase64)); currentUser = user; - userLocalSharedPreferences.setCurrentUser(user); + flagStoreManager.switchToUser(user.getSharedPrefsKey()); } ListenableFuture updateCurrentUser() { @@ -126,7 +101,6 @@ public void onFailure(@NonNull Throwable t) { if (Util.isClientConnected(application, environmentName)) { Timber.e(t, "Error when attempting to set user: [%s] [%s]", currentUser.getAsUrlSafeBase64(), userBase64ToJson(currentUser.getAsUrlSafeBase64())); } - syncCurrentUserToActiveUserAndLog(); } }, MoreExecutors.directExecutor()); @@ -140,17 +114,12 @@ public Void apply(@javax.annotation.Nullable JsonObject input) { }, MoreExecutors.directExecutor()); } - @SuppressWarnings("SameParameterValue") - Collection> getListenersByKey(String key) { - return userLocalSharedPreferences.getListener(key); - } - void registerListener(final String key, final FeatureFlagChangeListener listener) { - userLocalSharedPreferences.registerListener(key, listener); + flagStoreManager.registerListener(key, listener); } void unregisterListener(String key, FeatureFlagChangeListener listener) { - userLocalSharedPreferences.unRegisterListener(key, listener); + flagStoreManager.unRegisterListener(key, listener); } /** @@ -159,28 +128,20 @@ void unregisterListener(String key, FeatureFlagChangeListener listener) { * saves those values to the active user, triggering any registered {@link FeatureFlagChangeListener} * objects. * - * @param flags + * @param flagsJson */ @SuppressWarnings("JavaDoc") - private void saveFlagSettings(JsonObject flags) { - + private void saveFlagSettings(JsonObject flagsJson) { Timber.d("saveFlagSettings for user key: %s", currentUser.getKey()); - FlagResponseStore> responseStore = new UserFlagResponseStore<>(flags, new PingFlagResponseInterpreter()); - List flagResponseList = responseStore.getFlagResponse(); - if (flagResponseList != null) { - flagResponseSharedPreferences.clear(); - flagResponseSharedPreferences.saveAll(flagResponseList); - userLocalSharedPreferences.saveCurrentUserFlags(getSharedPreferencesEntries(flagResponseList)); - syncCurrentUserToActiveUserAndLog(); + try { + final List flags = GsonCache.getGson().fromJson(flagsJson, FlagsResponse.class).getFlags(); + flagStoreManager.getCurrentUserStore().clearAndApplyFlagUpdates(flags); + } catch (Exception e) { + Timber.d("Invalid JsonObject for flagSettings: %s", flagsJson); } } - private void syncCurrentUserToActiveUserAndLog() { - userLocalSharedPreferences.syncCurrentUserToActiveUser(); - userLocalSharedPreferences.logCurrentUserFlags(); - } - private static String userBase64ToJson(String base64) { return new String(Base64.decode(base64, Base64.URL_SAFE)); } @@ -190,165 +151,70 @@ boolean isInitialized() { } ListenableFuture deleteCurrentUserFlag(@NonNull final String json) { - - JsonObject jsonObject = parseJson(json); - final FlagResponseStore responseStore - = new UserFlagResponseStore<>(jsonObject, new DeleteFlagResponseInterpreter()); - - ListeningExecutorService service = MoreExecutors.listeningDecorator(executor); - return service.submit(new Callable() { - @Override - public Void call() throws Exception { - initialized = true; - FlagResponse flagResponse = responseStore.getFlagResponse(); - if (flagResponse != null) { - if (flagResponseSharedPreferences.isVersionValid(flagResponse)) { - flagResponseSharedPreferences.deleteStoredFlagResponse(flagResponse); - - userLocalSharedPreferences.deleteCurrentUserFlag(flagResponse.getKey()); - UserManager.this.syncCurrentUserToActiveUserAndLog(); + try { + final DeleteFlagResponse deleteFlagResponse = GsonCache.getGson().fromJson(json, DeleteFlagResponse.class); + ListeningExecutorService service = MoreExecutors.listeningDecorator(executor); + return service.submit(new Runnable() { + @Override + public void run() { + initialized = true; + if (deleteFlagResponse != null) { + flagStoreManager.getCurrentUserStore().applyFlagUpdate(deleteFlagResponse); + } else { + Timber.d("Invalid DELETE payload: %s", json); } - } else { - Timber.d("Invalid DELETE payload: %s", json); } - return null; - } - }); + }, null); + } catch (Exception ex) { + Timber.d(ex, "Invalid DELETE payload: %s", json); + // In future should this be an immediateFailedFuture? + return Futures.immediateFuture(null); + } } ListenableFuture putCurrentUserFlags(final String json) { - - JsonObject jsonObject = parseJson(json); - final FlagResponseStore> responseStore = - new UserFlagResponseStore<>(jsonObject, new PutFlagResponseInterpreter()); - - ListeningExecutorService service = MoreExecutors.listeningDecorator(executor); - return service.submit(new Callable() { - @Override - public Void call() throws Exception { - initialized = true; - Timber.d("PUT for user key: %s", currentUser.getKey()); - - List flagResponseList = responseStore.getFlagResponse(); - if (flagResponseList != null) { - flagResponseSharedPreferences.clear(); - flagResponseSharedPreferences.saveAll(flagResponseList); - - userLocalSharedPreferences.saveCurrentUserFlags(UserManager.this.getSharedPreferencesEntries(flagResponseList)); - UserManager.this.syncCurrentUserToActiveUserAndLog(); - } else { - Timber.d("Invalid PUT payload: %s", json); + try { + final List flags = GsonCache.getGson().fromJson(json, FlagsResponse.class).getFlags(); + ListeningExecutorService service = MoreExecutors.listeningDecorator(executor); + return service.submit(new Runnable() { + @Override + public void run() { + initialized = true; + Timber.d("PUT for user key: %s", currentUser.getKey()); + flagStoreManager.getCurrentUserStore().clearAndApplyFlagUpdates(flags); } - return null; - } - }); + }, null); + } catch (Exception ex) { + Timber.d(ex, "Invalid PUT payload: %s", json); + // In future should this be an immediateFailedFuture? + return Futures.immediateFuture(null); + } } ListenableFuture patchCurrentUserFlags(@NonNull final String json) { - - JsonObject jsonObject = parseJson(json); - final FlagResponseStore responseStore - = new UserFlagResponseStore<>(jsonObject, new PatchFlagResponseInterpreter()); - - ListeningExecutorService service = MoreExecutors.listeningDecorator(executor); - return service.submit(new Callable() { - @Override - public Void call() throws Exception { - initialized = true; - FlagResponse flagResponse = responseStore.getFlagResponse(); - if (flagResponse != null) { - if (flagResponse.isVersionMissing() || flagResponseSharedPreferences.isVersionValid(flagResponse)) { - flagResponseSharedPreferences.updateStoredFlagResponse(flagResponse); - - UserLocalSharedPreferences.SharedPreferencesEntries sharedPreferencesEntries = UserManager.this.getSharedPreferencesEntries(flagResponse); - userLocalSharedPreferences.patchCurrentUserFlags(sharedPreferencesEntries); - UserManager.this.syncCurrentUserToActiveUserAndLog(); + try { + final Flag flag = GsonCache.getGson().fromJson(json, Flag.class); + ListeningExecutorService service = MoreExecutors.listeningDecorator(executor); + return service.submit(new Runnable() { + @Override + public void run() { + initialized = true; + if (flag != null) { + flagStoreManager.getCurrentUserStore().applyFlagUpdate(flag); + } else { + Timber.d("Invalid PATCH payload: %s", json); } - } else { - Timber.d("Invalid PATCH payload: %s", json); } - return null; - } - }); - - } - - @NonNull - private JsonObject parseJson(String json) { - JsonParser parser = jsonParser.get(); - if (json != null) { - try { - return parser.parse(json).getAsJsonObject(); - } catch (JsonSyntaxException | IllegalStateException exception) { - Timber.e(exception); - } - } - return new JsonObject(); - } - - @NonNull - private UserLocalSharedPreferences.SharedPreferencesEntries getSharedPreferencesEntries(@Nullable FlagResponse flagResponse) { - List sharedPreferencesEntryList - = new ArrayList<>(); - - if (flagResponse != null) { - JsonElement v = flagResponse.getValue(); - String key = flagResponse.getKey(); - - UserLocalSharedPreferences.SharedPreferencesEntry sharedPreferencesEntry = getSharedPreferencesEntry(flagResponse); - if (sharedPreferencesEntry == null) { - Timber.w("Found some unknown feature flag type for key: [%s] value: [%s]", key, v.toString()); - } else { - sharedPreferencesEntryList.add(sharedPreferencesEntry); - } - } - - return new UserLocalSharedPreferences.SharedPreferencesEntries(sharedPreferencesEntryList); - - } - - @NonNull - private UserLocalSharedPreferences.SharedPreferencesEntries getSharedPreferencesEntries(@NonNull List flagResponseList) { - List sharedPreferencesEntryList - = new ArrayList<>(); - - for (FlagResponse flagResponse : flagResponseList) { - JsonElement v = flagResponse.getValue(); - String key = flagResponse.getKey(); - - UserLocalSharedPreferences.SharedPreferencesEntry sharedPreferencesEntry = getSharedPreferencesEntry(flagResponse); - if (sharedPreferencesEntry == null) { - Timber.w("Found some unknown feature flag type for key: [%s] value: [%s]", key, v.toString()); - } else { - sharedPreferencesEntryList.add(sharedPreferencesEntry); - } + }, null); + } catch (Exception ex) { + Timber.d(ex, "Invalid PATCH payload: %s", json); + // In future should this be an immediateFailedFuture? + return Futures.immediateFuture(null); } - - return new UserLocalSharedPreferences.SharedPreferencesEntries(sharedPreferencesEntryList); - - } - - - @Nullable - private UserLocalSharedPreferences.SharedPreferencesEntry getSharedPreferencesEntry(@NonNull FlagResponse flagResponse) { - String key = flagResponse.getKey(); - JsonElement element = flagResponse.getValue(); - - if (element.isJsonObject() || element.isJsonArray()) { - return new UserLocalSharedPreferences.StringSharedPreferencesEntry(key, element.toString()); - } else if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isBoolean()) { - return new UserLocalSharedPreferences.BooleanSharedPreferencesEntry(key, element.getAsBoolean()); - } else if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isNumber()) { - return new UserLocalSharedPreferences.FloatSharedPreferencesEntry(key, element.getAsFloat()); - } else if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { - return new UserLocalSharedPreferences.StringSharedPreferencesEntry(key, element.getAsString()); - } - return null; } @VisibleForTesting - void clearFlagResponseSharedPreferences() { - this.flagResponseSharedPreferences.clear(); + public Collection getListenersByKey(String key) { + return flagStoreManager.getListenersByKey(key); } - } diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserSummaryEventSharedPreferences.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserSummaryEventSharedPreferences.java new file mode 100644 index 00000000..e398ce38 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/UserSummaryEventSharedPreferences.java @@ -0,0 +1,185 @@ +package com.launchdarkly.android; + +import android.annotation.SuppressLint; +import android.app.Application; +import android.content.Context; +import android.content.SharedPreferences; +import android.support.annotation.Nullable; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; + +import timber.log.Timber; + +/** + * Created by jamesthacker on 4/12/18. + */ + +public class UserSummaryEventSharedPreferences implements SummaryEventSharedPreferences { + + private SharedPreferences sharedPreferences; + + UserSummaryEventSharedPreferences(Application application, String name) { + this.sharedPreferences = application.getSharedPreferences(name, Context.MODE_PRIVATE); + } + + @Override + public synchronized void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElement defaultVal, int version, @Nullable Integer nullableVariation) { + JsonElement variation = nullableVariation == null ? JsonNull.INSTANCE : new JsonPrimitive(nullableVariation); + JsonObject object = getValueAsJsonObject(flagResponseKey); + if (object == null) { + object = createNewEvent(value, defaultVal, version, variation); + } else { + JsonArray countersArray = object.get("counters").getAsJsonArray(); + + boolean isUnknown = version == -1; + boolean variationExists = false; + for (JsonElement element : countersArray) { + if (element instanceof JsonObject) { + JsonObject asJsonObject = element.getAsJsonObject(); + boolean unknownElement = asJsonObject.get("unknown") != null && !asJsonObject.get("unknown").equals(JsonNull.INSTANCE) && asJsonObject.get("unknown").getAsBoolean(); + + if (unknownElement != isUnknown) { + continue; + } + // Both are unknown and same value + if (isUnknown && value.equals(asJsonObject.get("value"))) { + variationExists = true; + int currentCount = asJsonObject.get("count").getAsInt(); + asJsonObject.add("count", new JsonPrimitive(++currentCount)); + break; + } + JsonElement variationElement = asJsonObject.get("variation"); + JsonElement versionElement = asJsonObject.get("version"); + + // We can compare variation rather than value. + boolean isSameVersion = versionElement != null && asJsonObject.get("version").getAsInt() == version; + boolean isSameVariation = variationElement != null && variationElement.equals(variation); + if (isSameVersion && isSameVariation) { + variationExists = true; + int currentCount = asJsonObject.get("count").getAsInt(); + asJsonObject.add("count", new JsonPrimitive(++currentCount)); + break; + } + } + } + + if (!variationExists) { + addNewCountersElement(countersArray, value, version, variation); + } + } + + if (sharedPreferences.getAll().isEmpty()) { + object.add("startDate", new JsonPrimitive(System.currentTimeMillis())); + } + + String flagSummary = object.toString(); + + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(flagResponseKey, object.toString()); + editor.apply(); + + Timber.d("Updated summary for flagKey %s to %s", flagResponseKey, flagSummary); + } + + @Override + public synchronized SummaryEvent getSummaryEvent() { + return getSummaryEventNoSync(); + } + + private SummaryEvent getSummaryEventNoSync() { + JsonObject features = getFeaturesJsonObject(); + if (features.keySet().size() == 0) { + return null; + } + Long startDate = null; + for (String key : features.keySet()) { + JsonObject asJsonObject = features.get(key).getAsJsonObject(); + if (asJsonObject.has("startDate")) { + startDate = asJsonObject.get("startDate").getAsLong(); + asJsonObject.remove("startDate"); + break; + } + } + SummaryEvent summaryEvent = new SummaryEvent(startDate, System.currentTimeMillis(), features); + Timber.d("Sending Summary Event: %s", summaryEvent.toString()); + return summaryEvent; + } + + @Override + public synchronized SummaryEvent getSummaryEventAndClear() { + SummaryEvent summaryEvent = getSummaryEventNoSync(); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.clear(); + editor.apply(); + return summaryEvent; + } + + private JsonObject createNewEvent(JsonElement value, JsonElement defaultVal, int version, JsonElement variation) { + JsonObject object = new JsonObject(); + object.add("default", defaultVal); + JsonArray countersArray = new JsonArray(); + addNewCountersElement(countersArray, value, version, variation); + object.add("counters", countersArray); + return object; + } + + private void addNewCountersElement(JsonArray countersArray, @Nullable JsonElement value, int version, JsonElement variation) { + JsonObject newCounter = new JsonObject(); + if (version == -1) { + newCounter.add("unknown", new JsonPrimitive(true)); + newCounter.add("value", value); + } else { + newCounter.add("value", value); + newCounter.add("version", new JsonPrimitive(version)); + newCounter.add("variation", variation); + } + newCounter.add("count", new JsonPrimitive(1)); + countersArray.add(newCounter); + } + + + private JsonObject getFeaturesJsonObject() { + JsonObject returnObject = new JsonObject(); + for (String key : sharedPreferences.getAll().keySet()) { + returnObject.add(key, getValueAsJsonObject(key)); + } + return returnObject; + } + + @SuppressLint("ApplySharedPref") + @Nullable + private JsonObject getValueAsJsonObject(String flagResponseKey) { + String storedFlag; + try { + storedFlag = sharedPreferences.getString(flagResponseKey, null); + } catch (ClassCastException castException) { + // An old version of shared preferences is stored, so clear it. + // The flag responses will get re-synced with the server + sharedPreferences.edit().clear().commit(); + return null; + } + + if (storedFlag == null) { + return null; + } + + JsonElement element = new JsonParser().parse(storedFlag); + if (element instanceof JsonObject) { + return (JsonObject) element; + } + + return null; + } + + public synchronized void clear() { + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.clear(); + editor.apply(); + } + +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Util.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Util.java index bb1a0e83..91a3b1e0 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Util.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/Util.java @@ -9,10 +9,10 @@ class Util { /** - * Looks at both the Android device status to determine if the device is online. + * Looks at the Android device status to determine if the device is online. * - * @param context - * @return + * @param context Context for getting the ConnectivityManager + * @return whether device is connected to the internet */ static boolean isInternetConnected(Context context) { ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); @@ -21,55 +21,35 @@ static boolean isInternetConnected(Context context) { } /** - * Looks at both the Android device status and the {@link LDClient} to determine if any network calls should be made. + * Looks at both the Android device status and the default {@link LDClient} to determine if any network calls should be made. * - * @param context - * @return + * @param context Context for getting the ConnectivityManager + * @return whether the device is connected to the internet and the default LDClient instance is online */ static boolean isClientConnected(Context context) { boolean deviceConnected = isInternetConnected(context); try { return deviceConnected && !LDClient.get().isOffline(); } catch (LaunchDarklyException e) { - Timber.e(e,"Exception caught when getting LDClient"); + Timber.e(e, "Exception caught when getting LDClient"); return false; } } /** - * Looks at both the Android device status and the {@link LDClient} to determine if any network calls should be made. + * Looks at both the Android device status and the environment's {@link LDClient} to determine if any network calls should be made. * - * @param context - * @param environmentName - * @return + * @param context Context for getting the ConnectivityManager + * @param environmentName Name of the environment to get the LDClient for + * @return whether the device is connected to the internet and the LDClient instance is online */ static boolean isClientConnected(Context context, String environmentName) { boolean deviceConnected = isInternetConnected(context); try { return deviceConnected && !LDClient.getForMobileKey(environmentName).isOffline(); } catch (LaunchDarklyException e) { - Timber.e(e,"Exception caught when getting LDClient"); + Timber.e(e, "Exception caught when getting LDClient"); return false; } } - - static class LazySingleton { - private final Provider provider; - private T instance; - - LazySingleton(Provider provider) { - this.provider = provider; - } - - public T get() { - if (instance == null) { - instance = provider.get(); - } - return instance; - } - } - - interface Provider { - T get(); - } } diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/ValueTypes.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/ValueTypes.java new file mode 100644 index 00000000..fb4c06b1 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/ValueTypes.java @@ -0,0 +1,117 @@ +package com.launchdarkly.android; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.launchdarkly.android.gson.GsonCache; + +import timber.log.Timber; + +/** + * Allows the client's flag evaluation methods to treat the various supported data types generically. + */ +abstract class ValueTypes { + /** + * Implements JSON serialization and deserialization for a specific type. + * @param the requested value type + */ + public interface Converter { + /** + * Converts a JSON value to the desired type. The JSON value is guaranteed to be non-null. + * @param jsonValue the JSON value + * @return the converted value, or null if the JSON value was not of the correct type + */ + @Nullable public T valueFromJson(@NonNull JsonElement jsonValue); + + /** + * Converts a value to JSON. The value is guaranteed to be non-null. + * @param value the value + * @return the JSON value + */ + @NonNull public JsonElement valueToJson(@NonNull T value); + } + + public static final Converter BOOLEAN = new Converter() { + @Override + public Boolean valueFromJson(JsonElement jsonValue) { + return (jsonValue.isJsonPrimitive() && jsonValue.getAsJsonPrimitive().isBoolean()) ? jsonValue.getAsBoolean() : null; + } + + @Override + public JsonElement valueToJson(Boolean value) { + return new JsonPrimitive(value); + } + }; + + public static final Converter INT = new Converter() { + @Override + public Integer valueFromJson(JsonElement jsonValue) { + return (jsonValue.isJsonPrimitive() && jsonValue.getAsJsonPrimitive().isNumber()) ? jsonValue.getAsInt() : null; + } + + @Override + public JsonElement valueToJson(Integer value) { + return new JsonPrimitive(value); + } + }; + + public static final Converter FLOAT = new Converter() { + @Override + public Float valueFromJson(JsonElement jsonValue) { + return (jsonValue.isJsonPrimitive() && jsonValue.getAsJsonPrimitive().isNumber()) ? jsonValue.getAsFloat() : null; + } + + @Override + public JsonElement valueToJson(Float value) { + return new JsonPrimitive(value); + } + }; + + public static final Converter STRING = new Converter() { + @Override + public String valueFromJson(JsonElement jsonValue) { + return (jsonValue.isJsonPrimitive() && jsonValue.getAsJsonPrimitive().isString()) ? jsonValue.getAsString() : null; + } + + @Override + public JsonElement valueToJson(String value) { + return new JsonPrimitive(value); + } + }; + + // Used for maintaining compatible behavior in allowing evaluation of Json flags as Strings + // TODO(gwhelanld): remove in 3.0.0 + public static final Converter STRINGCOMPAT = new Converter() { + @Override + public String valueFromJson(JsonElement jsonValue) { + if (jsonValue.isJsonPrimitive() && jsonValue.getAsJsonPrimitive().isString()) { + return jsonValue.getAsString(); + } else if (!jsonValue.isJsonPrimitive() && !jsonValue.isJsonNull()) { + Timber.w("JSON flag requested as String. For backwards compatibility " + + "returning a serialized representation of flag value. " + + "This behavior will be removed in the next major version (3.0.0)"); + return GsonCache.getGson().toJson(jsonValue); + } + return null; + } + + @Override + public JsonElement valueToJson(String value) { + return new JsonPrimitive(value); + } + }; + + public static final Converter JSON = new Converter() { + @Override + public JsonElement valueFromJson(JsonElement jsonValue) { + return jsonValue; + } + + @Override + public JsonElement valueToJson(JsonElement value) { + return value; + } + }; +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/Flag.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/Flag.java new file mode 100644 index 00000000..4f664ac1 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/Flag.java @@ -0,0 +1,88 @@ +package com.launchdarkly.android.flagstore; + +import android.support.annotation.NonNull; + +import com.google.gson.JsonElement; +import com.launchdarkly.android.EvaluationReason; + +public class Flag implements FlagUpdate, FlagInterface { + + @NonNull + private String key; + private JsonElement value; + private Integer version; + private Integer flagVersion; + private Integer variation; + private Boolean trackEvents; + private Long debugEventsUntilDate; + private EvaluationReason reason; + + public Flag(@NonNull String key, JsonElement value, Integer version, Integer flagVersion, Integer variation, Boolean trackEvents, Long debugEventsUntilDate, EvaluationReason reason) { + this.key = key; + this.value = value; + this.version = version; + this.flagVersion = flagVersion; + this.variation = variation; + this.trackEvents = trackEvents; + this.debugEventsUntilDate = debugEventsUntilDate; + this.reason = reason; + } + + @NonNull + public String getKey() { + return key; + } + + public JsonElement getValue() { + return value; + } + + public Integer getVersion() { + return version; + } + + public Integer getFlagVersion() { + return flagVersion; + } + + public Integer getVariation() { + return variation; + } + + public boolean getTrackEvents() { + return trackEvents == null ? false : trackEvents; + } + + public Long getDebugEventsUntilDate() { + return debugEventsUntilDate; + } + + @Override + public EvaluationReason getReason() { + return reason; + } + + public boolean isVersionMissing() { + return version == null; + } + + public int getVersionForEvents() { + if (flagVersion == null) { + return version == null ? -1 : version; + } + return flagVersion; + } + + @Override + public Flag updateFlag(Flag before) { + if (before == null || this.isVersionMissing() || before.isVersionMissing() || this.getVersion() > before.getVersion()) { + return this; + } + return before; + } + + @Override + public String flagToUpdate() { + return key; + } +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagInterface.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagInterface.java new file mode 100644 index 00000000..4362d5e2 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagInterface.java @@ -0,0 +1,63 @@ +package com.launchdarkly.android.flagstore; + +import android.support.annotation.NonNull; + +import com.google.gson.JsonElement; +import com.launchdarkly.android.EvaluationReason; + +/** + * Public interface for a Flag, to be used if exposing Flag model to public API methods. + */ +public interface FlagInterface { + + /** + * Getter for flag's key + * + * @return The flag's key + */ + @NonNull + String getKey(); + + /** + * Getter for flag's value. The value along with the variation are provided by LaunchDarkly by + * evaluating full flag rules against the specific user. + * + * @return The flag's value + */ + JsonElement getValue(); + + /** + * Getter for the flag's environment version field. This is an environment global version that + * is updated whenever any flag is updated in an environment. This field is nullable, as + * LaunchDarkly may provide only one of version and flagVersion. + * + * @return The environment version for this flag + */ + Integer getVersion(); + + /** + * Getter for the flag's version. This is a flag specific version that is updated when the + * specific flag has been updated. This field is nullable, as LaunchDarkly may provide only one + * of version and flagVersion. + * + * @return The flag's version + */ + Integer getFlagVersion(); + + /** + * Getter for flag's variation. The variation along with the value are provided by LaunchDarkly + * by evaluating full flag rules against the specific user. + * + * @return The flag's variation + */ + Integer getVariation(); + + /** + * Getter for the flag's evaluation reason. The evaluation reason is provided by the server to + * describe the underlying conditions leading to the selection of the flag's variation and value + * when evaluated against the particular user. + * + * @return The reason describing the flag's evaluation result + */ + EvaluationReason getReason(); +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStore.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStore.java new file mode 100644 index 00000000..8122b6a5 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStore.java @@ -0,0 +1,85 @@ +package com.launchdarkly.android.flagstore; + +import java.util.List; + +import javax.annotation.Nullable; + +/** + * A FlagStore supports getting individual or collections of flag updates and updating an underlying + * persistent store. Individual flags can be retrieved by a flagKey, or all flags retrieved. Allows + * replacing backing store for flags at a future date, as well as mocking for unit testing. + */ +public interface FlagStore { + + /** + * Delete the backing persistent store for this identifier entirely. Further operations on a + * FlagStore are undefined after calling this method. + */ + void delete(); + + /** + * Remove all flags from the store. + */ + void clear(); + + /** + * Returns true if a flag with the key is in the store, otherwise false. + * + * @param key The key to check for membership in the store. + * @return Whether a flag with the given key is in the store. + */ + boolean containsKey(String key); + + /** + * Get an individual flag from the store. If a flag with the key flagKey is not stored, returns + * null. + * + * @param flagKey The key to get the corresponding flag for. + * @return The flag with the key flagKey or null. + */ + @Nullable + Flag getFlag(String flagKey); + + /** + * Apply an individual flag update to the FlagStore. + * + * @param flagUpdate The FlagUpdate to apply. + */ + void applyFlagUpdate(FlagUpdate flagUpdate); + + /** + * Apply a list of flag updates to the FlagStore. + * + * @param flagUpdates The list of FlagUpdates to apply. + */ + void applyFlagUpdates(List flagUpdates); + + /** + * First removes all flags from the store, then applies a list of flag updates to the + * FlagStore. + * + * @param flagUpdates The list of FlagUpdates to apply. + */ + void clearAndApplyFlagUpdates(List flagUpdates); + + /** + * Gets a list of all flags currently in the store. + * + * @return The List of current Flags. + */ + List getAllFlags(); + + /** + * Register a listener to be called on any updates to the store. If a listener is already + * registered, it will be replaced with the argument listener. The FlagStore implementation is + * not guaranteed to retain a strong reference to the listener. + * + * @param storeUpdatedListener The listener to be called on store updates. + */ + void registerOnStoreUpdatedListener(StoreUpdatedListener storeUpdatedListener); + + /** + * Remove the currently registered listener if one exists. + */ + void unregisterOnStoreUpdatedListener(); +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreFactory.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreFactory.java new file mode 100644 index 00000000..48fb3db0 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreFactory.java @@ -0,0 +1,19 @@ +package com.launchdarkly.android.flagstore; + +import android.support.annotation.NonNull; + +/** + * This interface is used to provide a mechanism for a FlagStoreManager to create FlagStores without + * being dependent on a concrete FlagStore class. + */ +public interface FlagStoreFactory { + + /** + * Create a new flag store + * + * @param identifier identifier to associate all flags under + * @return A new instance of a FlagStore backed by a concrete implementation. + */ + FlagStore createFlagStore(@NonNull String identifier); + +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreManager.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreManager.java new file mode 100644 index 00000000..371e0a93 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreManager.java @@ -0,0 +1,52 @@ +package com.launchdarkly.android.flagstore; + +import com.launchdarkly.android.FeatureFlagChangeListener; + +import java.util.Collection; + +/** + * A FlagStoreManager is responsible for managing FlagStores for active and recently active users, + * as well as providing flagKey specific update callbacks. + */ +public interface FlagStoreManager { + + /** + * Loads the FlagStore for the particular userKey. If too many users have a locally cached + * FlagStore, deletes the oldest. + * + * @param userKey The key representing the user to switch to + */ + void switchToUser(String userKey); + + /** + * Gets the current user's flag store. + * + * @return The flag store for the current user. + */ + FlagStore getCurrentUserStore(); + + /** + * Register a listener to be called when a flag with the given key is created or updated. + * Multiple listeners can be registered to a single key. + * + * @param key Flag key to register the listener to. + * @param listener The listener to be called when the flag is updated. + */ + void registerListener(String key, FeatureFlagChangeListener listener); + + /** + * Unregister a specific listener registered to the given key. + * + * @param key Flag key to unregister the listener from. + * @param listener The specific listener to be unregistered. + */ + void unRegisterListener(String key, FeatureFlagChangeListener listener); + + /** + * Gets all the listeners currently registered to the given key. + * + * @param key The key to return the listeners for. + * @return A collection of listeners registered to the key. + */ + Collection getListenersByKey(String key); +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreUpdateType.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreUpdateType.java new file mode 100644 index 00000000..5885d06f --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagStoreUpdateType.java @@ -0,0 +1,19 @@ +package com.launchdarkly.android.flagstore; + +/** + * Types of updates that a FlagStore can report + */ +public enum FlagStoreUpdateType { + /** + * The flag was deleted + */ + FLAG_DELETED, + /** + * The flag has been updated or replaced + */ + FLAG_UPDATED, + /** + * A new flag has been created + */ + FLAG_CREATED +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagUpdate.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagUpdate.java new file mode 100644 index 00000000..9855284c --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/FlagUpdate.java @@ -0,0 +1,26 @@ +package com.launchdarkly.android.flagstore; + +/** + * Interfaces for classes that are tied to a flagKey and can take an existing flag and determine + * whether it should be updated/deleted/left the same based on its update payload. + */ +public interface FlagUpdate { + + /** + * Given an existing Flag retrieved by the flagKey returned by flagToUpdate(), updateFlag should + * return null if the flag is to be deleted, a new Flag if the flag should be replaced by the + * new Flag, or the before Flag if the flag should be left the same. + * + * @param before An existing Flag associated with flagKey from flagToUpdate() + * @return null, a new Flag, or the before Flag. + */ + Flag updateFlag(Flag before); + + /** + * Get the key of the flag that this FlagUpdate is intended to update. + * + * @return The key of the flag to be updated. + */ + String flagToUpdate(); + +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/StoreUpdatedListener.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/StoreUpdatedListener.java new file mode 100644 index 00000000..a4b7b212 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/StoreUpdatedListener.java @@ -0,0 +1,14 @@ +package com.launchdarkly.android.flagstore; + +/** + * Listener interface for receiving FlagStore update callbacks + */ +public interface StoreUpdatedListener { + /** + * Called by a FlagStore when the store is updated. + * + * @param flagKey The key of the Flag that was updated + * @param flagStoreUpdateType The type of update that occurred. + */ + void onStoreUpdate(String flagKey, FlagStoreUpdateType flagStoreUpdateType); +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStore.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStore.java new file mode 100644 index 00000000..70d3bbd5 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStore.java @@ -0,0 +1,192 @@ +package com.launchdarkly.android.flagstore.sharedprefs; + +import android.annotation.SuppressLint; +import android.app.Application; +import android.content.Context; +import android.content.SharedPreferences; +import android.support.annotation.NonNull; +import android.util.Pair; + +import com.launchdarkly.android.flagstore.Flag; +import com.launchdarkly.android.flagstore.FlagStore; +import com.launchdarkly.android.flagstore.FlagStoreUpdateType; +import com.launchdarkly.android.flagstore.FlagUpdate; +import com.launchdarkly.android.flagstore.StoreUpdatedListener; +import com.launchdarkly.android.gson.GsonCache; + +import java.io.File; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.annotation.Nullable; + +import timber.log.Timber; + +class SharedPrefsFlagStore implements FlagStore { + + private static final String SHARED_PREFS_BASE_KEY = "LaunchDarkly-"; + private final String prefsKey; + private Application application; + private SharedPreferences sharedPreferences; + private WeakReference listenerWeakReference; + + SharedPrefsFlagStore(@NonNull Application application, @NonNull String identifier) { + this.application = application; + this.prefsKey = SHARED_PREFS_BASE_KEY + identifier + "-flags"; + this.sharedPreferences = application.getSharedPreferences(prefsKey, Context.MODE_PRIVATE); + this.listenerWeakReference = new WeakReference<>(null); + } + + @SuppressLint("ApplySharedPref") + @Override + public void delete() { + sharedPreferences.edit().clear().commit(); + sharedPreferences = null; + + File file = new File(application.getFilesDir().getParent() + "/shared_prefs/" + prefsKey + ".xml"); + Timber.i("Deleting SharedPrefs file:%s", file.getAbsolutePath()); + + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + + @Override + public void clear() { + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.clear(); + editor.apply(); + } + + @Override + public boolean containsKey(String key) { + return sharedPreferences.contains(key); + } + + @Nullable + @Override + public Flag getFlag(String flagKey) { + String flagData = sharedPreferences.getString(flagKey, null); + if (flagData == null) + return null; + + return GsonCache.getGson().fromJson(flagData, Flag.class); + } + + private Pair applyFlagUpdateNoCommit(@NonNull SharedPreferences.Editor editor, @NonNull FlagUpdate flagUpdate) { + String flagKey = flagUpdate.flagToUpdate(); + if (flagKey == null) { + return null; + } + Flag flag = getFlag(flagKey); + Flag newFlag = flagUpdate.updateFlag(flag); + if (flag != null && newFlag == null) { + editor.remove(flagKey); + return new Pair<>(flagKey, FlagStoreUpdateType.FLAG_DELETED); + } else if (flag == null && newFlag != null) { + String flagData = GsonCache.getGson().toJson(newFlag); + editor.putString(flagKey, flagData); + return new Pair<>(flagKey, FlagStoreUpdateType.FLAG_CREATED); + } else if (flag != newFlag) { + String flagData = GsonCache.getGson().toJson(newFlag); + editor.putString(flagKey, flagData); + return new Pair<>(flagKey, FlagStoreUpdateType.FLAG_UPDATED); + } + return null; + } + + @Override + public void applyFlagUpdate(FlagUpdate flagUpdate) { + SharedPreferences.Editor editor = sharedPreferences.edit(); + Pair update = applyFlagUpdateNoCommit(editor, flagUpdate); + editor.apply(); + StoreUpdatedListener storeUpdatedListener = listenerWeakReference.get(); + if (update != null && storeUpdatedListener != null) { + storeUpdatedListener.onStoreUpdate(update.first, update.second); + } + } + + private void informListenersOfUpdateList(List> updates) { + StoreUpdatedListener storeUpdatedListener = listenerWeakReference.get(); + if (storeUpdatedListener != null) { + for (Pair update : updates) { + storeUpdatedListener.onStoreUpdate(update.first, update.second); + } + } + } + + @Override + public void applyFlagUpdates(List flagUpdates) { + SharedPreferences.Editor editor = sharedPreferences.edit(); + ArrayList> updates = new ArrayList<>(); + for (FlagUpdate flagUpdate : flagUpdates) { + Pair update = applyFlagUpdateNoCommit(editor, flagUpdate); + if (update != null) { + updates.add(update); + } + } + editor.apply(); + informListenersOfUpdateList(updates); + } + + @Override + public void clearAndApplyFlagUpdates(List flagUpdates) { + Set clearedKeys = sharedPreferences.getAll().keySet(); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.clear(); + ArrayList> updates = new ArrayList<>(); + for (FlagUpdate flagUpdate : flagUpdates) { + String flagKey = flagUpdate.flagToUpdate(); + if (flagKey == null) { + continue; + } + Flag newFlag = flagUpdate.updateFlag(null); + if (newFlag != null) { + String flagData = GsonCache.getGson().toJson(newFlag); + editor.putString(flagKey, flagData); + clearedKeys.remove(flagKey); + updates.add(new Pair<>(flagKey, FlagStoreUpdateType.FLAG_CREATED)); + } + } + editor.apply(); + for (String clearedKey : clearedKeys) { + updates.add(new Pair<>(clearedKey, FlagStoreUpdateType.FLAG_DELETED)); + } + informListenersOfUpdateList(updates); + } + + @Override + public List getAllFlags() { + Map flags = sharedPreferences.getAll(); + ArrayList result = new ArrayList<>(); + for (Object entry : flags.values()) { + if (entry instanceof String) { + Flag flag = null; + try { + flag = GsonCache.getGson().fromJson((String) entry, Flag.class); + } catch (Exception ignored) { + } + if (flag == null) { + Timber.e("invalid flag found in flag store"); + } else { + result.add(flag); + } + } else { + Timber.e("non-string found in flag store"); + } + } + return result; + } + + @Override + public void registerOnStoreUpdatedListener(StoreUpdatedListener storeUpdatedListener) { + listenerWeakReference = new WeakReference<>(storeUpdatedListener); + } + + @Override + public void unregisterOnStoreUpdatedListener() { + listenerWeakReference.clear(); + } +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreFactory.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreFactory.java new file mode 100644 index 00000000..1583c232 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreFactory.java @@ -0,0 +1,21 @@ +package com.launchdarkly.android.flagstore.sharedprefs; + +import android.app.Application; +import android.support.annotation.NonNull; + +import com.launchdarkly.android.flagstore.FlagStore; +import com.launchdarkly.android.flagstore.FlagStoreFactory; + +public class SharedPrefsFlagStoreFactory implements FlagStoreFactory { + + private final Application application; + + public SharedPrefsFlagStoreFactory(@NonNull Application application) { + this.application = application; + } + + @Override + public FlagStore createFlagStore(@NonNull String identifier) { + return new SharedPrefsFlagStore(application, identifier); + } +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreManager.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreManager.java new file mode 100644 index 00000000..c5c12a2e --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/flagstore/sharedprefs/SharedPrefsFlagStoreManager.java @@ -0,0 +1,165 @@ +package com.launchdarkly.android.flagstore.sharedprefs; + +import android.app.Application; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.NonNull; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import com.google.common.collect.Multimaps; +import com.launchdarkly.android.FeatureFlagChangeListener; +import com.launchdarkly.android.flagstore.FlagStore; +import com.launchdarkly.android.flagstore.FlagStoreFactory; +import com.launchdarkly.android.flagstore.FlagStoreManager; +import com.launchdarkly.android.flagstore.FlagStoreUpdateType; +import com.launchdarkly.android.flagstore.StoreUpdatedListener; + +import java.util.Collection; +import java.util.Date; +import java.util.Iterator; +import java.util.Map; +import java.util.TreeMap; + +import timber.log.Timber; + +public class SharedPrefsFlagStoreManager implements FlagStoreManager, StoreUpdatedListener { + + private static final String SHARED_PREFS_BASE_KEY = "LaunchDarkly-"; + private static final int MAX_USERS = 5; + + @NonNull + private final FlagStoreFactory flagStoreFactory; + @NonNull + private String mobileKey; + + private FlagStore currentFlagStore; + private final SharedPreferences usersSharedPrefs; + private final Multimap listeners; + + public SharedPrefsFlagStoreManager(@NonNull Application application, + @NonNull String mobileKey, + @NonNull FlagStoreFactory flagStoreFactory) { + this.mobileKey = mobileKey; + this.flagStoreFactory = flagStoreFactory; + this.usersSharedPrefs = application.getSharedPreferences(SHARED_PREFS_BASE_KEY + mobileKey + "-users", Context.MODE_PRIVATE); + HashMultimap multimap = HashMultimap.create(); + listeners = Multimaps.synchronizedMultimap(multimap); + } + + @Override + public void switchToUser(String userKey) { + if (currentFlagStore != null) { + currentFlagStore.unregisterOnStoreUpdatedListener(); + } + currentFlagStore = flagStoreFactory.createFlagStore(storeIdentifierForUser(userKey)); + currentFlagStore.registerOnStoreUpdatedListener(this); + + // Store the user's key and the current time in usersSharedPrefs so it can be removed when + // MAX_USERS is exceeded. + usersSharedPrefs.edit() + .putLong(userKey, System.currentTimeMillis()) + .apply(); + + int usersStored = usersSharedPrefs.getAll().size(); + if (usersStored > MAX_USERS) { + Iterator oldestFirstUsers = getAllUsers().iterator(); + // Remove oldest users until we are at MAX_USERS. + while (usersStored-- > MAX_USERS) { + String removed = oldestFirstUsers.next(); + Timber.d("Exceeded max # of users: [%s] Removing user: [%s]", MAX_USERS, removed); + // Load FlagStore for oldest user and delete it. + flagStoreFactory.createFlagStore(storeIdentifierForUser(removed)).delete(); + // Remove entry from usersSharedPrefs. + usersSharedPrefs.edit().remove(removed).apply(); + } + } + } + + private String storeIdentifierForUser(String userKey) { + return mobileKey + userKey; + } + + @Override + public FlagStore getCurrentUserStore() { + return currentFlagStore; + } + + @Override + public void registerListener(String key, FeatureFlagChangeListener listener) { + synchronized (listeners) { + listeners.put(key, listener); + Timber.d("Added listener. Total count: [%s]", listeners.size()); + } + } + + @Override + public void unRegisterListener(String key, FeatureFlagChangeListener listener) { + synchronized (listeners) { + Iterator it = listeners.get(key).iterator(); + while (it.hasNext()) { + FeatureFlagChangeListener check = it.next(); + if (check.equals(listener)) { + Timber.d("Removing listener for key: [%s]", key); + it.remove(); + } + } + } + } + + // Gets all users sorted by creation time (oldest first) + private Collection getAllUsers() { + Map all = usersSharedPrefs.getAll(); + TreeMap sortedMap = new TreeMap<>(); + //get typed versions of the users' timestamps and insert into sorted TreeMap + for (String k : all.keySet()) { + try { + sortedMap.put((Long) all.get(k), k); + Timber.d("Found user: %s", userAndTimeStampToHumanReadableString(k, (Long) all.get(k))); + } catch (ClassCastException cce) { + Timber.e(cce, "Unexpected type! This is not good"); + } + } + return sortedMap.values(); + } + + private static String userAndTimeStampToHumanReadableString(String userSharedPrefsKey, Long timestamp) { + return userSharedPrefsKey + " [" + userSharedPrefsKey + "] timestamp: [" + timestamp + "]" + " [" + new Date(timestamp) + "]"; + } + + @Override + public void onStoreUpdate(final String flagKey, final FlagStoreUpdateType flagStoreUpdateType) { + // We make sure to call listener callbacks on the main thread, as we consistently did so in + // the past by virtue of using SharedPreferences to implement the callbacks. + if (Looper.myLooper() == Looper.getMainLooper()) { + // Make sure listeners are not updated while we are calling them. + synchronized (listeners) { + // We only call the listener if the flag is a new flag or updated. + if (flagStoreUpdateType != FlagStoreUpdateType.FLAG_DELETED) { + for (FeatureFlagChangeListener listener : listeners.get(flagKey)) { + listener.onFeatureFlagChange(flagKey); + } + } else { + // When flag is deleted we remove the corresponding listeners + listeners.removeAll(flagKey); + } + } + } else { + // Call ourselves on the main thread + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + onStoreUpdate(flagKey, flagStoreUpdateType); + } + }); + } + } + + public Collection getListenersByKey(String key) { + synchronized (listeners) { + return listeners.get(key); + } + } +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/EvaluationReasonSerialization.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/EvaluationReasonSerialization.java new file mode 100644 index 00000000..30818361 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/EvaluationReasonSerialization.java @@ -0,0 +1,81 @@ +package com.launchdarkly.android.gson; + +import android.support.annotation.Nullable; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.launchdarkly.android.EvaluationReason; + +import java.lang.reflect.Type; + +class EvaluationReasonSerialization implements JsonSerializer, JsonDeserializer { + + @Nullable + private static > T parseEnum(Class c, String name, T fallback) { + try { + return Enum.valueOf(c, name); + } catch (IllegalArgumentException e) { + return fallback; + } + } + + @Override + public JsonElement serialize(EvaluationReason src, Type typeOfSrc, JsonSerializationContext context) { + return context.serialize(src, src.getClass()); + } + + @Override + public EvaluationReason deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + JsonObject o = json.getAsJsonObject(); + if (o == null) { + return null; + } + JsonElement kindElement = o.get("kind"); + if (kindElement != null && kindElement.isJsonPrimitive() && kindElement.getAsJsonPrimitive().isString()) { + EvaluationReason.Kind kind = parseEnum(EvaluationReason.Kind.class, kindElement.getAsString(), EvaluationReason.Kind.UNKNOWN); + if (kind == null) { + return null; + } + switch (kind) { + case OFF: + return EvaluationReason.off(); + case FALLTHROUGH: + return EvaluationReason.fallthrough(); + case TARGET_MATCH: + return EvaluationReason.targetMatch(); + case RULE_MATCH: + JsonElement indexElement = o.get("ruleIndex"); + JsonElement idElement = o.get("ruleId"); + if (indexElement != null && indexElement.isJsonPrimitive() && indexElement.getAsJsonPrimitive().isNumber() && + idElement != null && idElement.isJsonPrimitive() && idElement.getAsJsonPrimitive().isString()) { + return EvaluationReason.ruleMatch(indexElement.getAsInt(), + idElement.getAsString()); + } + return null; + case PREREQUISITE_FAILED: + JsonElement prereqElement = o.get("prerequisiteKey"); + if (prereqElement != null && prereqElement.isJsonPrimitive() && prereqElement.getAsJsonPrimitive().isString()) { + return EvaluationReason.prerequisiteFailed(prereqElement.getAsString()); + } + break; + case ERROR: + JsonElement errorKindElement = o.get("errorKind"); + if (errorKindElement != null && errorKindElement.isJsonPrimitive() && errorKindElement.getAsJsonPrimitive().isString()) { + EvaluationReason.ErrorKind errorKind = parseEnum(EvaluationReason.ErrorKind.class, errorKindElement.getAsString(), EvaluationReason.ErrorKind.UNKNOWN); + return EvaluationReason.error(errorKind); + } + return null; + case UNKNOWN: + return EvaluationReason.unknown(); + } + } + return null; + } + + +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/FlagsResponseSerialization.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/FlagsResponseSerialization.java new file mode 100644 index 00000000..2ebd2578 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/FlagsResponseSerialization.java @@ -0,0 +1,38 @@ +package com.launchdarkly.android.gson; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.launchdarkly.android.flagstore.Flag; +import com.launchdarkly.android.response.FlagsResponse; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Map; + +class FlagsResponseSerialization implements JsonDeserializer { + @Override + public FlagsResponse deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + JsonObject o = json.getAsJsonObject(); + if (o == null) { + return null; + } + ArrayList flags = new ArrayList<>(); + for (Map.Entry flagJson : o.entrySet()) { + String flagKey = flagJson.getKey(); + JsonElement flagBody = flagJson.getValue(); + JsonObject flagBodyObject = flagBody.getAsJsonObject(); + if (flagBodyObject != null) { + flagBodyObject.addProperty("key", flagKey); + } + Flag flag = context.deserialize(flagBodyObject, Flag.class); + if (flag != null) { + flags.add(flag); + } + } + + return new FlagsResponse(flags); + } +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/GsonCache.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/GsonCache.java new file mode 100644 index 00000000..00353a1b --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/gson/GsonCache.java @@ -0,0 +1,22 @@ +package com.launchdarkly.android.gson; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.launchdarkly.android.EvaluationReason; +import com.launchdarkly.android.response.FlagsResponse; + +public class GsonCache { + + private static final Gson gson = createGson(); + + public static Gson getGson() { + return gson; + } + + private static Gson createGson() { + GsonBuilder gsonBuilder = new GsonBuilder(); + gsonBuilder.registerTypeAdapter(EvaluationReason.class, new EvaluationReasonSerialization()); + gsonBuilder.registerTypeAdapter(FlagsResponse.class, new FlagsResponseSerialization()); + return gsonBuilder.create(); + } +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/BaseUserSharedPreferences.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/BaseUserSharedPreferences.java deleted file mode 100644 index 4d06cc96..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/BaseUserSharedPreferences.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.launchdarkly.android.response; - -import android.annotation.SuppressLint; -import android.content.SharedPreferences; -import android.support.annotation.Nullable; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -/** - * Created by jamesthacker on 4/12/18. - */ - -abstract class BaseUserSharedPreferences { - - SharedPreferences sharedPreferences; - - public void clear() { - SharedPreferences.Editor editor = sharedPreferences.edit(); - editor.clear(); - editor.apply(); - } - - @Nullable - JsonElement extractValueFromPreferences(String flagResponseKey, String keyOfValueToExtract) { - - JsonObject asJsonObject = getValueAsJsonObject(flagResponseKey); - if (asJsonObject == null) { - return null; - } - - return asJsonObject.get(keyOfValueToExtract); - } - - @SuppressLint("ApplySharedPref") - @Nullable - JsonObject getValueAsJsonObject(String flagResponseKey) { - String storedFlag; - try { - storedFlag = sharedPreferences.getString(flagResponseKey, null); - } catch (ClassCastException castException) { - // An old version of shared preferences is stored, so clear it. - // The flag responses will get re-synced with the server - sharedPreferences.edit().clear().commit(); - return null; - } - - if (storedFlag == null) { - return null; - } - - JsonElement element = new JsonParser().parse(storedFlag); - if (element instanceof JsonObject) { - return (JsonObject) element; - } - - return null; - } -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/DeleteFlagResponse.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/DeleteFlagResponse.java new file mode 100644 index 00000000..6125bdc4 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/DeleteFlagResponse.java @@ -0,0 +1,35 @@ +package com.launchdarkly.android.response; + +import com.launchdarkly.android.flagstore.Flag; +import com.launchdarkly.android.flagstore.FlagUpdate; + +public class DeleteFlagResponse implements FlagUpdate { + + private String key; + private Integer version; + + public DeleteFlagResponse(String key, Integer version) { + this.key = key; + this.version = version; + } + + /** + * Returns null to signal deletion of the flag if this update is valid on the supplied flag, + * otherwise returns the existing flag. + * + * @param before An existing Flag associated with flagKey from flagToUpdate() + * @return null, or the before flag. + */ + @Override + public Flag updateFlag(Flag before) { + if (before == null || version == null || before.isVersionMissing() || version > before.getVersion()) { + return null; + } + return before; + } + + @Override + public String flagToUpdate() { + return key; + } +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponse.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponse.java deleted file mode 100644 index a7f69d7f..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponse.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.launchdarkly.android.response; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -/** - * Farhan - * 2018-01-30 - */ -public interface FlagResponse { - - String getKey(); - - JsonElement getValue(); - - int getVersion(); - - int getFlagVersion(); - - Integer getVariation(); - - Boolean getTrackEvents(); - - Long getDebugEventsUntilDate(); - - JsonObject getAsJsonObject(); - - boolean isVersionMissing(); -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponseSharedPreferences.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponseSharedPreferences.java deleted file mode 100644 index 18838643..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponseSharedPreferences.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.launchdarkly.android.response; - -import java.util.List; - -/** - * Farhan - * 2018-01-30 - */ -public interface FlagResponseSharedPreferences { - - void clear(); - - boolean isVersionValid(FlagResponse flagResponse); - - void saveAll(List flagResponseList); - - void deleteStoredFlagResponse(FlagResponse flagResponse); - - void updateStoredFlagResponse(FlagResponse flagResponse); - - int getStoredVersion(String flagResponseKey); - - int getStoredFlagVersion(String flagResponseKey); - - Long getStoredDebugEventsUntilDate(String flagResponseKey); - - boolean getStoredTrackEvents(String flagResponseKey); - - int getStoredVariation(String flagResponseKey); - - boolean containsKey(String key); - - int getVersionForEvents(String flagResponseKey); -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponseStore.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponseStore.java deleted file mode 100644 index c415d1c2..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagResponseStore.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.launchdarkly.android.response; - -import android.support.annotation.Nullable; - -/** - * Farhan - * 2018-01-30 - */ -public interface FlagResponseStore { - - @Nullable - T getFlagResponse(); -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagsResponse.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagsResponse.java new file mode 100644 index 00000000..d49528b0 --- /dev/null +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/FlagsResponse.java @@ -0,0 +1,31 @@ +package com.launchdarkly.android.response; + +import android.support.annotation.NonNull; + +import com.launchdarkly.android.flagstore.Flag; + +import java.util.List; + +/** + * Used for cases where the server sends a collection of flags as a key-value object. Uses custom + * deserializer in {@link com.launchdarkly.android.gson.FlagsResponseSerialization} to get a list of + * {@link com.launchdarkly.android.flagstore.Flag} objects. + */ +public class FlagsResponse { + @NonNull + private List flags; + + public FlagsResponse(@NonNull List flags) { + this.flags = flags; + } + + /** + * Get a list of the {@link Flag}s in this response + * + * @return A list of the {@link Flag}s in this response + */ + @NonNull + public List getFlags() { + return flags; + } +} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/SummaryEventSharedPreferences.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/SummaryEventSharedPreferences.java deleted file mode 100644 index 3d1e69c5..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/SummaryEventSharedPreferences.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.launchdarkly.android.response; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -/** - * Created by jamesthacker on 4/12/18. - */ - -public interface SummaryEventSharedPreferences { - - void clear(); - - void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElement defaultVal, int version, int variation, boolean unknown); - - JsonObject getFeaturesJsonObject(); -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponse.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponse.java deleted file mode 100644 index ece3a93d..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponse.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.launchdarkly.android.response; - -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; - -import com.google.gson.JsonElement; -import com.google.gson.JsonNull; -import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; - -/** - * Farhan - * 2018-01-30 - */ -public class UserFlagResponse implements FlagResponse { - - @NonNull - private final String key; - @Nullable - private final JsonElement value; - - private final int version; - - private final int flagVersion; - - @Nullable - private final Integer variation; - - @Nullable - private final Boolean trackEvents; - - @Nullable - private final Long debugEventsUntilDate; - - public UserFlagResponse(@NonNull String key, @Nullable JsonElement value, int version, int flagVersion, @Nullable Integer variation, @Nullable Boolean trackEvents, @Nullable Long debugEventsUntilDate) { - this.key = key; - this.value = value; - this.version = version; - this.flagVersion = flagVersion; - this.variation = variation; - this.trackEvents = trackEvents; - this.debugEventsUntilDate = debugEventsUntilDate; - } - - public UserFlagResponse(String key, JsonElement value) { - this(key, value, -1, -1, null, null, null); - } - - public UserFlagResponse(String key, JsonElement value, int version, int flagVersion) { - this(key, value, version, flagVersion, null, null, null); - } - - @NonNull - @Override - public String getKey() { - return key; - } - - @Nullable - @Override - public JsonElement getValue() { - return value; - } - - @Override - public int getVersion() { - return version; - } - - @Override - public int getFlagVersion() { - return flagVersion; - } - - @Nullable - @Override - public Integer getVariation() { - return variation; - } - - @Nullable - @Override - public Boolean getTrackEvents() { - return trackEvents; - } - - @Nullable - @Override - public Long getDebugEventsUntilDate() { - return debugEventsUntilDate; - } - - @Override - public JsonObject getAsJsonObject() { - JsonObject object = new JsonObject(); - object.add("version", new JsonPrimitive(version)); - object.add("flagVersion", new JsonPrimitive(flagVersion)); - object.add("variation", variation == null ? JsonNull.INSTANCE : new JsonPrimitive(variation)); - object.add("trackEvents", trackEvents == null ? JsonNull.INSTANCE : new JsonPrimitive(trackEvents)); - object.add("debugEventsUntilDate", debugEventsUntilDate == null ? JsonNull.INSTANCE : new JsonPrimitive(debugEventsUntilDate)); - return object; - } - - @Override - public boolean isVersionMissing() { - return version == -1; - } -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferences.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferences.java deleted file mode 100644 index 1b0aa9c1..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponseSharedPreferences.java +++ /dev/null @@ -1,158 +0,0 @@ -package com.launchdarkly.android.response; - -import android.app.Application; -import android.content.Context; -import android.content.SharedPreferences; -import android.support.annotation.Nullable; -import android.support.annotation.VisibleForTesting; - -import com.google.gson.JsonElement; -import com.google.gson.JsonNull; - -import java.util.List; - -import timber.log.Timber; - -/** - * Farhan - * 2018-01-30 - */ -public class UserFlagResponseSharedPreferences extends BaseUserSharedPreferences implements FlagResponseSharedPreferences { - - public UserFlagResponseSharedPreferences(Application application, String name) { - this.sharedPreferences = application.getSharedPreferences(name, Context.MODE_PRIVATE); - } - - @Override - public boolean isVersionValid(FlagResponse flagResponse) { - if (flagResponse != null && sharedPreferences.contains(flagResponse.getKey())) { - float storedVersion = getStoredVersion(flagResponse.getKey()); - return storedVersion < flagResponse.getVersion(); - } - return true; - } - - @Override - public void saveAll(List flagResponseList) { - SharedPreferences.Editor editor = sharedPreferences.edit(); - - for (FlagResponse flagResponse : flagResponseList) { - editor.putString(flagResponse.getKey(), flagResponse.getAsJsonObject().toString()); - } - editor.apply(); - } - - @Override - public void deleteStoredFlagResponse(FlagResponse flagResponse) { - SharedPreferences.Editor editor = sharedPreferences.edit(); - editor.remove(flagResponse.getKey()); - editor.apply(); - } - - @Override - public void updateStoredFlagResponse(FlagResponse flagResponse) { - SharedPreferences.Editor editor = sharedPreferences.edit(); - editor.putString(flagResponse.getKey(), flagResponse.getAsJsonObject().toString()); - editor.apply(); - } - - @Override - public int getStoredVersion(String flagResponseKey) { - JsonElement extracted = extractValueFromPreferences(flagResponseKey, "version"); - if (extracted == null || extracted instanceof JsonNull) { - return -1; - } - - try { - return extracted.getAsInt(); - } catch (ClassCastException cce) { - Timber.e(cce, "Failed to get stored version"); - } - - return -1; - } - - @Override - public int getStoredFlagVersion(String flagResponseKey) { - JsonElement extracted = extractValueFromPreferences(flagResponseKey, "flagVersion"); - if (extracted == null || extracted instanceof JsonNull) { - return -1; - } - - try { - return extracted.getAsInt(); - } catch (ClassCastException cce) { - Timber.e(cce, "Failed to get stored flag version"); - } - - return -1; - } - - @Override - @Nullable - public Long getStoredDebugEventsUntilDate(String flagResponseKey) { - JsonElement extracted = extractValueFromPreferences(flagResponseKey, "debugEventsUntilDate"); - if (extracted == null || extracted instanceof JsonNull) { - return null; - } - - try { - return extracted.getAsLong(); - } catch (ClassCastException cce) { - Timber.e(cce, "Failed to get stored debug events until date"); - } - - return null; - } - - @Override - @Nullable - public boolean getStoredTrackEvents(String flagResponseKey) { - JsonElement extracted = extractValueFromPreferences(flagResponseKey, "trackEvents"); - if (extracted == null || extracted instanceof JsonNull) { - return false; - } - - try { - return extracted.getAsBoolean(); - } catch (ClassCastException cce) { - Timber.e(cce, "Failed to get stored trackEvents"); - } - - return false; - } - - @Override - public int getStoredVariation(String flagResponseKey) { - JsonElement extracted = extractValueFromPreferences(flagResponseKey, "variation"); - if (extracted == null || extracted instanceof JsonNull) { - return -1; - } - - try { - return extracted.getAsInt(); - } catch (ClassCastException cce) { - Timber.e(cce, "Failed to get stored variation"); - } - - return -1; - } - - @Override - public boolean containsKey(String key) { - return sharedPreferences.contains(key); - } - - @VisibleForTesting - int getLength() { - return sharedPreferences.getAll().size(); - } - - @Override - public int getVersionForEvents(String flagResponseKey) { - int storedFlagVersion = getStoredFlagVersion(flagResponseKey); - int storedVersion = getStoredVersion(flagResponseKey); - return storedFlagVersion == -1 ? storedVersion : storedFlagVersion; - } - -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponseStore.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponseStore.java deleted file mode 100644 index 18392932..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserFlagResponseStore.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.launchdarkly.android.response; - -import android.support.annotation.NonNull; - -import com.google.common.base.Function; -import com.google.gson.JsonObject; -import com.launchdarkly.android.response.interpreter.FlagResponseInterpreter; - -/** - * Farhan - * 2018-01-30 - */ -@SuppressWarnings("Guava") -public class UserFlagResponseStore implements FlagResponseStore { - - @NonNull - private final JsonObject jsonObject; - @NonNull - private final Function function; - - public UserFlagResponseStore(@NonNull JsonObject jsonObject, @NonNull FlagResponseInterpreter function) { - this.jsonObject = jsonObject; - this.function = function; - } - - @Override - public T getFlagResponse() { - return function.apply(jsonObject); - } -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserSummaryEventSharedPreferences.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserSummaryEventSharedPreferences.java deleted file mode 100644 index 55b28124..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/UserSummaryEventSharedPreferences.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.launchdarkly.android.response; - -import android.app.Application; -import android.content.Context; -import android.content.SharedPreferences; -import android.support.annotation.Nullable; - -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; - -/** - * Created by jamesthacker on 4/12/18. - */ - -public class UserSummaryEventSharedPreferences extends BaseUserSharedPreferences implements SummaryEventSharedPreferences { - - public UserSummaryEventSharedPreferences(Application application, String name) { - this.sharedPreferences = application.getSharedPreferences(name, Context.MODE_PRIVATE); - } - - @Override - public void addOrUpdateEvent(String flagResponseKey, JsonElement value, JsonElement defaultVal, int version, int variation, boolean isUnknown) { - JsonObject object = getValueAsJsonObject(flagResponseKey); - if (object == null) { - object = createNewEvent(value, defaultVal, version, variation, isUnknown); - } else { - JsonArray countersArray = object.get("counters").getAsJsonArray(); - - boolean variationExists = false; - for (JsonElement element : countersArray) { - if (element instanceof JsonObject) { - JsonObject asJsonObject = element.getAsJsonObject(); - JsonElement variationElement = asJsonObject.get("variation"); - JsonElement versionElement = asJsonObject.get("version"); - // We can compare variation rather than value. - boolean isSameVersion = versionElement != null && asJsonObject.get("version").getAsFloat() == version; - boolean isSameVariation = variationElement != null && variationElement.getAsInt() == variation; - if ((isSameVersion && isSameVariation) || (variationElement == null && versionElement == null && isUnknown)) { - variationExists = true; - int currentCount = asJsonObject.get("count").getAsInt(); - asJsonObject.add("count", new JsonPrimitive(++currentCount)); - break; - } - } - } - - if (!variationExists) { - addNewCountersElement(countersArray, value, version, variation, isUnknown); - } - } - - if (sharedPreferences.getAll().isEmpty()) { - object.add("startDate", new JsonPrimitive(System.currentTimeMillis())); - } - - SharedPreferences.Editor editor = sharedPreferences.edit(); - editor.putString(flagResponseKey, object.toString()); - editor.apply(); - } - - private JsonObject createNewEvent(JsonElement value, JsonElement defaultVal, int version, int variation, boolean isUnknown) { - JsonObject object = new JsonObject(); - object.add("default", defaultVal); - JsonArray countersArray = new JsonArray(); - addNewCountersElement(countersArray, value, version, variation, isUnknown); - object.add("counters", countersArray); - return object; - } - - private void addNewCountersElement(JsonArray countersArray, @Nullable JsonElement value, int version, int variation, boolean isUnknown) { - JsonObject newCounter = new JsonObject(); - if (isUnknown) { - newCounter.add("unknown", new JsonPrimitive(true)); - } else { - newCounter.add("value", value); - newCounter.add("version", new JsonPrimitive(version)); - newCounter.add("variation", new JsonPrimitive(variation)); - } - newCounter.add("count", new JsonPrimitive(1)); - countersArray.add(newCounter); - } - - @Override - public JsonObject getFeaturesJsonObject() { - JsonObject returnObject = new JsonObject(); - for (String key : sharedPreferences.getAll().keySet()) { - JsonObject keyObject = getValueAsJsonObject(key); - if (keyObject != null) { - JsonArray countersArray = keyObject.get("counters").getAsJsonArray(); - for (JsonElement element : countersArray) { - JsonObject elementAsJsonObject = element.getAsJsonObject(); - // Include variation if we have it, otherwise exclude it - if (elementAsJsonObject.has("variation") && elementAsJsonObject.get("variation").getAsInt() == -1) { - elementAsJsonObject.remove("variation"); - } - } - returnObject.add(key, keyObject); - } - } - return returnObject; - } -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/BaseFlagResponseInterpreter.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/BaseFlagResponseInterpreter.java deleted file mode 100644 index a84fbbee..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/BaseFlagResponseInterpreter.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.launchdarkly.android.response.interpreter; - -import android.support.annotation.Nullable; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -/** - * Created by jamesthacker on 4/10/18. - */ - -public abstract class BaseFlagResponseInterpreter implements FlagResponseInterpreter { - - public boolean isValueInsideObject(JsonElement element) { - return !element.isJsonNull() && element.isJsonObject() && element.getAsJsonObject().has("value"); - } - - @Nullable - public Long getDebugEventsUntilDate(JsonObject object) { - if (object == null || object.get("debugEventsUntilDate") == null || object.get("debugEventsUntilDate").isJsonNull()) { - return null; - } - return object.get("debugEventsUntilDate").getAsLong(); - } - - @Nullable - public Boolean getTrackEvents(JsonObject object) { - if (object == null || object.get("trackEvents") == null || object.get("trackEvents").isJsonNull()) { - return null; - } - return object.get("trackEvents").getAsBoolean(); - } - - @Nullable - public Integer getVariation(JsonObject object) { - if (object == null || object.get("variation") == null || object.get("variation").isJsonNull()) { - return null; - } - return object.get("variation").getAsInt(); - } -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/DeleteFlagResponseInterpreter.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/DeleteFlagResponseInterpreter.java deleted file mode 100644 index 16dcfe80..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/DeleteFlagResponseInterpreter.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.launchdarkly.android.response.interpreter; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.launchdarkly.android.response.FlagResponse; -import com.launchdarkly.android.response.UserFlagResponse; - -import javax.annotation.Nullable; - -/** - * Farhan - * 2018-01-30 - */ -public class DeleteFlagResponseInterpreter extends BaseFlagResponseInterpreter { - - @Nullable - @Override - public FlagResponse apply(@Nullable JsonObject input) { - if (input != null) { - JsonElement keyElement = input.get("key"); - JsonElement valueElement = input.get("value"); - JsonElement versionElement = input.get("version"); - JsonElement flagVersionElement = input.get("flagVersion"); - Boolean trackEvents = getTrackEvents(input); - Long debugEventsUntilDate = getDebugEventsUntilDate(input); - int version = versionElement != null && versionElement.getAsJsonPrimitive().isNumber() - ? versionElement.getAsInt() - : -1; - Integer variation = getVariation(input); - int flagVersion = flagVersionElement != null && flagVersionElement.getAsJsonPrimitive().isNumber() - ? flagVersionElement.getAsInt() - : -1; - - if (keyElement != null) { - String key = keyElement.getAsJsonPrimitive().getAsString(); - return new UserFlagResponse(key, valueElement, version, flagVersion, variation, trackEvents, debugEventsUntilDate); - } - } - return null; - } -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/FlagResponseInterpreter.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/FlagResponseInterpreter.java deleted file mode 100644 index 73de67ab..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/FlagResponseInterpreter.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.launchdarkly.android.response.interpreter; - -import com.google.gson.JsonObject; - -/** - * Farhan - * 2018-01-30 - */ -public interface FlagResponseInterpreter extends ResponseInterpreter { -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PatchFlagResponseInterpreter.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PatchFlagResponseInterpreter.java deleted file mode 100644 index f7a3287c..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PatchFlagResponseInterpreter.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.launchdarkly.android.response.interpreter; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.launchdarkly.android.response.FlagResponse; -import com.launchdarkly.android.response.UserFlagResponse; - -import javax.annotation.Nullable; - -/** - * Farhan - * 2018-01-30 - */ -public class PatchFlagResponseInterpreter extends BaseFlagResponseInterpreter { - - @Nullable - @Override - public FlagResponse apply(@Nullable JsonObject input) { - if (input != null) { - JsonElement keyElement = input.get("key"); - JsonElement valueElement = input.get("value"); - JsonElement versionElement = input.get("version"); - JsonElement flagVersionElement = input.get("flagVersion"); - Boolean trackEvents = getTrackEvents(input); - Long debugEventsUntilDate = getDebugEventsUntilDate(input); - int version = versionElement != null && versionElement.getAsJsonPrimitive().isNumber() - ? versionElement.getAsInt() - : -1; - Integer variation = getVariation(input); - int flagVersion = flagVersionElement != null && flagVersionElement.getAsJsonPrimitive().isNumber() - ? flagVersionElement.getAsInt() - : -1; - - if (keyElement != null) { - String key = keyElement.getAsJsonPrimitive().getAsString(); - return new UserFlagResponse(key, valueElement, version, flagVersion, variation, trackEvents, debugEventsUntilDate); - } - } - return null; - } -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PingFlagResponseInterpreter.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PingFlagResponseInterpreter.java deleted file mode 100644 index 25506246..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PingFlagResponseInterpreter.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.launchdarkly.android.response.interpreter; - -import android.support.annotation.NonNull; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.launchdarkly.android.response.FlagResponse; -import com.launchdarkly.android.response.UserFlagResponse; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import javax.annotation.Nullable; - -/** - * Farhan - * 2018-01-30 - */ -public class PingFlagResponseInterpreter extends BaseFlagResponseInterpreter> { - - @NonNull - @Override - public List apply(@Nullable JsonObject input) { - List flagResponseList = new ArrayList<>(); - if (input != null) { - for (Map.Entry entry : input.entrySet()) { - String key = entry.getKey(); - JsonElement v = entry.getValue(); - - if (isValueInsideObject(v)) { - JsonObject asJsonObject = v.getAsJsonObject(); - - Integer variation = getVariation(asJsonObject); - Boolean trackEvents = getTrackEvents(asJsonObject); - Long debugEventsUntilDate = getDebugEventsUntilDate(asJsonObject); - - - JsonElement flagVersionElement = asJsonObject.get("flagVersion"); - JsonElement versionElement = asJsonObject.get("version"); - int flagVersion = flagVersionElement != null && flagVersionElement.getAsJsonPrimitive().isNumber() - ? flagVersionElement.getAsInt() - : -1; - int version = versionElement != null && versionElement.getAsJsonPrimitive().isNumber() - ? versionElement.getAsInt() - : -1; - flagResponseList.add(new UserFlagResponse(key, asJsonObject.get("value"), version, flagVersion, variation, trackEvents, debugEventsUntilDate)); - } else { - flagResponseList.add(new UserFlagResponse(key, v)); - } - } - } - return flagResponseList; - } -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PutFlagResponseInterpreter.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PutFlagResponseInterpreter.java deleted file mode 100644 index acca8fac..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/PutFlagResponseInterpreter.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.launchdarkly.android.response.interpreter; - -import android.support.annotation.NonNull; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.launchdarkly.android.response.FlagResponse; -import com.launchdarkly.android.response.UserFlagResponse; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import javax.annotation.Nullable; - -/** - * Farhan - * 2018-01-30 - */ -public class PutFlagResponseInterpreter extends BaseFlagResponseInterpreter> { - - @NonNull - @Override - public List apply(@Nullable JsonObject input) { - List flagResponseList = new ArrayList<>(); - if (input != null) { - for (Map.Entry entry : input.entrySet()) { - JsonElement v = entry.getValue(); - String key = entry.getKey(); - JsonObject asJsonObject = v.getAsJsonObject(); - - if (asJsonObject != null) { - JsonElement versionElement = asJsonObject.get("version"); - JsonElement valueElement = asJsonObject.get("value"); - JsonElement flagVersionElement = asJsonObject.get("flagVersion"); - Boolean trackEvents = getTrackEvents(asJsonObject); - Long debugEventsUntilDate = getDebugEventsUntilDate(asJsonObject); - int version = versionElement != null && versionElement.getAsJsonPrimitive().isNumber() - ? versionElement.getAsInt() - : -1; - Integer variation = getVariation(asJsonObject); - int flagVersion = flagVersionElement != null && flagVersionElement.getAsJsonPrimitive().isNumber() - ? flagVersionElement.getAsInt() - : -1; - - flagResponseList.add(new UserFlagResponse(key, valueElement, version, flagVersion, variation, trackEvents, debugEventsUntilDate)); - } - } - } - return flagResponseList; - } -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/ResponseInterpreter.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/ResponseInterpreter.java deleted file mode 100644 index dc16eee8..00000000 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/response/interpreter/ResponseInterpreter.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.launchdarkly.android.response.interpreter; - -import com.google.common.base.Function; - -/** - * Farhan - * 2018-01-30 - */ -interface ResponseInterpreter extends Function { -} diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/ModernTLSSocketFactory.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/ModernTLSSocketFactory.java index 52e340fa..58408796 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/ModernTLSSocketFactory.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/ModernTLSSocketFactory.java @@ -73,8 +73,8 @@ public Socket createSocket(InetAddress inetAddress, int i, InetAddress inetAddre * @param s the socket * @return */ - static Socket setModernTlsVersionsOnSocket(Socket s) { - if (s != null && (s instanceof SSLSocket)) { + private static Socket setModernTlsVersionsOnSocket(Socket s) { + if (s instanceof SSLSocket) { List defaultEnabledProtocols = Arrays.asList(((SSLSocket) s).getSupportedProtocols()); ArrayList newEnabledProtocols = new ArrayList<>(); if (defaultEnabledProtocols.contains(TLS_1_2)) { diff --git a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/TLSUtils.java b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/TLSUtils.java index d98dc906..006bcce5 100644 --- a/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/TLSUtils.java +++ b/launchdarkly-android-client/src/main/java/com/launchdarkly/android/tls/TLSUtils.java @@ -1,13 +1,23 @@ package com.launchdarkly.android.tls; +import android.app.Application; + +import com.google.android.gms.common.GooglePlayServicesNotAvailableException; +import com.google.android.gms.common.GooglePlayServicesRepairableException; +import com.google.android.gms.security.ProviderInstaller; + import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.security.NoSuchAlgorithmException; import java.util.Arrays; +import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; +import timber.log.Timber; + public class TLSUtils { public static X509TrustManager defaultTrustManager() throws GeneralSecurityException { @@ -22,4 +32,19 @@ public static X509TrustManager defaultTrustManager() throws GeneralSecurityExcep return (X509TrustManager) trustManagers[0]; } + public static void patchTLSIfNeeded(Application application) { + try { + SSLContext.getInstance("TLSv1.2"); + } catch (NoSuchAlgorithmException e) { + Timber.w("No TLSv1.2 implementation available, attempting patch."); + try { + ProviderInstaller.installIfNeeded(application.getApplicationContext()); + } catch (GooglePlayServicesRepairableException e1) { + Timber.w("Patch failed, Google Play Services too old."); + } catch (GooglePlayServicesNotAvailableException e1) { + Timber.w("Patch failed, no Google Play Services available."); + } + } + } + }