diff --git a/.circleci/config.yml b/.circleci/config.yml index 3c67b493..3b3ce6bb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -15,6 +15,11 @@ workflows: # - 21 - TODO: EasyMock makes our tests fail - 25 # - 29 - TODO: offline tests fail +# Contract tests are temporarily disabled for API 31 and API 33 due to a degree of +# test instability that impedes development. Currently there are two common failure +# modes: SDK initialization times out, or SDK initialization completes immediately +# without acquiring any data because the emulator reports no network availability. +# These do not appear correlated to any actual SDK logic flaw. - contract-tests: matrix: parameters: @@ -22,19 +27,19 @@ workflows: - 21 - 25 - 30 - - 31 - # "default" images are faster than "google_apis" images, but are otherwise equivalent for our purposes. - # however, there are no "default" images for Android 32+, so as a workaround we have a separate matrix - # for Android 32+ - - contract-tests: - matrix: - parameters: - api-level: - - 33 - system-image-type: - - google_apis - resource-class: - - xlarge +# - 31 + # # "default" images are faster than "google_apis" images, but are otherwise equivalent for our purposes. + # # however, there are no "default" images for Android 32+, so as a workaround we have a separate matrix + # # for Android 32+ + # - contract-tests: + # matrix: + # parameters: + # api-level: + # - 33 + # system-image-type: + # - google_apis + # resource-class: + # - xlarge commands: check-emulator-available: diff --git a/build.gradle b/build.gradle index 60b702ad..36100328 100644 --- a/build.gradle +++ b/build.gradle @@ -64,9 +64,4 @@ nexusPublishing { repositories { sonatype() } - - transitionCheckOptions { - maxRetries.set(20) - delayBetween.set(Duration.ofMillis(3000)) - } -} \ No newline at end of file +} diff --git a/contract-tests/src/main/java/com/launchdarkly/sdk/android/LDClientControl.java b/contract-tests/src/main/java/com/launchdarkly/sdk/android/LDClientControl.java deleted file mode 100644 index 6dcc03d9..00000000 --- a/contract-tests/src/main/java/com/launchdarkly/sdk/android/LDClientControl.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.launchdarkly.sdk.android; - -import android.app.Application; - -/** - * A class that is only here to allow the contract test service to instantiate multiple - * LDClients. Contains one static method `resetInstances()` that resets the static global - * state in LDClient. - */ -public class LDClientControl { - - /** - * Resets the global state that prevents creating more than one LDClient. - * - * This is a workaround that allows testing the Android SDK from a long-lived - * test service. - */ - public static void resetInstances() { - LDClient.instances = null; - } -} diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java index ec091443..5fcf0d59 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java @@ -6,7 +6,6 @@ import com.launchdarkly.sdk.android.LaunchDarklyException; import com.launchdarkly.sdk.android.LDClient; import com.launchdarkly.sdk.android.LDConfig; -import com.launchdarkly.sdk.android.LDClientControl; import com.launchdarkly.sdktest.Representations.AliasEventParams; import com.launchdarkly.sdktest.Representations.CommandParams; @@ -45,8 +44,6 @@ public class SdkClientEntity { public SdkClientEntity(Application application, CreateInstanceParams params) { Timber.i("Creating client for %s", params.tag); - LDClientControl.resetInstances(); - Timber.i("Reset global state to allow for another client"); LDConfig config = buildSdkConfig(params.configuration); // Each new client will plant a new Timber tree, so we uproot any existing ones // to avoid spamming stdout with duplicate log lines @@ -70,6 +67,9 @@ public SdkClientEntity(Application application, CreateInstanceParams params) { this.client = LDClient.get(); if (!client.isInitialized() && !params.configuration.initCanFail) { // If `initCanFail` is true, we can proceed with an uninitialized client + try { + client.close(); + } catch (IOException e) {} throw new RuntimeException("client initialization failed or timed out"); } } catch (LaunchDarklyException e) { @@ -171,7 +171,11 @@ private EvaluateAllFlagsResponse doEvaluateAll(EvaluateAllFlagsParams params) { } private void doIdentifyEvent(IdentifyEventParams params) { - client.identify(params.user); + try { + client.identify(params.user).get(); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException("Error waiting for identify", e); + } } private void doCustomEvent(CustomEventParams params) { diff --git a/launchdarkly-android-client-sdk/build.gradle b/launchdarkly-android-client-sdk/build.gradle index 2353751e..bc652b9c 100644 --- a/launchdarkly-android-client-sdk/build.gradle +++ b/launchdarkly-android-client-sdk/build.gradle @@ -55,7 +55,7 @@ configurations { ext {} ext.versions = [ "androidAnnotation": "1.2.0", - "eventsource": "1.11.2", + "eventsource": "1.11.3", "gson": "2.8.9", "jacksonCore": "2.10.5", "jacksonDatabind": "2.10.5.1", diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/DefaultUserManagerTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/DefaultUserManagerTest.java index 127cf04a..80bf0bd2 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/DefaultUserManagerTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/DefaultUserManagerTest.java @@ -195,8 +195,11 @@ public void testDeleteFlag() throws ExecutionException { AwaitableCallback deleteAwait = new AwaitableCallback<>(); userManager.deleteCurrentUserFlag("{\"key\":\"stringFlag1\",\"version\":16}", deleteAwait); deleteAwait.await(); - assertNull(flagStore.getFlag("stringFlag1")); - assertEquals(true, flagStore.getFlag("boolFlag1").getValue().booleanValue()); + Flag updated = flagStore.getFlag("stringFlag1"); + assertNotNull(updated); + assertEquals("stringFlag1", updated.getKey()); + assertEquals(16, updated.getVersion()); + assertTrue(updated.isDeleted()); deleteAwait.reset(); userManager.deleteCurrentUserFlag("{\"key\":\"nonExistentFlag\",\"version\":16,\"value\":false}", deleteAwait); @@ -274,7 +277,8 @@ public void testDeleteWithVersion() throws ExecutionException { userManager.deleteCurrentUserFlag("{\"key\":\"stringFlag1\",\"version\":127}", awaitableCallback); awaitableCallback.await(); awaitableCallback.reset(); - assertNull(flagStore.getFlag("stringFlag1")); + assertNotNull(flagStore.getFlag("stringFlag1")); + assertTrue(flagStore.getFlag("stringFlag1").isDeleted()); userManager.deleteCurrentUserFlag("{\"key\":\"nonExistent\",\"version\":1}", awaitableCallback); awaitableCallback.await(); @@ -323,32 +327,13 @@ public void testPatchSucceedsForMissingVersionInPatch() throws ExecutionExceptio FlagStore flagStore = userManager.getCurrentUserFlagStore(); AwaitableCallback awaitableCallback = new AwaitableCallback<>(); - // 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\"}", awaitableCallback); - awaitableCallback.await(); - awaitableCallback.reset(); - Flag flag1 = flagStore.getFlag("flag1"); - assertEquals("value-from-patch", flag1.getValue().stringValue()); - assertNull(flag1.getVersion()); - - //// case 2: value exists in shared preferences without version. - userManager.putCurrentUserFlags("{\"flag1\": {\"value\": \"value1\"}}", null); - userManager.patchCurrentUserFlags("{\"key\":\"flag1\",\"value\":\"value-from-patch\"}", awaitableCallback); - awaitableCallback.await(); - awaitableCallback.reset(); - flag1 = flagStore.getFlag("flag1"); - assertEquals("value-from-patch", flag1.getValue().stringValue()); - 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}", awaitableCallback); awaitableCallback.await(); awaitableCallback.reset(); - flag1 = flagStore.getFlag("flag1"); + Flag flag1 = flagStore.getFlag("flag1"); assertEquals("value-from-patch", flag1.getValue().stringValue()); assertEquals(558, (int) flag1.getVersion()); assertEquals(3, (int) flag1.getFlagVersion()); @@ -361,21 +346,9 @@ public void testPatchSucceedsForMissingVersionInPatch() throws ExecutionExceptio awaitableCallback.reset(); flag1 = flagStore.getFlag("flag1"); assertEquals("value-from-patch", flag1.getValue().stringValue()); - assertEquals(558, (int) flag1.getVersion()); - assertEquals(3, (int) flag1.getFlagVersion()); - assertEquals(3, (int) 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}}", null); - userManager.patchCurrentUserFlags("{\"key\":\"flag1\",\"value\":\"value-from-patch\"}", awaitableCallback); - awaitableCallback.await(); - awaitableCallback.reset(); - flag1 = flagStore.getFlag("flag1"); - assertEquals("value-from-patch", flag1.getValue().stringValue()); - assertNull(flag1.getVersion()); - assertNull(flag1.getFlagVersion()); - assertNull(flag1.getVersionForEvents()); + assertEquals(558, flag1.getVersion()); + assertEquals(Integer.valueOf(3), flag1.getFlagVersion()); + assertEquals(3, flag1.getVersionForEvents()); // version exists in shared preferences and patch. // --------------------------- @@ -384,9 +357,9 @@ public void testPatchSucceedsForMissingVersionInPatch() throws ExecutionExceptio awaitableCallback.await(); flag1 = flagStore.getFlag("flag1"); assertEquals("value-from-patch", flag1.getValue().stringValue()); - assertEquals(559, (int) flag1.getVersion()); - assertEquals(3, (int) flag1.getFlagVersion()); - assertEquals(3, (int) flag1.getVersionForEvents()); + assertEquals(559, flag1.getVersion()); + assertEquals(Integer.valueOf(3), flag1.getFlagVersion()); + assertEquals(3, flag1.getVersionForEvents()); } @Test diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/FlagBuilder.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/FlagBuilder.java index 6c7b5475..39c335ba 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/FlagBuilder.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/FlagBuilder.java @@ -10,7 +10,7 @@ public class FlagBuilder { @NonNull private String key; private LDValue value = null; - private Integer version = null; + private int version; private Integer flagVersion = null; private Integer variation = null; private Boolean trackEvents = null; @@ -27,7 +27,7 @@ public FlagBuilder value(LDValue value) { return this; } - public FlagBuilder version(Integer version) { + public FlagBuilder version(int version) { this.version = version; return this; } diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/FlagStoreTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/FlagStoreTest.java index a2f1bf58..70c7cdd5 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/FlagStoreTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/FlagStoreTest.java @@ -51,7 +51,7 @@ public boolean matches(Object argument) { 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.isTrackEvents(), received.isTrackEvents()) && Objects.equals(flag.isTrackReason(), received.isTrackReason()) && Objects.equals(flag.getDebugEventsUntilDate(), received.getDebugEventsUntilDate()) && @@ -79,7 +79,7 @@ private void assertExpectedFlag(Flag expected, Flag received) { assertEquals(expected.getVersion(), received.getVersion()); assertEquals(expected.getFlagVersion(), received.getFlagVersion()); assertEquals(expected.getVariation(), received.getVariation()); - assertEquals(expected.getTrackEvents(), received.getTrackEvents()); + assertEquals(expected.isTrackEvents(), received.isTrackEvents()); assertEquals(expected.isTrackReason(), received.isTrackReason()); assertEquals(expected.getDebugEventsUntilDate(), received.getDebugEventsUntilDate()); assertEquals(expected.getReason(), received.getReason()); @@ -204,7 +204,7 @@ public void mockFlagDeleteBehavior() { @Test public void testUnregisterStoreUpdate() { - final Flag initialFlag = new FlagBuilder("flag").build(); + final Flag initialFlag = new FlagBuilder("flag").version(10).build(); final FlagUpdate mockCreate = strictMock(FlagUpdate.class); expect(mockCreate.flagToUpdate()).andReturn("flag"); diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientTest.java index 7c235dfe..7457f2f5 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientTest.java @@ -602,7 +602,7 @@ public void variationFlagTrackReasonGeneratesEventWithReason() throws IOExceptio TestUtil.markMigrationComplete(application); EvaluationReason testReason = EvaluationReason.off(); FlagStore flagStore = new SharedPrefsFlagStoreFactory(application).createFlagStore(mobileKey + DefaultUserManager.sharedPrefs(ldUser)); - flagStore.applyFlagUpdate(new FlagBuilder("track-reason-flag").trackEvents(true).trackReason(true).reason(testReason).build()); + flagStore.applyFlagUpdate(new FlagBuilder("track-reason-flag").version(10).trackEvents(true).trackReason(true).reason(testReason).build()); try (LDClient client = LDClient.init(application, ldConfig, ldUser, 0)) { client.boolVariation("track-reason-flag", false); @@ -615,7 +615,7 @@ public void variationFlagTrackReasonGeneratesEventWithReason() throws IOExceptio assertEquals("track-reason-flag", event.key); assertEquals("userKey", event.userKey); assertNull(event.variation); - assertNull(event.version); + assertEquals(Integer.valueOf(10), event.version); assertFalse(event.value.booleanValue()); assertFalse(event.defaultVal.booleanValue()); assertEquals(testReason, event.reason); diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/SharedPrefsFlagStoreTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/SharedPrefsFlagStoreTest.java index 285b0e7b..9fcab3cc 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/SharedPrefsFlagStoreTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/SharedPrefsFlagStoreTest.java @@ -15,6 +15,9 @@ import java.util.Collections; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; @RunWith(AndroidJUnit4.class) public class SharedPrefsFlagStoreTest extends FlagStoreTest { @@ -34,51 +37,60 @@ public FlagStore createFlagStore(String identifier) { } @Test - public void deletesVersions() { + public void deletesVersionAndStoresDeletedItemPlaceholder() { 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)); + flagStore.applyFlagUpdate(new DeleteFlagResponse(key1.getKey(), 13)); - Assert.assertNull(flagStore.getFlag(key1.getKey())); + Flag updated = flagStore.getFlag(key1.getKey()); + assertNotNull(updated); + assertEquals(key1.getKey(), updated.getKey()); + assertEquals(13, updated.getVersion()); + assertTrue(updated.isDeleted()); } @Test - public void updatesVersions() { + public void doesNotDeleteIfDeletionVersionIsLessThanOrEqualToExistingVersion() { 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); + flagStore.applyFlagUpdate(new DeleteFlagResponse(key1.getKey(), 11)); + flagStore.applyFlagUpdate(new DeleteFlagResponse(key1.getKey(), 12)); + + Flag updated = flagStore.getFlag(key1.getKey()); + assertEquals(key1.getKey(), updated.getKey()); + assertEquals(key1.getVersion(), updated.getVersion()); + assertEquals(key1.getValue(), updated.getValue()); + assertFalse(updated.isDeleted()); } @Test - public void deletesFlagVersions() { - final Flag key1 = new FlagBuilder("key1").flagVersion(12).build(); + 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(new DeleteFlagResponse(key1.getKey(), null)); - Assert.assertNull(flagStore.getFlag(key1.getKey())); + flagStore.applyFlagUpdate(updatedKey1); + + assertEquals(15, flagStore.getFlag(key1.getKey()).getVersion()); } @Test public void updatesFlagVersions() { - final Flag key1 = new FlagBuilder("key1").flagVersion(12).build(); - final Flag updatedKey1 = new FlagBuilder(key1.getKey()).flagVersion(15).build(); + final Flag key1 = new FlagBuilder("key1").version(100).flagVersion(12).build(); + final Flag updatedKey1 = new FlagBuilder(key1.getKey()).version(101).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); + assertEquals(Integer.valueOf(15), flagStore.getFlag(key1.getKey()).getFlagVersion()); } @Test diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultEventProcessor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultEventProcessor.java index e557376a..d4ca4160 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultEventProcessor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultEventProcessor.java @@ -98,7 +98,9 @@ public void close() { } public void flush() { - Executors.newSingleThreadExecutor().execute(consumer); + if (scheduler != null) { + scheduler.schedule(consumer, 0, TimeUnit.MILLISECONDS); + } } @VisibleForTesting diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DeleteFlagResponse.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DeleteFlagResponse.java index b5bcd102..bae14b19 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DeleteFlagResponse.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DeleteFlagResponse.java @@ -3,24 +3,25 @@ class DeleteFlagResponse implements FlagUpdate { private final String key; - private final Integer version; + private final int version; - DeleteFlagResponse(String key, Integer version) { + DeleteFlagResponse(String key, int 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. + * Returns an updated version of the flag that is in a deleted state, if the update is valid + * (has a higher version than any existing version in the store), otherwise returns the + * existing flag. * * @param before An existing Flag associated with flagKey from flagToUpdate() - * @return null, or the before flag. + * @return the new Flag state */ @Override public Flag updateFlag(Flag before) { - if (before == null || version == null || before.isVersionMissing() || version > before.getVersion()) { - return null; + if (before == null || this.version > before.getVersion()) { + return Flag.deletedItemPlaceholder(key, version); } return before; } @@ -29,4 +30,8 @@ public Flag updateFlag(Flag before) { public String flagToUpdate() { return key; } + + public int getVersion() { + return version; + } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Flag.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Flag.java index a3d2ddfc..6baac192 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Flag.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Flag.java @@ -10,15 +10,16 @@ class Flag implements FlagUpdate { @NonNull private final String key; private final LDValue value; - private final Integer version; + private final int version; private final Integer flagVersion; private final Integer variation; private final Boolean trackEvents; private final Boolean trackReason; private final Long debugEventsUntilDate; private final EvaluationReason reason; + private final Boolean deleted; - Flag(@NonNull String key, LDValue value, Integer version, Integer flagVersion, Integer variation, Boolean trackEvents, Boolean trackReason, Long debugEventsUntilDate, EvaluationReason reason) { + private Flag(@NonNull String key, LDValue value, int version, Integer flagVersion, Integer variation, Boolean trackEvents, Boolean trackReason, Long debugEventsUntilDate, EvaluationReason reason, boolean deleted) { this.key = key; this.value = value; this.version = version; @@ -28,9 +29,17 @@ class Flag implements FlagUpdate { this.trackReason = trackReason; this.debugEventsUntilDate = debugEventsUntilDate; this.reason = reason; + this.deleted = deleted ? Boolean.valueOf(true) : null; + } + + Flag(@NonNull String key, LDValue value, int version, Integer flagVersion, Integer variation, Boolean trackEvents, Boolean trackReason, Long debugEventsUntilDate, EvaluationReason reason) { + this(key, value, version, flagVersion, variation, trackEvents, trackReason, debugEventsUntilDate, reason, false); + } + + static Flag deletedItemPlaceholder(@NonNull String key, int version) { + return new Flag(key, null, version, null, null, null, null, null, null, true); } - @NonNull String getKey() { return key; } @@ -41,7 +50,7 @@ LDValue getValue() { return LDValue.normalize(value); } - Integer getVersion() { + int getVersion() { return version; } @@ -53,11 +62,11 @@ Integer getVariation() { return variation; } - boolean getTrackEvents() { - return trackEvents == null ? false : trackEvents; + boolean isTrackEvents() { + return trackEvents != null && trackEvents.booleanValue(); } - boolean isTrackReason() { return trackReason == null ? false : trackReason; } + boolean isTrackReason() { return trackReason != null && trackReason.booleanValue(); } Long getDebugEventsUntilDate() { return debugEventsUntilDate; @@ -67,20 +76,17 @@ EvaluationReason getReason() { return reason; } - boolean isVersionMissing() { - return version == null; + int getVersionForEvents() { + return flagVersion == null ? version : flagVersion.intValue(); } - Integer getVersionForEvents() { - if (flagVersion == null) { - return version; - } - return flagVersion; + boolean isDeleted() { + return deleted != null && deleted.booleanValue(); } @Override public Flag updateFlag(Flag before) { - if (before == null || this.isVersionMissing() || before.isVersionMissing() || this.getVersion() > before.getVersion()) { + if (before == null || this.getVersion() > before.getVersion()) { return this; } return before; diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java index bfa802a1..327b2e2b 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android; import android.content.Context; +import android.net.Uri; import androidx.annotation.NonNull; @@ -107,7 +108,8 @@ public void onResponse(@NonNull Call call, @NonNull final Response response) { } private Request getDefaultRequest(LDUser user) { - String uri = config.getPollUri() + "/msdk/evalx/users/" + DefaultUserManager.base64Url(user); + String uri = Uri.withAppendedPath(config.getPollUri(), "msdk/evalx/users/").toString() + + DefaultUserManager.base64Url(user); if (config.isEvaluationReasons()) { uri += "?withReasons=true"; } @@ -118,7 +120,7 @@ private Request getDefaultRequest(LDUser user) { } private Request getReportRequest(LDUser user) { - String reportUri = config.getPollUri() + "/msdk/evalx/user"; + String reportUri = Uri.withAppendedPath(config.getPollUri(), "msdk/evalx/user").toString(); if (config.isEvaluationReasons()) { reportUri += "?withReasons=true"; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index 07323034..5ee5c5af 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -235,14 +235,15 @@ public static LDClient get() throws LaunchDarklyException { */ @SuppressWarnings("WeakerAccess") public static LDClient getForMobileKey(String keyName) throws LaunchDarklyException { - if (instances == null) { + Map instancesNow = instances; // ensures atomicity + if (instancesNow == null) { LDConfig.log().e("LDClient.getForMobileKey() was called before init()!"); throw new LaunchDarklyException("LDClient.getForMobileKey() was called before init()!"); } - if (!(instances.containsKey(keyName))) { + if (!(instancesNow.containsKey(keyName))) { throw new LaunchDarklyException("LDClient.getForMobileKey() called with invalid keyName"); } - return instances.get(keyName); + return instancesNow.get(keyName); } @VisibleForTesting @@ -312,7 +313,23 @@ public Future identify(LDUser user) { if (user.getKey() == null) { LDConfig.log().w("identify called with null user or null user key!"); } - return LDClient.identifyInstances(customizeUser(user)); + return identifyInstances(customizeUser(user)); + } + + private @NonNull Map getInstancesIfTheyIncludeThisClient() { + // Using this method ensures that 1. we are operating on an atomic snapshot of the + // instances (in the unlikely case that they get closed & recreated right around now) and + // 2. we do *not* operate on these instances if the current client is not one of them (i.e. + // if it's already been closed). This method is guaranteed never to return null. + Map ret = instances; + if (ret != null) { + for (LDClient c: ret.values()) { + if (c == this) { + return ret; + } + } + } + return Collections.emptyMap(); } private void identifyInternal(@NonNull LDUser user, @@ -328,9 +345,10 @@ private void identifyInternal(@NonNull LDUser user, sendEvent(new IdentifyEvent(user)); } - private static Future identifyInstances(@NonNull LDUser user) { + private Future identifyInstances(@NonNull LDUser user) { final LDAwaitFuture resultFuture = new LDAwaitFuture<>(); - final AtomicInteger identifyCounter = new AtomicInteger(instances.size()); + final Map instancesNow = getInstancesIfTheyIncludeThisClient(); + final AtomicInteger identifyCounter = new AtomicInteger(instancesNow.size()); LDUtil.ResultCallback completeWhenCounterZero = new LDUtil.ResultCallback() { @Override public void onSuccess(Void result) { @@ -345,7 +363,7 @@ public void onError(Throwable e) { } }; - for (LDClient client : instances.values()) { + for (LDClient client : instancesNow.values()) { client.identifyInternal(user, completeWhenCounterZero); } @@ -357,7 +375,9 @@ public Map allFlags() { Collection allFlags = userManager.getCurrentUserFlagStore().getAllFlags(); HashMap flagValues = new HashMap<>(); for (Flag flag: allFlags) { - flagValues.put(flag.getKey(), flag.getValue()); + if (!flag.isDeleted()) { + flagValues.put(flag.getKey(), flag.getValue()); + } } return flagValues; } @@ -421,22 +441,22 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ EvaluationDetail result; LDValue value = defaultValue; - if (flag == null) { + if (flag == null || flag.isDeleted()) { LDConfig.log().i("Unknown feature flag \"%s\"; returning default value", key); result = EvaluationDetail.fromValue(defaultValue, EvaluationDetail.NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); } else { value = flag.getValue(); + int variation = flag.getVariation() == null ? EvaluationDetail.NO_VARIATION : flag.getVariation(); if (value.isNull()) { LDConfig.log().w("Feature flag \"%s\" retrieved with no value; returning default value", key); value = defaultValue; - int variation = flag.getVariation() == null ? EvaluationDetail.NO_VARIATION : flag.getVariation(); result = EvaluationDetail.fromValue(defaultValue, variation, flag.getReason()); } else if (checkType && !defaultValue.isNull() && value.getType() != defaultValue.getType()) { LDConfig.log().w("Feature flag \"%s\" with type %s retrieved as %s; returning default value", key, value.getType(), defaultValue.getType()); value = defaultValue; result = EvaluationDetail.fromValue(defaultValue, EvaluationDetail.NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)); } else { - result = EvaluationDetail.fromValue(value, flag.getVariation(), flag.getReason()); + result = EvaluationDetail.fromValue(value, variation, flag.getReason()); } sendFlagRequestEvent(key, flag, value, defaultValue, flag.isTrackReason() | needsReason ? result.getReason() : null); } @@ -453,7 +473,7 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ */ @Override public void close() throws IOException { - LDClient.closeInstances(); + closeInstances(); } private void closeInternal() { @@ -466,27 +486,28 @@ private void closeInternal() { } } - private static void closeInstances() { - for (LDClient client : instances.values()) { + private void closeInstances() { + Iterable oldClients; + synchronized (initLock) { + oldClients = getInstancesIfTheyIncludeThisClient().values(); + instances = null; + } + for (LDClient client : oldClients) { client.closeInternal(); } } @Override public void flush() { - LDClient.flushInstances(); + for (LDClient client : getInstancesIfTheyIncludeThisClient().values()) { + client.flushInternal(); + } } private void flushInternal() { eventProcessor.flush(); } - private static void flushInstances() { - for (LDClient client : instances.values()) { - client.flushInternal(); - } - } - @VisibleForTesting void blockingFlush() { eventProcessor.blockingFlush(); @@ -504,34 +525,26 @@ public boolean isOffline() { @Override public void setOffline() { - LDClient.setInstancesOffline(); + for (LDClient client : getInstancesIfTheyIncludeThisClient().values()) { + client.setOfflineInternal(); + } } private void setOfflineInternal() { connectivityManager.setOffline(); } - private static void setInstancesOffline() { - for (LDClient client : instances.values()) { - client.setOfflineInternal(); - } - } - @Override public void setOnline() { - setOnlineStatusInstances(); + for (LDClient client : getInstancesIfTheyIncludeThisClient().values()) { + client.setOnlineStatusInternal(); + } } private void setOnlineStatusInternal() { connectivityManager.setOnline(); } - private static void setOnlineStatusInstances() { - for (LDClient client : instances.values()) { - client.setOnlineStatusInternal(); - } - } - @Override public void registerFeatureFlagListener(String flagKey, FeatureFlagChangeListener listener) { userManager.registerListener(flagKey, listener); @@ -639,9 +652,9 @@ private void onNetworkConnectivityChange(boolean connectedToInternet) { } private void sendFlagRequestEvent(String flagKey, Flag flag, LDValue value, LDValue defaultValue, EvaluationReason reason) { - Integer version = flag.getVersionForEvents(); + int version = flag.getVersionForEvents(); Integer variation = flag.getVariation(); - if (flag.getTrackEvents()) { + if (flag.isTrackEvents()) { sendEvent(new FeatureRequestEvent(flagKey, userManager.getCurrentUser(), value, defaultValue, version, variation, reason, config.inlineUsersInEvents(), false)); } else { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java index 5741715d..fbdbf9d5 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java @@ -220,7 +220,10 @@ public void write(JsonWriter out, LDUser user) throws IOException { out.beginObject(); out.name("key").value(user.getKey()); - out.name("anonymous").value(user.isAnonymous()); + + if (!user.getAttribute(UserAttribute.ANONYMOUS).isNull()) { + out.name("anonymous").value(user.isAnonymous()); + } for (UserAttribute attrib : OPTIONAL_BUILTINS) { safeWrite(out, user, attrib, privateAttrs); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamUpdateProcessor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamUpdateProcessor.java index db6f7199..250f7bb0 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamUpdateProcessor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamUpdateProcessor.java @@ -20,6 +20,8 @@ import static com.launchdarkly.sdk.android.LDConfig.GSON; import static com.launchdarkly.sdk.android.LDConfig.JSON; +import android.net.Uri; + class StreamUpdateProcessor { private static final String METHOD_REPORT = "REPORT"; @@ -151,7 +153,7 @@ private RequestBody getRequestBody(@Nullable LDUser user) { } private URI getUri(@Nullable LDUser user) { - String str = config.getStreamUri().toString() + "/meval"; + String str = Uri.withAppendedPath(config.getStreamUri(), "meval").toString(); if (!config.isUseReport() && user != null) { str += "/" + DefaultUserManager.base64Url(user); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Throttler.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Throttler.java index 880829af..ec1b2193 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Throttler.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Throttler.java @@ -2,7 +2,7 @@ import androidx.annotation.NonNull; -import java.util.Random; +import java.security.SecureRandom; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -20,7 +20,7 @@ class Throttler { private final long retryTimeMs; private final long maxRetryTimeMs; - private final Random jitter = new Random(); + private final SecureRandom jitter = new SecureRandom(); private final AtomicInteger attempts = new AtomicInteger(-1); private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); @@ -80,7 +80,7 @@ private int pow2(int k) { // Adapted from http://stackoverflow.com/questions/2546078/java-random-long-number-in-0-x-n-range // Since ThreadLocalRandom.current().nextLong(n) requires Android 5 - private long nextLong(Random rand, long bound) { + private long nextLong(SecureRandom rand, long bound) { if (bound <= 0) { throw new IllegalArgumentException("bound must be positive"); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DeleteFlagResponseTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DeleteFlagResponseTest.java index 91c36400..ddd64d78 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DeleteFlagResponseTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DeleteFlagResponseTest.java @@ -6,12 +6,12 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; public class DeleteFlagResponseTest { private static final Gson gson = GsonCache.getGson(); - private static final String jsonWithoutVersion = "{\"key\": \"flag\"}"; private static final String jsonWithNullVersion = "{\"key\": \"flag2\", \"version\": null}"; private static final String jsonWithVersion = "{\"key\": \"flag\", \"version\": 50}"; private static final String jsonWithExtraElement = "{\"key\": \"flag\", \"version\": 100, \"extra\": [1,2,3]}"; @@ -19,15 +19,13 @@ public class DeleteFlagResponseTest { @Test public void constructor() { // Cannot check flag version, as the field is not exposed. - assertNull((new DeleteFlagResponse(null, null)).flagToUpdate()); - assertEquals("test", (new DeleteFlagResponse("test", null)).flagToUpdate()); + assertNull((new DeleteFlagResponse(null, 1)).flagToUpdate()); + assertEquals("test", (new DeleteFlagResponse("test", 1)).flagToUpdate()); } @Test public void deleteFlagResponseKeyIsDeserialized() { DeleteFlagResponse result; - result = gson.fromJson(jsonWithoutVersion, DeleteFlagResponse.class); - assertEquals("flag", result.flagToUpdate()); result = gson.fromJson(jsonWithNullVersion, DeleteFlagResponse.class); assertEquals("flag2", result.flagToUpdate()); result = gson.fromJson(jsonWithVersion, DeleteFlagResponse.class); @@ -39,23 +37,25 @@ public void deleteFlagResponseKeyIsDeserialized() { @Test public void testUpdateFlag() { // Create delete flag responses from json to verify version is deserialized - DeleteFlagResponse deleteNoVersion = gson.fromJson(jsonWithoutVersion, DeleteFlagResponse.class); DeleteFlagResponse deleteLowVersion = gson.fromJson(jsonWithVersion, DeleteFlagResponse.class); DeleteFlagResponse deleteHighVersion = gson.fromJson(jsonWithExtraElement, DeleteFlagResponse.class); - Flag flagNoVersion = new FlagBuilder("flag").build(); Flag flagLowVersion = new FlagBuilder("flag").version(50).build(); 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)); + + assertDeletedItemPlaceholder(deleteLowVersion.updateFlag(null), + deleteLowVersion.flagToUpdate(), deleteLowVersion.getVersion()); + assertDeletedItemPlaceholder(deleteHighVersion.updateFlag(null), + deleteHighVersion.flagToUpdate(), deleteHighVersion.getVersion()); + assertDeletedItemPlaceholder(deleteHighVersion.updateFlag(flagLowVersion), + deleteHighVersion.flagToUpdate(), deleteHighVersion.getVersion()); + } + + private static void assertDeletedItemPlaceholder(Flag f, String key, int version) { + assertEquals(key, f.getKey()); + assertEquals(version, f.getVersion()); + assertTrue(f.isDeleted()); } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FlagBuilder.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FlagBuilder.java index 6c7b5475..857b4f6a 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FlagBuilder.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FlagBuilder.java @@ -10,7 +10,7 @@ public class FlagBuilder { @NonNull private String key; private LDValue value = null; - private Integer version = null; + private int version = 0; private Integer flagVersion = null; private Integer variation = null; private Boolean trackEvents = null; @@ -27,7 +27,7 @@ public FlagBuilder value(LDValue value) { return this; } - public FlagBuilder version(Integer version) { + public FlagBuilder version(int version) { this.version = version; return this; } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FlagTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FlagTest.java index 881d454c..2438a0f4 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FlagTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FlagTest.java @@ -119,13 +119,6 @@ public void versionIsDeserialized() { 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(); @@ -180,14 +173,14 @@ public void trackEventsIsSerialized() { public void trackEventsIsDeserialized() { final String jsonStr = "{\"version\": 99, \"trackEvents\": true}"; final Flag r = gson.fromJson(jsonStr, Flag.class); - assertTrue(r.getTrackEvents()); + assertTrue(r.isTrackEvents()); } @Test public void trackEventsDefaultWhenOmitted() { final String jsonStr = "{\"version\": 99}"; final Flag r = gson.fromJson(jsonStr, Flag.class); - assertFalse(r.getTrackEvents()); + assertFalse(r.isTrackEvents()); } @Test @@ -285,24 +278,21 @@ public void emptyPropertiesAreNotSerialized() { } @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()); + public void testIsDeleted() { + final Flag normalFlag = new FlagBuilder("flag").version(10).build(); + final Flag placeholder = Flag.deletedItemPlaceholder("flag", 10); + assertFalse(normalFlag.isDeleted()); + assertTrue(placeholder.isDeleted()); + assertEquals(normalFlag.getVersion(), placeholder.getVersion()); } @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(); + final Flag withVersionAndFlagVersion = new FlagBuilder("flag").version(10).flagVersion(5).build(); - assertNull(noVersions.getVersionForEvents()); - assertEquals(10, (int) withVersion.getVersionForEvents()); - assertEquals(5, (int) withFlagVersion.getVersionForEvents()); - assertEquals(5, (int) withBothVersions.getVersionForEvents()); + assertEquals(10, withVersion.getVersionForEvents()); + assertEquals(5, withVersionAndFlagVersion.getVersionForEvents()); } @Test @@ -313,17 +303,11 @@ public void flagToUpdateReturnsKey() { @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/testharness-suppressions.txt b/testharness-suppressions.txt index 8667c169..e69de29b 100644 --- a/testharness-suppressions.txt +++ b/testharness-suppressions.txt @@ -1,16 +0,0 @@ -# sc-159880 (sdk bug) - NullPointerException on null variation id -# sc-160002 (sdk bug) - doesn't support initialRetryDelayMs -streaming/updates/ -# sc-159579 (test harness bug) - `device` and `os` user properties -streaming/requests/user properties/GET -streaming/requests/user properties/REPORT -polling/requests/user properties/GET -polling/requests/user properties/REPORT -# sc-159583 (sdk bug) - `anonymous: false` in events -# sc-159579 (test harness bug) - `device` and `os` user properties -events/user properties/ -# sc-159578 (sdk bug) - trailing slashes not handled properly -streaming/requests/URL path is computed correctly/base URI has a trailing slash/GET -streaming/requests/URL path is computed correctly/base URI has a trailing slash/REPORT -polling/requests/URL path is computed correctly/base URI has a trailing slash/GET -polling/requests/URL path is computed correctly/base URI has a trailing slash/REPORT